diff --git a/extensions/http-functions/README.md b/extensions/http-functions/README.md new file mode 100644 index 00000000..f1434e51 --- /dev/null +++ b/extensions/http-functions/README.md @@ -0,0 +1,340 @@ +# DynamiaTools HTTP Functions Extension + +## Overview + +The DynamiaTools HTTP Functions Extension provides a declarative, versioned function runtime +exposed over HTTP. It lets applications define, register, and execute reusable business +capabilities as versioned functions, without coupling callers to a specific implementation of +"how to call WhatsApp" / "how to generate this PDF" / "how to call that third-party API". + +Instead of hardcoding a REST client for every external integration, you configure a +`DynamiaHttpFunction` once (method, URL, body template, headers) and call it by name from Java +code or straight over HTTP. Credentials, endpoints and payload shapes can change without touching +application code or redeploying. + +It acts as an internal capability bus for Dynamia-based applications, and provides: + +- Versioned function contracts (`name` + `functionVersion`) +- HTTP-based invocation (`POST /api/dynamia/fx/{functionName}`) and direct Java invocation + (`DynamiaFunctions.call(...)`) +- Parameter validation and type coercion before execution +- JSON and binary responses (images, PDFs, CSV, etc.) +- Deterministic version resolution (highest `ACTIVE` version by default) +- A back office UI to register functions and test them without leaving the browser + +------------------------------------------------------------------------ + +## Getting Started + +### 1. Add the dependency + +```xml + + tools.dynamia.modules + tools.dynamia.modules.functions.core + 26.6.0 + + + + + tools.dynamia.modules + tools.dynamia.modules.functions.ui + 26.6.0 + +``` + +No extra configuration is required: `DynamiaHttpFunctionsServiceImpl` and the REST controller are +auto-registered as long as the module is on the classpath (standard DynamiaTools `@Service` / +component scanning). + +### 2. Register a function + +You can create it through the back office UI (`Http Functions` page, once the `ui` module is on +the classpath), or programmatically: + +```java +DynamiaHttpFunction function = new DynamiaHttpFunction(); +function.setName("WhatsApp.sendMessage"); +function.setFunctionVersion(1); +function.setMethod(HttpMethod.POST); +function.setUrl("https://api.whatsapp.example.com/v1/messages"); +function.setContentType("application/json"); +function.setBodyTemplate("{\"to\":\"${number}\",\"text\":\"${message}\"}"); +function.setHeaders("Authorization: Bearer ${apiToken}"); +function.setStatus(FunctionStatus.ACTIVE); + +DynamiaHttpFunctionParameter number = new DynamiaHttpFunctionParameter(); +number.setName("number"); +number.setRequired(true); +function.addParameter(number); + +DynamiaHttpFunctionParameter message = new DynamiaHttpFunctionParameter(); +message.setName("message"); +message.setRequired(true); +function.addParameter(message); + +crudService.create(function); +``` + +### 3. Call it + +From Java: + +```java +FunctionResult result = DynamiaFunctions.call("WhatsApp.sendMessage", + Map.of("number", "123456789", "message", "Hello")); + +if (result.isSuccess()) { + Map data = result.toJson(); + // or, mapped straight into a DTO: + // SendMessageResponse dto = result.toJson(SendMessageResponse.class); +} +``` + +Or over HTTP: + +```http +POST /api/dynamia/fx/WhatsApp.sendMessage +Content-Type: application/json + +{ "params": { "number": "123456789", "message": "Hello" } } +``` + +That's it — no client class, no manual `RestTemplate`/`RestClient` wiring, no hardcoded URL in +your service code. + +------------------------------------------------------------------------ + +## Core Concepts + +### 1. `DynamiaHttpFunction` + +Represents a versioned function definition. Each function includes: + +| Field | Description | +|-------------------|--------------------------------------------------------------------------------------------| +| `name` | Function identifier, e.g. `WhatsApp.sendMessage`. Free-form, but a `Namespace.action` convention is recommended. | +| `functionVersion` | Integer, starts at 1. | +| `method` | HTTP method used for the outbound call (`HttpMethod`). | +| `url` | Target URL. Supports `${param}` placeholders. | +| `contentType` | Content type sent with the request body. Defaults to `application/json`. | +| `bodyTemplate` | Request body template. Supports `${param}` placeholders. | +| `headers` | Optional, one `Header-Name: value` per line. Supports `${param}` placeholders — the usual way to inject API keys/tokens. | +| `status` | `DRAFT`, `ACTIVE`, `INACTIVE`, `DELETED`. Only `ACTIVE` functions can be called. | +| `metadata` | Optional free-form JSON, informative/extensible only — not used by the execution engine. | +| `parameters` | List of `DynamiaHttpFunctionParameter` (see below). | + +`url`, `bodyTemplate` and `headers` are rendered with the call parameters before the request is +issued, using `${paramName}` placeholders (e.g. `{"to":"${number}","text":"${message}"}`). + +Constraint: `(name, functionVersion)` is unique per account, enforced both by a database index and +by `DynamiaHttpFunctionValidator`. A function can only reach `ACTIVE` status once it has a `url`. + +### 2. `DynamiaHttpFunctionParameter` + +Declares one input parameter accepted by a function call: + +| Field | Description | +|----------------|--------------------------------------------------------------------------| +| `name` | Parameter name, matched against the `params` map at call time. | +| `type` | `STRING` (default), `NUMBER`, `BOOLEAN`, `DATE` (`yyyy-MM-dd`), `DATETIME` (`yyyy-MM-dd HH:mm:ss`). | +| `required` | When `true` and the caller doesn't provide a value (and there's no `defaultValue`), the call fails with `400`. | +| `defaultValue` | Used when the caller omits the parameter. | +| `position` | Display order in the back office UI. | + +### 3. Versioning Model + +Versioning is explicit and manual in the MVP. + +Rules: + +- Versions start at 1 +- A new version must be strictly greater than the current maximum for that `name` +- No duplicate `(name, version)` combinations +- By default, the highest **`ACTIVE`** version is executed +- Specific versions can be requested explicitly, regardless of status — useful for testing a + `DRAFT` version before promoting it to `ACTIVE` + +```java +DynamiaFunctions.call("WhatsApp.sendMessage", params); // highest ACTIVE version +DynamiaFunctions.call("WhatsApp.sendMessage", 2, params); // version 2 explicitly +``` + +### 4. Parameter Validation & Type Coercion + +Before invoking the function, declared parameters are validated against the call's `params` map: + +- Missing values fall back to `defaultValue` when declared +- Still-missing **required** parameters raise a validation error → HTTP `400` +- Present values are coerced to the declared `type` (e.g. a `NUMBER` parameter accepts a numeric + string and is parsed into a `BigDecimal`); a value that can't be coerced also raises `400` + +------------------------------------------------------------------------ + +## HTTP API + +### Base Endpoint + +``` +POST /api/dynamia/fx/{functionName} +``` + +### Optional Version Selection + +- Header: `X-Dynamia-Version: 2` +- Or query parameter: `?v=2` + +If not specified, the latest `ACTIVE` version is used. + +### Request + +```http +POST /api/dynamia/fx/WhatsApp.sendMessage +Content-Type: application/json + +{ "params": { "number": "123456789", "message": "Hello" } } +``` + +### Response + +JSON response: + +```json +{ "success": true, "data": { ... } } +``` + +Binary response — when the target endpoint returns an image/PDF/CSV/etc., the raw bytes are +streamed back with the matching `Content-Type` (e.g. `image/png`), no JSON envelope, no base64 +wrapping. + +Error response (validation, not-found, inactive, execution error): + +```json +{ "success": false, "error": "Parameter [number] is required" } +``` + +### Status Codes + +| Code | Meaning | +|------|-------------------------------------------------------------| +| 200 | Success | +| 400 | Validation error (missing/invalid parameter) | +| 401 | Authentication required *(delegated to the app's security layer, see below)* | +| 403 | Authorization failure *(delegated to the app's security layer, see below)* | +| 404 | Function or version not found, or function not `ACTIVE` | +| 500 | Internal execution error (target endpoint failed, network error, etc.) | + +------------------------------------------------------------------------ + +## Calling Functions from Java + +Functions are resolved through `DynamiaHttpFunctionsService` (registry + execution engine). The +static facade `DynamiaFunctions` is the recommended entry point for application code: + +```java +// Highest ACTIVE version +FunctionResult result = DynamiaFunctions.call("WhatsApp.sendMessage", + Map.of("number", "123456789", "message", "Hello")); + +// Specific version +FunctionResult v2 = DynamiaFunctions.call("WhatsApp.sendMessage", 2, + Map.of("number", "123456789", "message", "Hello")); + +// Auto-register a DRAFT placeholder instead of throwing FunctionNotFoundException +// when the function hasn't been configured yet — handy while wiring up new +// integrations, so calling code doesn't need a hard dependency on setup order. +FunctionResult draft = DynamiaFunctions.call("Invoicing.generatePdf", + Map.of("orderId", "123"), true); + +// Non-blocking (runs on a virtual thread) +DynamiaFunctions.callAsync("WhatsApp.sendMessage", Map.of("number", "123", "message", "Hello")) + .thenAccept(r -> log.info("sent: " + r.isSuccess())); +``` + +### Reading the result + +`FunctionResult` wraps either structured data or binary content: + +```java +FunctionResult result = DynamiaFunctions.call("WhatsApp.sendMessage", params); + +result.isSuccess(); // true/false +result.isBinary(); // true when the payload is raw bytes (image/PDF/CSV/...) +result.isJson(); // true when getData() is a Map/List + +Map data = result.toJson(); // parsed as a Map +SendMessageResponse dto = result.toJson(SendMessageResponse.class); // parsed into a DTO +byte[] file = result.getBinaryData(); // when isBinary() == true +``` + +### Error handling + +Calls can throw: + +- `FunctionNotFoundException` — no function registered for that `(name, version)` +- `FunctionInactiveException` — function exists but isn't `ACTIVE` +- `ValidationError` (`tools.dynamia.domain`) — missing/invalid parameter +- `FunctionExecutionException` — the outbound HTTP call failed (network error, non-2xx response, + etc.); wraps the original cause + +The REST controller maps these to `404`, `400` and `500` respectively (see [Status +Codes](#status-codes)). When calling from Java, catch what's relevant to your use case, or let it +bubble up if the caller should fail loudly. + +------------------------------------------------------------------------ + +## UI Module + +The `ui` submodule (`tools.dynamia.modules.functions.ui`, ZK-based) adds a back office CRUD for +`DynamiaHttpFunction` / `DynamiaHttpFunctionParameter`: + +- **Navigation**: a "Http Functions" page group is contributed to the existing `saas` module (see + `DynamiaHttpFunctionsModuleProvider`), listing all registered functions. +- **View descriptors** (`META-INF/descriptors/`): form/table/crud for the function itself, plus a + nested `crudview` for its parameters and a form/table pair for `DynamiaHttpFunctionParameter`. +- **`TestHttpFunctionAction`**: a crud action ("Test") that opens a dialog to edit call parameters + as JSON and immediately invokes the selected function through `DynamiaHttpFunctionsService`, + showing the resulting `FunctionResult` (or the validation/execution error) without leaving the + browser — the fastest way to verify a function definition before wiring it into application + code. + +------------------------------------------------------------------------ + +## Design Principles + +- Deterministic version resolution +- Immutable version contracts (a version, once created, isn't meant to be edited — cut a new one) +- Clear separation between metadata and implementation +- HTTP-native semantics +- Extensible without modifying core controllers + +------------------------------------------------------------------------ + +## Example Use Cases + +- Send WhatsApp/SMS/email notifications through a third-party provider +- Generate PDF reports or optimize images via an internal microservice +- Export data files (CSV, XLSX) +- Integrate third-party services whose credentials/endpoints vary per environment or tenant +- Trigger internal business processes exposed as internal HTTP endpoints + +------------------------------------------------------------------------ + +## Known Limitations / Roadmap + +- **401/403 enforcement is not done at the function level** — securing who can call + `/api/dynamia/fx/{functionName}` is currently delegated entirely to the application's security + layer (e.g. Spring Security rules on the base path). +- `interfaceName` / `methodName` fields exist on `DynamiaHttpFunction` reserved for a future + dynamic Java interface proxy (call a function through a regular interface method instead of + `DynamiaFunctions.call(...)`), **not implemented yet**. +- Not yet implemented: deprecation metadata, automatic version suggestions, execution + metrics/observability, response caching, script-based execution sandbox. + +------------------------------------------------------------------------ + +## Philosophy + +This extension provides a lightweight, versioned function runtime that enables controlled +evolution of business capabilities while maintaining contract stability. It is designed to support +long-term extensibility without introducing unnecessary architectural complexity. diff --git a/extensions/http-functions/sources/core/pom.xml b/extensions/http-functions/sources/core/pom.xml new file mode 100644 index 00000000..28bd7c6a --- /dev/null +++ b/extensions/http-functions/sources/core/pom.xml @@ -0,0 +1,54 @@ + + 4.0.0 + + tools.dynamia.modules + tools.dynamia.modules.parent + 26.6.0 + ../../../pom.xml + + DynamiaModules - Http Functions Core + tools.dynamia.modules.functions.core + + Core module for Http Functions, contains the main classes and interfaces to create and handle http functions, a + way to create functions that can be called from Java like normal code and return a response with the result of + the function execution. + + + + + + tools.dynamia.modules + tools.dynamia.modules.saas.jpa + 26.6.0 + + + tools.dynamia + tools.dynamia.web + 26.6.0 + + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + 5.20.0 + test + + + org.mockito + mockito-junit-jupiter + 5.20.0 + test + + + org.springframework + spring-test + test + + + diff --git a/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/DynamiaFunctions.java b/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/DynamiaFunctions.java new file mode 100644 index 00000000..47f5e9ee --- /dev/null +++ b/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/DynamiaFunctions.java @@ -0,0 +1,153 @@ +package tools.dynamia.modules.functions; + +import tools.dynamia.integration.Containers; +import tools.dynamia.integration.scheduling.SchedulerUtil; +import tools.dynamia.modules.functions.services.DynamiaHttpFunctionsService; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** + * Static entry point to call {@link tools.dynamia.modules.functions.domain.DynamiaHttpFunction}s from + * regular Java code, as if they were normal method calls, without coupling the caller to a specific + * implementation. By default, the highest active version of a function is invoked, but a specific + * version can be requested explicitly. + * + * Example: + *
{@code
+ * FunctionResult result = DynamiaFunctions.call("WhatsApp.sendMessage",
+ *         Map.of("number", "123456789", "message", "Hello"));
+ *
+ * FunctionResult v2Result = DynamiaFunctions.call("WhatsApp.sendMessage", 2,
+ *         Map.of("number", "123456789", "message", "Hello"));
+ * }
+ * + * @author Mario A. Serrano Leones + */ +public class DynamiaFunctions { + + private DynamiaFunctions() { + } + + /** + * Calls the highest active version of a function. + * + * @param name the function name (e.g. {@code WhatsApp.sendMessage}) + * @param params the call parameters + * @return the execution result + */ + public static FunctionResult call(String name, Map params) { + return service().call(name, params); + } + + /** + * Calls a specific version of a function. + * + * @param name the function name + * @param version the requested version + * @param params the call parameters + * @return the execution result + */ + public static FunctionResult call(String name, int version, Map params) { + return service().call(name, version, params); + } + + /** + * Calls the highest active version of a function, auto-registering it as a {@code DRAFT} placeholder + * when it does not exist yet, so integration code doesn't fail hard while the function is pending + * configuration (url, method, body template, etc.) in the back office. + * + * @param name the function name + * @param params the call parameters + * @param autoCreate when {@code true}, a missing function is auto-registered as {@code DRAFT} instead + * of throwing {@link FunctionNotFoundException} + * @return the execution result + */ + public static FunctionResult call(String name, Map params, boolean autoCreate) { + return service().call(name, params, autoCreate); + } + + /** + * Calls a specific version of a function, auto-registering it as a {@code DRAFT} placeholder when it + * does not exist yet. See {@link #call(String, Map, boolean)}. + * + * @param name the function name + * @param version the requested version + * @param params the call parameters + * @param autoCreate when {@code true}, a missing function is auto-registered as {@code DRAFT} instead + * of throwing {@link FunctionNotFoundException} + * @return the execution result + */ + public static FunctionResult call(String name, int version, Map params, boolean autoCreate) { + return service().call(name, version, params, autoCreate); + } + + /** + * Calls the highest active version of a function asynchronously, on a Virtual Thread (see + * {@link SchedulerUtil#runWithResult(java.util.function.Supplier)}), without blocking the caller. + * + * @param name the function name + * @param params the call parameters + * @return a future completed with the execution result, or completed exceptionally if the call fails + * + * Example: + *
{@code
+     * DynamiaFunctions.callAsync("WhatsApp.sendMessage", Map.of("number", "123", "message", "Hello"))
+     *         .thenAccept(result -> log.info("sent: " + result.isSuccess()));
+     * }
+ */ + public static CompletableFuture callAsync(String name, Map params) { + return SchedulerUtil.runWithResult(() -> call(name, params)); + } + + /** + * Calls a specific version of a function asynchronously, on a Virtual Thread. See + * {@link #callAsync(String, Map)}. + * + * @param name the function name + * @param version the requested version + * @param params the call parameters + * @return a future completed with the execution result, or completed exceptionally if the call fails + */ + public static CompletableFuture callAsync(String name, int version, Map params) { + return SchedulerUtil.runWithResult(() -> call(name, version, params)); + } + + /** + * Calls the highest active version of a function asynchronously, auto-registering it as a + * {@code DRAFT} placeholder when it does not exist yet. See {@link #callAsync(String, Map)} and + * {@link #call(String, Map, boolean)}. + * + * @param name the function name + * @param params the call parameters + * @param autoCreate when {@code true}, a missing function is auto-registered as {@code DRAFT} instead + * of failing the future with {@link FunctionNotFoundException} + * @return a future completed with the execution result, or completed exceptionally if the call fails + */ + public static CompletableFuture callAsync(String name, Map params, boolean autoCreate) { + return SchedulerUtil.runWithResult(() -> call(name, params, autoCreate)); + } + + /** + * Calls a specific version of a function asynchronously, auto-registering it as a {@code DRAFT} + * placeholder when it does not exist yet. See {@link #callAsync(String, Map, boolean)}. + * + * @param name the function name + * @param version the requested version + * @param params the call parameters + * @param autoCreate when {@code true}, a missing function is auto-registered as {@code DRAFT} instead + * of failing the future with {@link FunctionNotFoundException} + * @return a future completed with the execution result, or completed exceptionally if the call fails + */ + public static CompletableFuture callAsync(String name, int version, Map params, boolean autoCreate) { + return SchedulerUtil.runWithResult(() -> call(name, version, params, autoCreate)); + } + + private static DynamiaHttpFunctionsService service() { + DynamiaHttpFunctionsService service = Containers.get().findObject(DynamiaHttpFunctionsService.class); + if (service == null) { + throw new IllegalStateException("No " + DynamiaHttpFunctionsService.class.getName() + " implementation available"); + } + return service; + } +} diff --git a/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/DynamiaHttpFunctionValidator.java b/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/DynamiaHttpFunctionValidator.java new file mode 100644 index 00000000..45dbd1aa --- /dev/null +++ b/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/DynamiaHttpFunctionValidator.java @@ -0,0 +1,59 @@ +package tools.dynamia.modules.functions; + +import tools.dynamia.domain.InstallValidator; +import tools.dynamia.domain.ValidationError; +import tools.dynamia.domain.Validator; +import tools.dynamia.domain.ValidatorUtil; +import tools.dynamia.domain.query.QueryParameters; +import tools.dynamia.domain.services.CrudService; +import tools.dynamia.domain.util.DomainUtils; +import tools.dynamia.modules.functions.domain.DynamiaHttpFunction; +import tools.dynamia.modules.functions.domain.enums.FunctionStatus; + +/** + * Enforces the versioning rules described in the extension's README for + * {@link DynamiaHttpFunction}: versions start at 1, {@code (name, functionVersion)} is unique, and a + * new version of an existing function must be strictly greater than the current maximum version. The + * {@code url} is only required once the function is {@code ACTIVE}, so it can be auto-registered as a + * {@code DRAFT} placeholder pending configuration (see {@code DynamiaHttpFunctionsService#call} auto + * registration overloads). + * + * @author Mario A. Serrano Leones + */ +@InstallValidator +public class DynamiaHttpFunctionValidator implements Validator { + + @Override + public void validate(DynamiaHttpFunction function) throws ValidationError { + ValidatorUtil.validateEmpty(function.getName(), "Function name is required"); + if (function.getStatus() == FunctionStatus.ACTIVE) { + ValidatorUtil.validateEmpty(function.getUrl(), "Function url is required"); + } + + if (function.getFunctionVersion() < 1) { + throw new ValidationError("Function version must start at 1"); + } + + CrudService crudService = DomainUtils.lookupCrudService(); + + if (function.getId() == null) { + QueryParameters params = QueryParameters.with("name", function.getName()) + .add("functionVersion", function.getFunctionVersion()); + if (crudService.count(DynamiaHttpFunction.class, params) > 0) { + throw new ValidationError("Function [%s] version [%s] already exists", function.getName(), function.getFunctionVersion()); + } + + Integer maxVersion = findMaxVersion(crudService, function.getName()); + if (maxVersion != null && function.getFunctionVersion() <= maxVersion) { + throw new ValidationError("New version of function [%s] must be greater than the current maximum version [%s]", + function.getName(), maxVersion); + } + } + } + + private Integer findMaxVersion(CrudService crudService, String name) { + var versions = crudService.find(DynamiaHttpFunction.class, + QueryParameters.with("name", name).orderBy("functionVersion", false)); + return versions.isEmpty() ? null : versions.get(0).getFunctionVersion(); + } +} diff --git a/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/FunctionExecutionException.java b/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/FunctionExecutionException.java new file mode 100644 index 00000000..4c3933c5 --- /dev/null +++ b/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/FunctionExecutionException.java @@ -0,0 +1,15 @@ +package tools.dynamia.modules.functions; + +/** + * Thrown when a {@link tools.dynamia.modules.functions.domain.DynamiaHttpFunction} fails during + * execution (e.g. the target endpoint is unreachable or returns an unexpected error). Callers should + * map this exception to an HTTP 500 response. + * + * @author Mario A. Serrano Leones + */ +public class FunctionExecutionException extends RuntimeException { + + public FunctionExecutionException(String name, int version, Throwable cause) { + super("Error executing function [" + name + "] version [" + version + "]: " + cause.getMessage(), cause); + } +} \ No newline at end of file diff --git a/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/FunctionInactiveException.java b/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/FunctionInactiveException.java new file mode 100644 index 00000000..492f3fa0 --- /dev/null +++ b/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/FunctionInactiveException.java @@ -0,0 +1,18 @@ +package tools.dynamia.modules.functions; + +/** + * Thrown when a requested {@link tools.dynamia.modules.functions.domain.DynamiaHttpFunction} (or a + * specific version of it) exists but is not {@code ACTIVE} (e.g. it is still {@code DRAFT}, + * {@code INACTIVE} or {@code DELETED}). Callers should map this exception to an HTTP 404 response, + * distinguishing it from {@link FunctionNotFoundException} for troubleshooting purposes. + * + * @author Mario A. Serrano Leones + */ +public class FunctionInactiveException extends RuntimeException { + + public FunctionInactiveException(String name, Integer version) { + super(version != null + ? "Function [" + name + "] version [" + version + "] is not active" + : "Function [" + name + "] is not active"); + } +} diff --git a/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/FunctionNotFoundException.java b/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/FunctionNotFoundException.java new file mode 100644 index 00000000..31a6353c --- /dev/null +++ b/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/FunctionNotFoundException.java @@ -0,0 +1,17 @@ +package tools.dynamia.modules.functions; + +/** + * Thrown when a requested {@link tools.dynamia.modules.functions.domain.DynamiaHttpFunction} (or a + * specific version of it) does not exist at all. Callers should map this exception to an HTTP 404 + * response, distinguishing it from {@link FunctionInactiveException} for troubleshooting purposes. + * + * @author Mario A. Serrano Leones + */ +public class FunctionNotFoundException extends RuntimeException { + + public FunctionNotFoundException(String name, Integer version) { + super(version != null + ? "Function [" + name + "] version [" + version + "] not found" + : "Function [" + name + "] not found"); + } +} \ No newline at end of file diff --git a/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/FunctionResult.java b/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/FunctionResult.java new file mode 100644 index 00000000..25ff295b --- /dev/null +++ b/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/FunctionResult.java @@ -0,0 +1,140 @@ +package tools.dynamia.modules.functions; + +import tools.jackson.databind.ObjectMapper; + +import java.util.List; +import java.util.Map; + +/** + * Represents the outcome of a {@link tools.dynamia.modules.functions.domain.DynamiaHttpFunction} execution. A result is either a structured + * JSON-like payload ({@link #getData()}) or raw binary content ({@link #getBinaryData()}) with an + * associated content type, matching the response model described in the extension's README. + * + * @author Mario A. Serrano Leones + */ +public class FunctionResult { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final boolean success; + private final Object data; + private final byte[] binaryData; + private final String contentType; + private final String errorMessage; + + private FunctionResult(boolean success, Object data, byte[] binaryData, String contentType, String errorMessage) { + this.success = success; + this.data = data; + this.binaryData = binaryData; + this.contentType = contentType; + this.errorMessage = errorMessage; + } + + /** + * Creates a successful result with a structured (JSON-serializable) payload. + * + * @param data the response payload + * @return a successful {@link FunctionResult} + */ + public static FunctionResult success(Object data) { + return new FunctionResult(true, data, null, "application/json", null); + } + + /** + * Creates a successful result carrying raw binary content (image, PDF, CSV, etc.). + * + * @param binaryData the raw bytes to stream back to the caller + * @param contentType the content type of the binary payload + * @return a successful {@link FunctionResult} + */ + public static FunctionResult binary(byte[] binaryData, String contentType) { + return new FunctionResult(true, null, binaryData, contentType, null); + } + + /** + * Creates a failed result with an error message. + * + * @param errorMessage the error description + * @return a failed {@link FunctionResult} + */ + public static FunctionResult error(String errorMessage) { + return new FunctionResult(false, null, null, null, errorMessage); + } + + public boolean isSuccess() { + return success; + } + + public boolean isBinary() { + return binaryData != null; + } + + /** + * Indicates whether {@link #getData()} holds a structured JSON payload (object or array) that can be + * read with {@link #toJson()} or {@link #toJson(Class)}. + * + * @return {@code true} when the result is not binary and carries a JSON object/array payload + */ + public boolean isJson() { + return !isBinary() && (data instanceof Map || data instanceof List); + } + + /** + * Returns {@link #getData()} as a {@code Map}, parsing it first when it was kept as a raw JSON + * string (e.g. because the target endpoint returned malformed JSON). + * + * @return the payload as a {@link Map}, or an empty map when there is no data + */ + @SuppressWarnings("unchecked") + public Map toJson() { + return convert(Map.class); + } + + /** + * Converts {@link #getData()} into the given DTO class using Jackson, so callers don't have to deal + * with raw {@code Map}/{@code String} payloads. + * + * @param type the target class to parse/convert the payload into + * @param the target type + * @return the payload converted to {@code type}, or {@code null} when there is no data + * + * Example: + *
{@code
+     * FunctionResult result = DynamiaFunctions.call("WhatsApp.sendMessage", params);
+     * SendMessageResponse response = result.toJson(SendMessageResponse.class);
+     * }
+ */ + public T toJson(Class type) { + return convert(type); + } + + @SuppressWarnings("unchecked") + private T convert(Class type) { + if (data == null) { + return type == Map.class ? (T) Map.of() : null; + } + if (type.isInstance(data)) { + return type.cast(data); + } + if (data instanceof String text) { + return MAPPER.readValue(text, type); + } + return MAPPER.convertValue(data, type); + } + + public Object getData() { + return data; + } + + public byte[] getBinaryData() { + return binaryData; + } + + public String getContentType() { + return contentType; + } + + public String getErrorMessage() { + return errorMessage; + } +} \ No newline at end of file diff --git a/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/controllers/DynamiaHttpFunctionsController.java b/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/controllers/DynamiaHttpFunctionsController.java new file mode 100644 index 00000000..f5c02bb6 --- /dev/null +++ b/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/controllers/DynamiaHttpFunctionsController.java @@ -0,0 +1,99 @@ +package tools.dynamia.modules.functions.controllers; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import tools.dynamia.domain.ValidationError; +import tools.dynamia.modules.functions.FunctionExecutionException; +import tools.dynamia.modules.functions.FunctionInactiveException; +import tools.dynamia.modules.functions.FunctionNotFoundException; +import tools.dynamia.modules.functions.FunctionResult; +import tools.dynamia.modules.functions.services.DynamiaHttpFunctionsService; + +import java.util.Map; + +/** + * HTTP entry point for {@link tools.dynamia.modules.functions.domain.DynamiaHttpFunction}s. Exposes + * functions as callable HTTP endpoints under {@code /api/dynamia/fx/{functionName}}, resolving the + * requested version (via the {@code X-Dynamia-Version} header or the {@code v} query parameter, + * defaulting to the highest active version) and returning either a structured JSON response or the raw + * binary payload produced by the function. + * + * @author Mario A. Serrano Leones + */ +@RestController +@RequestMapping("/api/dynamia/fx") +public class DynamiaHttpFunctionsController { + + private static final String VERSION_HEADER = "X-Dynamia-Version"; + + private final DynamiaHttpFunctionsService functionsService; + + public DynamiaHttpFunctionsController(DynamiaHttpFunctionsService functionsService) { + this.functionsService = functionsService; + } + + /** + * Invokes a function by name, optionally selecting a specific version. + * + * @param functionName the function name, e.g. {@code WhatsApp.sendMessage} + * @param versionHeader optional version requested via the {@value #VERSION_HEADER} header + * @param versionParam optional version requested via the {@code v} query parameter + * @param request the call request containing the function parameters + * @return {@code 200} with the function result, {@code 400} on validation errors, {@code 404} when + * the function/version is not found, or {@code 500} on execution errors + */ + @PostMapping(value = "/{functionName}") + public ResponseEntity call(@PathVariable String functionName, + @RequestHeader(value = VERSION_HEADER, required = false) Integer versionHeader, + @RequestParam(value = "v", required = false) Integer versionParam, + @RequestBody(required = false) FunctionCallRequest request) { + + Integer version = versionHeader != null ? versionHeader : versionParam; + Map params = request != null ? request.getParams() : Map.of(); + + try { + FunctionResult result = functionsService.call(functionName, version, params); + return toResponseEntity(result); + } catch (FunctionNotFoundException | FunctionInactiveException e) { + return errorResponse(HttpStatus.NOT_FOUND, e.getMessage()); + } catch (ValidationError e) { + return errorResponse(HttpStatus.BAD_REQUEST, e.getMessage()); + } catch (FunctionExecutionException e) { + return errorResponse(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage()); + } catch (Exception e) { + return errorResponse(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage()); + } + } + + private ResponseEntity toResponseEntity(FunctionResult result) { + if (result.isBinary()) { + MediaType contentType = MediaType.parseMediaType(result.getContentType()); + return ResponseEntity.ok().contentType(contentType).body(result.getBinaryData()); + } + return ResponseEntity.ok(Map.of("success", true, "data", result.getData() != null ? result.getData() : Map.of())); + } + + private ResponseEntity> errorResponse(HttpStatus status, String message) { + return ResponseEntity.status(status) + .header("X-Error-Message", message) + .body(Map.of("success", false, "error", message)); + } + + /** + * Request payload accepted by the function call endpoint. + */ + public static class FunctionCallRequest { + + private Map params; + + public Map getParams() { + return params; + } + + public void setParams(Map params) { + this.params = params; + } + } +} diff --git a/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/domain/DynamiaHttpFunction.java b/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/domain/DynamiaHttpFunction.java new file mode 100644 index 00000000..e2e98075 --- /dev/null +++ b/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/domain/DynamiaHttpFunction.java @@ -0,0 +1,218 @@ +package tools.dynamia.modules.functions.domain; + + +import jakarta.persistence.*; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import tools.dynamia.modules.functions.domain.enums.FunctionStatus; +import tools.dynamia.modules.saas.jpa.BaseEntitySaaS; +import tools.dynamia.web.HttpMethod; + +import java.util.ArrayList; +import java.util.List; + +/** + * Represents a versioned HTTP function definition. A function describes how to reach and invoke an + * external service (or internal endpoint) as if it was a normal, reusable, versioned capability that + * can be called from code through {@link tools.dynamia.modules.functions.DynamiaFunctions}. + *

+ * A function is uniquely identified by the pair {@code (name, functionVersion)} within an account. + * When multiple versions of the same function exist, callers can request an explicit version or let + * the runtime resolve the highest active version automatically. + * + * Example: + *

{@code
+ * DynamiaHttpFunction function = new DynamiaHttpFunction();
+ * function.setName("WhatsApp.sendMessage");
+ * function.setFunctionVersion(1);
+ * function.setMethod(HttpMethod.POST);
+ * function.setUrl("https://api.whatsapp.example.com/send");
+ * function.setBodyTemplate("{\"to\":\"${number}\",\"text\":\"${message}\"}");
+ * function.setStatus(FunctionStatus.ACTIVE);
+ * }
+ * + * @author Mario A. Serrano Leones + */ +@Entity +@Table(name = "fx_functions", indexes = { + @Index(name = "idx_fx_function_account", columnList = "accountId,name,functionVersion", unique = true) +}) +public class DynamiaHttpFunction extends BaseEntitySaaS { + + @NotNull + @NotEmpty + @Column(length = 1000) + private String name; + private String description; + @Min(1) + private int functionVersion = 1; + @OneToMany(mappedBy = "function", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("position") + private List parameters = new ArrayList<>(); + @Enumerated(EnumType.STRING) + @NotNull + private HttpMethod method = HttpMethod.POST; + @Column(length = 1000) + private String url; + private String contentType = "application/json"; + @Lob + private String bodyTemplate; + /** + * Optional raw HTTP headers, one per line, using the {@code Header-Name: value} syntax. Values + * support {@code ${paramName}} placeholders that are resolved with the call parameters, so + * integrations that require API keys, tokens or other custom headers can be configured declaratively. + */ + @Lob + private String headers; + /** + * Optional free-form JSON metadata associated with the function definition (e.g. provider name, + * documentation links, rate limits). Not used by the execution engine, purely informative/extensible. + */ + @Lob + private String metadata; + private String interfaceName; // For dynamic interfaces + private String methodName; // For dynamic interfaces + @NotNull + @Enumerated(EnumType.STRING) + private FunctionStatus status = FunctionStatus.DRAFT; + + /** + * Finds a parameter definition by name. + * + * @param name the parameter name + * @return the matching parameter definition, or {@code null} if not found + */ + public DynamiaHttpFunctionParameter getParameter(String name) { + if (name == null) { + return null; + } + return parameters.stream() + .filter(p -> name.equals(p.getName())) + .findFirst() + .orElse(null); + } + + /** + * Adds a parameter definition to this function, wiring the back-reference automatically. + * + * @param parameter the parameter definition to add + */ + public void addParameter(DynamiaHttpFunctionParameter parameter) { + parameter.setFunction(this); + parameters.add(parameter); + } + + public boolean isActive() { + return status == FunctionStatus.ACTIVE; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public int getFunctionVersion() { + return functionVersion; + } + + public void setFunctionVersion(int functionVersion) { + this.functionVersion = functionVersion; + } + + public List getParameters() { + return parameters; + } + + public void setParameters(List parameters) { + this.parameters = parameters; + } + + public HttpMethod getMethod() { + return method; + } + + public void setMethod(HttpMethod method) { + this.method = method; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getContentType() { + return contentType; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } + + public String getBodyTemplate() { + return bodyTemplate; + } + + public void setBodyTemplate(String bodyTemplate) { + this.bodyTemplate = bodyTemplate; + } + + public String getHeaders() { + return headers; + } + + public void setHeaders(String headers) { + this.headers = headers; + } + + public String getMetadata() { + return metadata; + } + + public void setMetadata(String metadata) { + this.metadata = metadata; + } + + public String getInterfaceName() { + return interfaceName; + } + + public void setInterfaceName(String interfaceName) { + this.interfaceName = interfaceName; + } + + public String getMethodName() { + return methodName; + } + + public void setMethodName(String methodName) { + this.methodName = methodName; + } + + public FunctionStatus getStatus() { + return status; + } + + public void setStatus(FunctionStatus status) { + this.status = status; + } + + @Override + public String toString() { + return name + " v" + functionVersion; + } +} \ No newline at end of file diff --git a/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/domain/DynamiaHttpFunctionParameter.java b/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/domain/DynamiaHttpFunctionParameter.java new file mode 100644 index 00000000..1117c450 --- /dev/null +++ b/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/domain/DynamiaHttpFunctionParameter.java @@ -0,0 +1,87 @@ +package tools.dynamia.modules.functions.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import tools.dynamia.modules.functions.domain.enums.ParameterDataType; +import tools.dynamia.modules.saas.jpa.SimpleEntitySaaS; + +@Entity +@Table(name = "fx_functions_parameters") +public class DynamiaHttpFunctionParameter extends SimpleEntitySaaS { + + @ManyToOne + private DynamiaHttpFunction function; + @NotNull + private String name; + private boolean required; + private String description; + private String defaultValue; + @NotNull + @Enumerated(jakarta.persistence.EnumType.STRING) + private ParameterDataType type = ParameterDataType.STRING; + private int position; + + public DynamiaHttpFunction getFunction() { + return function; + } + + public void setFunction(DynamiaHttpFunction function) { + this.function = function; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public boolean isRequired() { + return required; + } + + public void setRequired(boolean required) { + this.required = required; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getDefaultValue() { + return defaultValue; + } + + public void setDefaultValue(String defaultValue) { + this.defaultValue = defaultValue; + } + + public ParameterDataType getType() { + return type; + } + + public void setType(ParameterDataType type) { + this.type = type; + } + + public int getPosition() { + return position; + } + + public void setPosition(int position) { + this.position = position; + } + + @Override + public String toString() { + return name + (required ? " (required)" : "") + " - " + type; + } +} \ No newline at end of file diff --git a/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/domain/enums/FunctionStatus.java b/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/domain/enums/FunctionStatus.java new file mode 100644 index 00000000..7c0d0266 --- /dev/null +++ b/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/domain/enums/FunctionStatus.java @@ -0,0 +1,5 @@ +package tools.dynamia.modules.functions.domain.enums; + +public enum FunctionStatus { + DRAFT, ACTIVE, INACTIVE, DELETED +} diff --git a/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/domain/enums/ParameterDataType.java b/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/domain/enums/ParameterDataType.java new file mode 100644 index 00000000..f823c6fc --- /dev/null +++ b/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/domain/enums/ParameterDataType.java @@ -0,0 +1,5 @@ +package tools.dynamia.modules.functions.domain.enums; + +public enum ParameterDataType { + STRING, NUMBER, BOOLEAN, DATE, DATETIME +} diff --git a/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/services/DynamiaHttpFunctionsService.java b/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/services/DynamiaHttpFunctionsService.java new file mode 100644 index 00000000..72fcebfe --- /dev/null +++ b/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/services/DynamiaHttpFunctionsService.java @@ -0,0 +1,106 @@ +package tools.dynamia.modules.functions.services; + +import tools.dynamia.modules.functions.FunctionResult; +import tools.dynamia.modules.functions.domain.DynamiaHttpFunction; + +import java.util.Map; + +/** + * Registry and execution engine for {@link DynamiaHttpFunction}s. Implementations resolve functions by + * name and version, validate call parameters against their declared definitions, and execute the + * underlying HTTP call, acting as an internal capability bus for Dynamia-based applications. + * + * @author Mario A. Serrano Leones + */ +public interface DynamiaHttpFunctionsService { + + /** + * Resolves the highest active version of a function. + * + * @param name the function name (e.g. {@code WhatsApp.sendMessage}) + * @return the resolved function, or {@code null} if no active version exists + */ + DynamiaHttpFunction findLatestVersion(String name); + + /** + * Resolves a specific version of a function, or the highest active version when {@code version} is + * {@code null}. + * + * @param name the function name + * @param version the requested version, or {@code null} to resolve the latest active version + * @return the resolved function, or {@code null} if not found + */ + DynamiaHttpFunction findVersion(String name, Integer version); + + /** + * Validates the provided call parameters against a function's parameter definitions. + * + * @param function the function definition + * @param params the parameters supplied by the caller + * @throws tools.dynamia.domain.ValidationError if a required parameter is missing or has an invalid + * value for its declared type + */ + void validateParameters(DynamiaHttpFunction function, Map params); + + /** + * Calls the highest active version of a function. + * + * @param name the function name + * @param params the call parameters + * @return the execution result + * @throws tools.dynamia.modules.functions.FunctionNotFoundException if the function does not exist + * @throws tools.dynamia.modules.functions.FunctionInactiveException if the function exists but is + * not {@code ACTIVE} + * + * Example: + *
{@code
+     * FunctionResult result = service.call("WhatsApp.sendMessage", Map.of("number", "123", "message", "Hello"));
+     * }
+ */ + FunctionResult call(String name, Map params); + + /** + * Calls a specific version of a function. + * + * @param name the function name + * @param version the requested version, or {@code null} to resolve the latest active version + * @param params the call parameters + * @return the execution result + * @throws tools.dynamia.modules.functions.FunctionNotFoundException if the function/version does not exist + * @throws tools.dynamia.modules.functions.FunctionInactiveException if the function exists but is + * not {@code ACTIVE} + */ + FunctionResult call(String name, Integer version, Map params); + + /** + * Calls the highest active version of a function, auto-registering it as a {@code DRAFT} placeholder + * (version 1, inactive) when it does not exist yet, so it can be configured later (url, method, body + * template, etc.) instead of failing integration code with a hard "not found" error. The call itself + * still fails with {@link tools.dynamia.modules.functions.FunctionInactiveException} until someone + * configures and activates the function. + * + * @param name the function name + * @param params the call parameters + * @param autoCreate when {@code true}, a missing function is auto-registered as {@code DRAFT} instead + * of throwing {@link tools.dynamia.modules.functions.FunctionNotFoundException} + * @return the execution result + * @throws tools.dynamia.modules.functions.FunctionNotFoundException if the function does not exist + * and {@code autoCreate} is {@code false} + * @throws tools.dynamia.modules.functions.FunctionInactiveException if the function exists (or was + * just auto-created) but is not {@code ACTIVE} + */ + FunctionResult call(String name, Map params, boolean autoCreate); + + /** + * Calls a specific version of a function, auto-registering it as a {@code DRAFT} placeholder when it + * does not exist yet. See {@link #call(String, Map, boolean)}. + * + * @param name the function name + * @param version the requested version, or {@code null} to resolve/create version 1 + * @param params the call parameters + * @param autoCreate when {@code true}, a missing function is auto-registered as {@code DRAFT} instead + * of throwing {@link tools.dynamia.modules.functions.FunctionNotFoundException} + * @return the execution result + */ + FunctionResult call(String name, Integer version, Map params, boolean autoCreate); +} \ No newline at end of file diff --git a/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/services/impl/DynamiaHttpFunctionsServiceImpl.java b/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/services/impl/DynamiaHttpFunctionsServiceImpl.java new file mode 100644 index 00000000..1e663f4b --- /dev/null +++ b/extensions/http-functions/sources/core/src/main/java/tools/dynamia/modules/functions/services/impl/DynamiaHttpFunctionsServiceImpl.java @@ -0,0 +1,284 @@ +package tools.dynamia.modules.functions.services.impl; + +import tools.jackson.databind.ObjectMapper; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestClient; +import tools.dynamia.commons.DateTimeUtils; +import tools.dynamia.commons.SimpleTemplateEngine; +import tools.dynamia.commons.logger.LoggingService; +import tools.dynamia.commons.logger.SLF4JLoggingService; +import tools.dynamia.domain.ValidationError; +import tools.dynamia.domain.query.QueryParameters; +import tools.dynamia.domain.services.AbstractService; +import tools.dynamia.integration.sterotypes.Service; +import tools.dynamia.modules.functions.FunctionExecutionException; +import tools.dynamia.modules.functions.FunctionInactiveException; +import tools.dynamia.modules.functions.FunctionNotFoundException; +import tools.dynamia.modules.functions.FunctionResult; +import tools.dynamia.modules.functions.domain.DynamiaHttpFunction; +import tools.dynamia.modules.functions.domain.DynamiaHttpFunctionParameter; +import tools.dynamia.modules.functions.domain.enums.FunctionStatus; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Default {@link tools.dynamia.modules.functions.services.DynamiaHttpFunctionsService} implementation. + * Functions are resolved through {@link tools.dynamia.domain.services.CrudService}, call parameters are + * validated against their declared definitions, and execution is performed by rendering the function's + * {@code url}/{@code bodyTemplate}/{@code headers} templates with the call parameters and issuing the + * configured HTTP request. + * + * @author Mario A. Serrano Leones + */ +@Service +public class DynamiaHttpFunctionsServiceImpl extends AbstractService implements tools.dynamia.modules.functions.services.DynamiaHttpFunctionsService { + + private static final String DEFAULT_CONTENT_TYPE = "application/json"; + + private final LoggingService logger = new SLF4JLoggingService(getClass()); + private RestClient restClient = RestClient.create(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * Replaces the {@link RestClient} used to issue function calls. Package-private: intended for tests + * that need to bind a {@code MockRestServiceServer} instead of performing real HTTP calls. + * + * @param restClient the rest client to use + */ + void setRestClient(RestClient restClient) { + this.restClient = restClient; + } + + @Override + public DynamiaHttpFunction findLatestVersion(String name) { + checkName(name); + List versions = crudService().find(DynamiaHttpFunction.class, + QueryParameters.with("name", name) + .add("status", FunctionStatus.ACTIVE) + .orderBy("functionVersion", false)); + return versions.isEmpty() ? null : versions.get(0); + } + + @Override + public DynamiaHttpFunction findVersion(String name, Integer version) { + checkName(name); + if (version == null) { + return findLatestVersion(name); + } + return crudService().findSingle(DynamiaHttpFunction.class, + QueryParameters.with("name", name).add("functionVersion", version)); + } + + @Override + public void validateParameters(DynamiaHttpFunction function, Map params) { + Map values = params != null ? params : Map.of(); + for (DynamiaHttpFunctionParameter parameter : function.getParameters()) { + Object value = values.get(parameter.getName()); + if (isBlank(value) && parameter.getDefaultValue() != null) { + value = parameter.getDefaultValue(); + } + if (isBlank(value)) { + if (parameter.isRequired()) { + throw new ValidationError("Parameter [%s] is required", parameter.getName()); + } + continue; + } + coerce(parameter, value); + } + } + + @Override + public FunctionResult call(String name, Map params) { + return call(name, null, params, false); + } + + @Override + public FunctionResult call(String name, Integer version, Map params) { + return call(name, version, params, false); + } + + @Override + public FunctionResult call(String name, Map params, boolean autoCreate) { + return call(name, null, params, autoCreate); + } + + @Override + public FunctionResult call(String name, Integer version, Map params, boolean autoCreate) { + DynamiaHttpFunction function = resolveAnyStatus(name, version); + + if (function == null) { + if (!autoCreate) { + throw new FunctionNotFoundException(name, version); + } + function = registerDraft(name, version); + } + + if (!function.isActive()) { + throw new FunctionInactiveException(name, function.getFunctionVersion()); + } + + validateParameters(function, params); + Map vars = mergeWithDefaults(function, params); + + try { + return execute(function, vars); + } catch (FunctionExecutionException e) { + throw e; + } catch (Exception e) { + logger.error("Error executing function [" + name + "] version [" + function.getFunctionVersion() + "]", e); + throw new FunctionExecutionException(name, function.getFunctionVersion(), e); + } + } + + /** + * Resolves a function by name/version regardless of its {@link FunctionStatus}, so callers can tell + * apart a missing function from one that simply is not active yet. + */ + private DynamiaHttpFunction resolveAnyStatus(String name, Integer version) { + checkName(name); + if (version != null) { + return crudService().findSingle(DynamiaHttpFunction.class, + QueryParameters.with("name", name).add("functionVersion", version)); + } + + List versions = crudService().find(DynamiaHttpFunction.class, + QueryParameters.with("name", name).orderBy("functionVersion", false)); + return versions.isEmpty() ? null : versions.get(0); + } + + /** + * Creates a placeholder {@code DRAFT} function so it shows up for later configuration (url, method, + * body template, headers, etc.) instead of failing integration code with a hard "not found" error. + */ + private DynamiaHttpFunction registerDraft(String name, Integer version) { + DynamiaHttpFunction function = new DynamiaHttpFunction(); + function.setName(name); + function.setFunctionVersion(version != null ? version : 1); + function.setStatus(FunctionStatus.DRAFT); + return crudService().create(function); + } + + /** + * Renders and issues the HTTP request configured in the function definition, converting the response + * into a {@link FunctionResult}. + */ + private FunctionResult execute(DynamiaHttpFunction function, Map vars) { + String url = SimpleTemplateEngine.parse(function.getUrl(), vars); + var method = org.springframework.http.HttpMethod.valueOf(function.getMethod().name()); + + RestClient.RequestBodySpec spec = restClient.method(method).uri(url); + applyHeaders(spec, function.getHeaders(), vars); + + if (function.getBodyTemplate() != null && !function.getBodyTemplate().isBlank()) { + String body = SimpleTemplateEngine.parse(function.getBodyTemplate(), vars); + String contentType = function.getContentType() != null ? function.getContentType() : DEFAULT_CONTENT_TYPE; + spec.contentType(MediaType.parseMediaType(contentType)).body(body); + } + + ResponseEntity response = spec.retrieve().toEntity(byte[].class); + return toFunctionResult(response); + } + + /** + * Parses the {@code Header-Name: value} lines from the function's headers template and applies them + * (after resolving {@code ${param}} placeholders) to the outgoing request. + */ + private void applyHeaders(RestClient.RequestBodySpec spec, String headersTemplate, Map vars) { + if (headersTemplate == null || headersTemplate.isBlank()) { + return; + } + + for (String line : headersTemplate.split("\\r?\\n")) { + if (line.isBlank()) { + continue; + } + int separator = line.indexOf(':'); + if (separator <= 0) { + continue; + } + String headerName = line.substring(0, separator).trim(); + String headerValue = SimpleTemplateEngine.parse(line.substring(separator + 1).trim(), vars); + spec.header(headerName, headerValue); + } + } + + /** + * Converts the raw HTTP response into a {@link FunctionResult}, returning parsed JSON data when the + * response content type is JSON-compatible, or raw binary data otherwise (images, PDFs, CSV, etc.). + */ + private FunctionResult toFunctionResult(ResponseEntity response) { + byte[] body = response.getBody(); + MediaType contentType = response.getHeaders().getContentType(); + + if (!response.getStatusCode().is2xxSuccessful()) { + String message = body != null ? new String(body, StandardCharsets.UTF_8) : response.getStatusCode().toString(); + throw new RuntimeException("Function endpoint returned status " + response.getStatusCode() + ": " + message); + } + + if (body == null || body.length == 0) { + return FunctionResult.success(null); + } + + if (contentType != null && contentType.isCompatibleWith(MediaType.APPLICATION_JSON)) { + try { + Object data = objectMapper.readValue(body, Object.class); + return FunctionResult.success(data); + } catch (Exception e) { + return FunctionResult.success(new String(body, StandardCharsets.UTF_8)); + } + } + + if (contentType != null && contentType.getType().equals("text")) { + return FunctionResult.success(new String(body, StandardCharsets.UTF_8)); + } + + String binaryContentType = contentType != null ? contentType.toString() : "application/octet-stream"; + return FunctionResult.binary(body, binaryContentType); + } + + /** + * Builds the variables map used to render templates: declared parameter default values overridden by + * the values supplied by the caller. + */ + private Map mergeWithDefaults(DynamiaHttpFunction function, Map params) { + Map vars = new LinkedHashMap<>(); + for (DynamiaHttpFunctionParameter parameter : function.getParameters()) { + if (parameter.getDefaultValue() != null) { + vars.put(parameter.getName(), parameter.getDefaultValue()); + } + } + if (params != null) { + vars.putAll(params); + } + return vars; + } + + private Object coerce(DynamiaHttpFunctionParameter parameter, Object value) { + try { + return switch (parameter.getType()) { + case NUMBER -> value instanceof Number ? value : new BigDecimal(value.toString()); + case BOOLEAN -> value instanceof Boolean ? value : Boolean.parseBoolean(value.toString()); + case DATE -> DateTimeUtils.parse(value.toString(), "yyyy-MM-dd"); + case DATETIME -> DateTimeUtils.parse(value.toString(), "yyyy-MM-dd HH:mm:ss"); + default -> value.toString(); + }; + } catch (Exception e) { + throw new ValidationError("Parameter [%s] has an invalid value for type %s", parameter.getName(), parameter.getType()); + } + } + + private boolean isBlank(Object value) { + return value == null || (value instanceof String s && s.isBlank()); + } + + private void checkName(String name) { + if (name == null || name.isBlank()) { + throw new ValidationError("Function name is required"); + } + } +} diff --git a/extensions/http-functions/sources/core/src/test/java/tools/dynamia/modules/functions/DynamiaHttpFunctionValidatorTest.java b/extensions/http-functions/sources/core/src/test/java/tools/dynamia/modules/functions/DynamiaHttpFunctionValidatorTest.java new file mode 100644 index 00000000..9f481a5b --- /dev/null +++ b/extensions/http-functions/sources/core/src/test/java/tools/dynamia/modules/functions/DynamiaHttpFunctionValidatorTest.java @@ -0,0 +1,132 @@ +package tools.dynamia.modules.functions; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import tools.dynamia.domain.ValidationError; +import tools.dynamia.domain.query.QueryParameters; +import tools.dynamia.domain.services.CrudService; +import tools.dynamia.integration.Containers; +import tools.dynamia.integration.SimpleObjectContainer; +import tools.dynamia.modules.functions.domain.DynamiaHttpFunction; +import tools.dynamia.modules.functions.domain.enums.FunctionStatus; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Verifies the versioning and required-field rules enforced by {@link DynamiaHttpFunctionValidator}. + * {@link CrudService} is mocked and installed into {@link Containers} so the validator's static + * {@code DomainUtils.lookupCrudService()} lookup resolves to the mock instead of a real datasource. + */ +class DynamiaHttpFunctionValidatorTest { + + private final DynamiaHttpFunctionValidator validator = new DynamiaHttpFunctionValidator(); + private CrudService crudService; + + @BeforeEach + void installMockCrudService() { + crudService = mock(CrudService.class); + SimpleObjectContainer container = new SimpleObjectContainer("test-container"); + container.addObject(crudService); + Containers.get().installObjectContainer(container); + } + + @AfterEach + void resetContainers() { + Containers.get().removeAllContainers(); + } + + @Test + void requiresName() { + DynamiaHttpFunction function = draftFunction(null, 1); + + assertThrows(ValidationError.class, () -> validator.validate(function)); + } + + @Test + void doesNotRequireUrlWhileDraft() { + DynamiaHttpFunction function = draftFunction("WhatsApp.sendMessage", 1); + function.setUrl(null); + noExistingVersions(); + + assertDoesNotThrow(() -> validator.validate(function)); + } + + @Test + void requiresUrlWhenActive() { + DynamiaHttpFunction function = draftFunction("WhatsApp.sendMessage", 1); + function.setUrl(null); + function.setStatus(FunctionStatus.ACTIVE); + noExistingVersions(); + + assertThrows(ValidationError.class, () -> validator.validate(function)); + } + + @Test + void versionMustStartAtOne() { + DynamiaHttpFunction function = draftFunction("WhatsApp.sendMessage", 0); + + assertThrows(ValidationError.class, () -> validator.validate(function)); + } + + @Test + void rejectsDuplicateNameAndVersion() { + DynamiaHttpFunction function = draftFunction("WhatsApp.sendMessage", 1); + when(crudService.count(eq(DynamiaHttpFunction.class), any(QueryParameters.class))).thenReturn(1L); + + assertThrows(ValidationError.class, () -> validator.validate(function)); + } + + @Test + void newVersionMustBeGreaterThanCurrentMax() { + DynamiaHttpFunction function = draftFunction("WhatsApp.sendMessage", 2); + when(crudService.count(eq(DynamiaHttpFunction.class), any(QueryParameters.class))).thenReturn(0L); + + DynamiaHttpFunction existingV2 = draftFunction("WhatsApp.sendMessage", 2); + when(crudService.find(eq(DynamiaHttpFunction.class), any(QueryParameters.class))) + .thenReturn(List.of(existingV2)); + + assertThrows(ValidationError.class, () -> validator.validate(function)); + } + + @Test + void acceptsNewVersionGreaterThanCurrentMax() { + DynamiaHttpFunction function = draftFunction("WhatsApp.sendMessage", 3); + when(crudService.count(eq(DynamiaHttpFunction.class), any(QueryParameters.class))).thenReturn(0L); + + DynamiaHttpFunction existingV2 = draftFunction("WhatsApp.sendMessage", 2); + when(crudService.find(eq(DynamiaHttpFunction.class), any(QueryParameters.class))) + .thenReturn(List.of(existingV2)); + + assertDoesNotThrow(() -> validator.validate(function)); + } + + @Test + void skipsUniquenessAndVersionChecksWhenUpdatingExistingFunction() { + DynamiaHttpFunction function = draftFunction("WhatsApp.sendMessage", 1); + function.setId(10L); + + assertDoesNotThrow(() -> validator.validate(function)); + } + + private void noExistingVersions() { + when(crudService.count(eq(DynamiaHttpFunction.class), any(QueryParameters.class))).thenReturn(0L); + when(crudService.find(eq(DynamiaHttpFunction.class), any(QueryParameters.class))).thenReturn(List.of()); + } + + private DynamiaHttpFunction draftFunction(String name, int version) { + DynamiaHttpFunction function = new DynamiaHttpFunction(); + function.setName(name); + function.setFunctionVersion(version); + function.setUrl("https://api.example.com/send"); + function.setStatus(FunctionStatus.DRAFT); + return function; + } +} diff --git a/extensions/http-functions/sources/core/src/test/java/tools/dynamia/modules/functions/FunctionResultTest.java b/extensions/http-functions/sources/core/src/test/java/tools/dynamia/modules/functions/FunctionResultTest.java new file mode 100644 index 00000000..e0d1212e --- /dev/null +++ b/extensions/http-functions/sources/core/src/test/java/tools/dynamia/modules/functions/FunctionResultTest.java @@ -0,0 +1,101 @@ +package tools.dynamia.modules.functions; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FunctionResultTest { + + @Test + void successWithMapIsJsonAndNotBinary() { + FunctionResult result = FunctionResult.success(Map.of("messageId", "abc")); + + assertTrue(result.isSuccess()); + assertFalse(result.isBinary()); + assertTrue(result.isJson()); + assertEquals("abc", result.toJson().get("messageId")); + } + + @Test + void successWithListIsJson() { + FunctionResult result = FunctionResult.success(List.of("a", "b")); + + assertTrue(result.isJson()); + assertFalse(result.isBinary()); + } + + @Test + void successWithPlainStringIsNotJsonButToJsonStillParsesIt() { + FunctionResult result = FunctionResult.success("{\"messageId\":\"abc\"}"); + + assertFalse(result.isJson()); + assertEquals("abc", result.toJson().get("messageId")); + } + + @Test + void toJsonWithNoDataReturnsEmptyMap() { + FunctionResult result = FunctionResult.success(null); + + assertTrue(result.toJson().isEmpty()); + } + + @Test + void toJsonWithClassConvertsMapToDto() { + FunctionResult result = FunctionResult.success(Map.of("messageId", "abc", "delivered", true)); + + SendMessageResponse response = result.toJson(SendMessageResponse.class); + + assertEquals("abc", response.messageId()); + assertTrue(response.delivered()); + } + + @Test + void toJsonWithClassParsesRawJsonString() { + FunctionResult result = FunctionResult.success("{\"messageId\":\"abc\",\"delivered\":false}"); + + SendMessageResponse response = result.toJson(SendMessageResponse.class); + + assertEquals("abc", response.messageId()); + assertFalse(response.delivered()); + } + + @Test + void toJsonWithClassReturnsNullWhenNoData() { + FunctionResult result = FunctionResult.success(null); + + assertNull(result.toJson(SendMessageResponse.class)); + } + + @Test + void binaryResultIsNotJsonAndCarriesRawBytes() { + byte[] bytes = {1, 2, 3}; + FunctionResult result = FunctionResult.binary(bytes, "image/png"); + + assertTrue(result.isSuccess()); + assertTrue(result.isBinary()); + assertFalse(result.isJson()); + assertEquals("image/png", result.getContentType()); + assertEquals(bytes, result.getBinaryData()); + assertNull(result.getData()); + } + + @Test + void errorResultIsNotSuccessfulAndCarriesMessage() { + FunctionResult result = FunctionResult.error("boom"); + + assertFalse(result.isSuccess()); + assertFalse(result.isBinary()); + assertFalse(result.isJson()); + assertEquals("boom", result.getErrorMessage()); + assertNull(result.getData()); + } + + private record SendMessageResponse(String messageId, boolean delivered) { + } +} diff --git a/extensions/http-functions/sources/core/src/test/java/tools/dynamia/modules/functions/controllers/DynamiaHttpFunctionsControllerTest.java b/extensions/http-functions/sources/core/src/test/java/tools/dynamia/modules/functions/controllers/DynamiaHttpFunctionsControllerTest.java new file mode 100644 index 00000000..4544117f --- /dev/null +++ b/extensions/http-functions/sources/core/src/test/java/tools/dynamia/modules/functions/controllers/DynamiaHttpFunctionsControllerTest.java @@ -0,0 +1,159 @@ +package tools.dynamia.modules.functions.controllers; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import tools.dynamia.domain.ValidationError; +import tools.dynamia.modules.functions.FunctionExecutionException; +import tools.dynamia.modules.functions.FunctionInactiveException; +import tools.dynamia.modules.functions.FunctionNotFoundException; +import tools.dynamia.modules.functions.FunctionResult; +import tools.dynamia.modules.functions.services.DynamiaHttpFunctionsService; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Verifies the HTTP status/body mapping performed by {@link DynamiaHttpFunctionsController}. The + * underlying {@link DynamiaHttpFunctionsService} is mocked so no real function execution/HTTP call + * happens here; only the controller's request/response translation is under test. + */ +@ExtendWith(MockitoExtension.class) +class DynamiaHttpFunctionsControllerTest { + + @Mock + private DynamiaHttpFunctionsService functionsService; + + private DynamiaHttpFunctionsController controller; + + @BeforeEach + void setUp() { + controller = new DynamiaHttpFunctionsController(functionsService); + } + + @Test + void returnsOkWithJsonPayloadOnSuccess() { + when(functionsService.call(eq("WhatsApp.sendMessage"), isNull(), any())) + .thenReturn(FunctionResult.success(Map.of("messageId", "abc"))); + + ResponseEntity response = controller.call("WhatsApp.sendMessage", null, null, requestWith(Map.of("number", "123"))); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(Map.of("success", true, "data", Map.of("messageId", "abc")), response.getBody()); + } + + @Test + void returnsOkWithBinaryBodyWhenResultIsBinary() { + byte[] bytes = {1, 2, 3}; + when(functionsService.call(eq("Reports.export"), isNull(), any())) + .thenReturn(FunctionResult.binary(bytes, "application/pdf")); + + ResponseEntity response = controller.call("Reports.export", null, null, requestWith(Map.of())); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(MediaType.APPLICATION_PDF, response.getHeaders().getContentType()); + assertArrayEquals(bytes, (byte[]) response.getBody()); + } + + @Test + void returnsNotFoundWhenFunctionDoesNotExist() { + when(functionsService.call(eq("Missing.function"), isNull(), any())) + .thenThrow(new FunctionNotFoundException("Missing.function", null)); + + ResponseEntity response = controller.call("Missing.function", null, null, requestWith(Map.of())); + + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + assertFalse((Boolean) ((Map) response.getBody()).get("success")); + } + + @Test + void returnsNotFoundWhenFunctionIsInactive() { + when(functionsService.call(eq("Draft.function"), isNull(), any())) + .thenThrow(new FunctionInactiveException("Draft.function", 1)); + + ResponseEntity response = controller.call("Draft.function", null, null, requestWith(Map.of())); + + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + } + + @Test + void returnsBadRequestOnValidationError() { + when(functionsService.call(eq("WhatsApp.sendMessage"), isNull(), any())) + .thenThrow(new ValidationError("Parameter [number] is required")); + + ResponseEntity response = controller.call("WhatsApp.sendMessage", null, null, requestWith(Map.of())); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + @Test + void returnsInternalServerErrorOnExecutionFailure() { + when(functionsService.call(eq("WhatsApp.sendMessage"), isNull(), any())) + .thenThrow(new FunctionExecutionException("WhatsApp.sendMessage", 1, new RuntimeException("boom"))); + + ResponseEntity response = controller.call("WhatsApp.sendMessage", null, null, requestWith(Map.of())); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + } + + @Test + void returnsInternalServerErrorOnUnexpectedException() { + when(functionsService.call(eq("WhatsApp.sendMessage"), isNull(), any())) + .thenThrow(new RuntimeException("unexpected")); + + ResponseEntity response = controller.call("WhatsApp.sendMessage", null, null, requestWith(Map.of())); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + } + + @Test + void versionHeaderTakesPrecedenceOverQueryParam() { + when(functionsService.call(eq("WhatsApp.sendMessage"), eq(2), any())) + .thenReturn(FunctionResult.success(Map.of())); + + ResponseEntity response = controller.call("WhatsApp.sendMessage", 2, 5, requestWith(Map.of())); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + verify(functionsService).call(eq("WhatsApp.sendMessage"), eq(2), any()); + } + + @Test + void queryParamIsUsedWhenVersionHeaderIsAbsent() { + when(functionsService.call(eq("WhatsApp.sendMessage"), eq(5), any())) + .thenReturn(FunctionResult.success(Map.of())); + + ResponseEntity response = controller.call("WhatsApp.sendMessage", null, 5, requestWith(Map.of())); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + verify(functionsService).call(eq("WhatsApp.sendMessage"), eq(5), any()); + } + + @Test + void nullRequestBodyIsTreatedAsEmptyParams() { + when(functionsService.call(eq("WhatsApp.sendMessage"), isNull(), eq(Map.of()))) + .thenReturn(FunctionResult.success(Map.of())); + + ResponseEntity response = controller.call("WhatsApp.sendMessage", null, null, null); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + } + + private DynamiaHttpFunctionsController.FunctionCallRequest requestWith(Map params) { + DynamiaHttpFunctionsController.FunctionCallRequest request = new DynamiaHttpFunctionsController.FunctionCallRequest(); + request.setParams(params); + return request; + } +} diff --git a/extensions/http-functions/sources/core/src/test/java/tools/dynamia/modules/functions/services/impl/DynamiaHttpFunctionsServiceImplTest.java b/extensions/http-functions/sources/core/src/test/java/tools/dynamia/modules/functions/services/impl/DynamiaHttpFunctionsServiceImplTest.java new file mode 100644 index 00000000..4fc6cf98 --- /dev/null +++ b/extensions/http-functions/sources/core/src/test/java/tools/dynamia/modules/functions/services/impl/DynamiaHttpFunctionsServiceImplTest.java @@ -0,0 +1,234 @@ +package tools.dynamia.modules.functions.services.impl; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.client.RestClient; +import org.springframework.test.web.client.MockRestServiceServer; +import tools.dynamia.domain.ValidationError; +import tools.dynamia.domain.query.QueryParameters; +import tools.dynamia.domain.services.CrudService; +import tools.dynamia.integration.Containers; +import tools.dynamia.integration.SimpleObjectContainer; +import tools.dynamia.modules.functions.FunctionExecutionException; +import tools.dynamia.modules.functions.FunctionInactiveException; +import tools.dynamia.modules.functions.FunctionNotFoundException; +import tools.dynamia.modules.functions.FunctionResult; +import tools.dynamia.modules.functions.domain.DynamiaHttpFunction; +import tools.dynamia.modules.functions.domain.DynamiaHttpFunctionParameter; +import tools.dynamia.modules.functions.domain.enums.FunctionStatus; +import tools.dynamia.modules.functions.domain.enums.ParameterDataType; +import tools.dynamia.web.HttpMethod; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.content; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +/** + * Exercises {@link DynamiaHttpFunctionsServiceImpl} end to end: version resolution, parameter + * validation, template rendering and response parsing. Outbound HTTP calls are mocked with + * {@link MockRestServiceServer} (no real network access), and {@link CrudService} is mocked with + * Mockito and installed into {@link Containers} so the inherited {@code crudService()} lookup resolves + * to it. + */ +class DynamiaHttpFunctionsServiceImplTest { + + private DynamiaHttpFunctionsServiceImpl service; + private CrudService crudService; + private MockRestServiceServer mockServer; + + @BeforeEach + void setUp() { + crudService = mock(CrudService.class); + SimpleObjectContainer container = new SimpleObjectContainer("test-container"); + container.addObject(crudService); + Containers.get().installObjectContainer(container); + + service = new DynamiaHttpFunctionsServiceImpl(); + + RestClient.Builder builder = RestClient.builder(); + mockServer = MockRestServiceServer.bindTo(builder).build(); + service.setRestClient(builder.build()); + } + + @AfterEach + void tearDown() { + Containers.get().removeAllContainers(); + } + + @Test + void callRendersTemplatesAndReturnsJsonPayload() { + DynamiaHttpFunction function = whatsAppFunction(FunctionStatus.ACTIVE, 1); + function.setHeaders("Authorization: Bearer ${apiKey}"); + whenResolvingLatestVersion(function); + + mockServer.expect(requestTo("https://api.example.com/send")) + .andExpect(method(org.springframework.http.HttpMethod.POST)) + .andExpect(header("Authorization", "Bearer secret-key")) + .andExpect(content().string("{\"to\":\"123456789\",\"text\":\"Hello\"}")) + .andRespond(withSuccess("{\"messageId\":\"abc\"}", MediaType.APPLICATION_JSON)); + + FunctionResult result = service.call("WhatsApp.sendMessage", + Map.of("number", "123456789", "message", "Hello", "apiKey", "secret-key")); + + mockServer.verify(); + assertTrue(result.isSuccess()); + assertTrue(result.isJson()); + assertEquals("abc", result.toJson().get("messageId")); + } + + @Test + void callUsesParameterDefaultValueWhenNotProvided() { + DynamiaHttpFunction function = whatsAppFunction(FunctionStatus.ACTIVE, 1); + function.getParameter("message").setDefaultValue("Default greeting"); + function.getParameter("message").setRequired(false); + whenResolvingLatestVersion(function); + + mockServer.expect(requestTo("https://api.example.com/send")) + .andExpect(content().string("{\"to\":\"123456789\",\"text\":\"Default greeting\"}")) + .andRespond(withSuccess("{}", MediaType.APPLICATION_JSON)); + + service.call("WhatsApp.sendMessage", Map.of("number", "123456789")); + + mockServer.verify(); + } + + @Test + void callReturnsBinaryResultWhenResponseIsNotJson() { + DynamiaHttpFunction function = whatsAppFunction(FunctionStatus.ACTIVE, 1); + whenResolvingLatestVersion(function); + + byte[] pdfBytes = {0x25, 0x50, 0x44, 0x46}; + mockServer.expect(requestTo("https://api.example.com/send")) + .andRespond(withSuccess(pdfBytes, MediaType.APPLICATION_PDF)); + + FunctionResult result = service.call("WhatsApp.sendMessage", Map.of("number", "123", "message", "Hi")); + + assertTrue(result.isBinary()); + assertFalse(result.isJson()); + assertEquals(MediaType.APPLICATION_PDF_VALUE, result.getContentType()); + assertEquals(4, result.getBinaryData().length); + } + + @Test + void callThrowsFunctionNotFoundExceptionWhenFunctionDoesNotExist() { + when(crudService.find(eq(DynamiaHttpFunction.class), any(QueryParameters.class))).thenReturn(List.of()); + + assertThrows(FunctionNotFoundException.class, + () -> service.call("Unknown.function", Map.of())); + + verify(crudService, never()).create(any()); + } + + @Test + void callThrowsFunctionInactiveExceptionWhenFunctionIsDraft() { + DynamiaHttpFunction function = whatsAppFunction(FunctionStatus.DRAFT, 1); + whenResolvingLatestVersion(function); + + assertThrows(FunctionInactiveException.class, + () -> service.call("WhatsApp.sendMessage", Map.of("number", "123", "message", "Hi"))); + } + + @Test + void callThrowsValidationErrorWhenRequiredParameterIsMissing() { + DynamiaHttpFunction function = whatsAppFunction(FunctionStatus.ACTIVE, 1); + whenResolvingLatestVersion(function); + + assertThrows(ValidationError.class, + () -> service.call("WhatsApp.sendMessage", Map.of("number", "123456789"))); + } + + @Test + void callWithAutoCreateRegistersDraftAndThrowsInactiveInsteadOfNotFound() { + when(crudService.find(eq(DynamiaHttpFunction.class), any(QueryParameters.class))).thenReturn(List.of()); + when(crudService.create(any(DynamiaHttpFunction.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + assertThrows(FunctionInactiveException.class, + () -> service.call("New.integration", Map.of(), true)); + + verify(crudService).create(any(DynamiaHttpFunction.class)); + } + + @Test + void callWithAutoCreateFalseThrowsNotFoundInsteadOfCreating() { + when(crudService.find(eq(DynamiaHttpFunction.class), any(QueryParameters.class))).thenReturn(List.of()); + + assertThrows(FunctionNotFoundException.class, + () -> service.call("New.integration", Map.of(), false)); + + verify(crudService, never()).create(any()); + } + + @Test + void callWrapsHttpFailureAsFunctionExecutionException() { + DynamiaHttpFunction function = whatsAppFunction(FunctionStatus.ACTIVE, 1); + whenResolvingLatestVersion(function); + + mockServer.expect(requestTo("https://api.example.com/send")) + .andRespond(withServerError()); + + assertThrows(FunctionExecutionException.class, + () -> service.call("WhatsApp.sendMessage", Map.of("number", "123", "message", "Hi"))); + } + + @Test + void callResolvesExplicitVersion() { + DynamiaHttpFunction v2 = whatsAppFunction(FunctionStatus.ACTIVE, 2); + when(crudService.findSingle(eq(DynamiaHttpFunction.class), any(QueryParameters.class))).thenReturn(v2); + + mockServer.expect(requestTo("https://api.example.com/send")) + .andRespond(withStatus(HttpStatus.OK).body("{}").contentType(MediaType.APPLICATION_JSON)); + + FunctionResult result = service.call("WhatsApp.sendMessage", 2, Map.of("number", "123", "message", "Hi")); + + assertTrue(result.isSuccess()); + verify(crudService).findSingle(eq(DynamiaHttpFunction.class), any(QueryParameters.class)); + } + + private void whenResolvingLatestVersion(DynamiaHttpFunction function) { + when(crudService.find(eq(DynamiaHttpFunction.class), any(QueryParameters.class))) + .thenReturn(List.of(function)); + } + + private DynamiaHttpFunction whatsAppFunction(FunctionStatus status, int version) { + DynamiaHttpFunction function = new DynamiaHttpFunction(); + function.setName("WhatsApp.sendMessage"); + function.setFunctionVersion(version); + function.setStatus(status); + function.setMethod(HttpMethod.POST); + function.setUrl("https://api.example.com/send"); + function.setContentType("application/json"); + function.setBodyTemplate("{\"to\":\"${number}\",\"text\":\"${message}\"}"); + + function.addParameter(parameter("number", ParameterDataType.STRING, true)); + function.addParameter(parameter("message", ParameterDataType.STRING, true)); + return function; + } + + private DynamiaHttpFunctionParameter parameter(String name, ParameterDataType type, boolean required) { + DynamiaHttpFunctionParameter parameter = new DynamiaHttpFunctionParameter(); + parameter.setName(name); + parameter.setType(type); + parameter.setRequired(required); + return parameter; + } +} diff --git a/extensions/http-functions/sources/pom.xml b/extensions/http-functions/sources/pom.xml new file mode 100644 index 00000000..bec284fb --- /dev/null +++ b/extensions/http-functions/sources/pom.xml @@ -0,0 +1,72 @@ + + 4.0.0 + + + tools.dynamia.modules + tools.dynamia.modules.parent + 26.6.0 + ../../pom.xml + + + DynamiaModules - Http Functions + tools.dynamia.modules.functions.parent + pom + DynamiaTools extension to create and handle http functions, a way to create functions that can be + called from Java like normal code and return a response with the result of the function execution. + + 2026 + https://dynamia.tools/modules/functions + + + + Dynamia Soluciones IT SAS + https://www.dynamiasoluciones.com + + + + + Mario Serrano Leones + mario@dynamiasoluciones.com + Dynamia Soluciones IT + https://www.dynamiasoluciones.com + + + + + + APACHE LICENSE, VERSION 2.0 + https://www.apache.org/licenses/LICENSE-2.0 + repo + + + + + core + ui + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven.compiler} + + ${java.version} + ${java.version} + ${source.encoding} + true + + + + + + + + tools.dynamia.modules + tools.dynamia.modules.saas.jpa + 26.6.0 + + + diff --git a/extensions/http-functions/sources/ui/pom.xml b/extensions/http-functions/sources/ui/pom.xml new file mode 100644 index 00000000..90ab317a --- /dev/null +++ b/extensions/http-functions/sources/ui/pom.xml @@ -0,0 +1,29 @@ + + 4.0.0 + + + tools.dynamia.modules + tools.dynamia.modules.functions.parent + 26.6.0 + + + DynamiaModules - Http Functions UI + tools.dynamia.modules.functions.ui + ZK UI for the Http Functions extension: navigation pages, view descriptors and actions to + create, configure and test DynamiaHttpFunctions from the back office. + + + + + tools.dynamia + tools.dynamia.zk + 26.6.0 + + + tools.dynamia.modules + tools.dynamia.modules.functions.core + 26.6.0 + + + diff --git a/extensions/http-functions/sources/ui/src/main/java/tools/dynamia/modules/functions/ui/DynamiaHttpFunctionsModuleProvider.java b/extensions/http-functions/sources/ui/src/main/java/tools/dynamia/modules/functions/ui/DynamiaHttpFunctionsModuleProvider.java new file mode 100644 index 00000000..68e23f24 --- /dev/null +++ b/extensions/http-functions/sources/ui/src/main/java/tools/dynamia/modules/functions/ui/DynamiaHttpFunctionsModuleProvider.java @@ -0,0 +1,32 @@ +package tools.dynamia.modules.functions.ui; + +import tools.dynamia.crud.CrudPage; +import tools.dynamia.integration.sterotypes.Provider; +import tools.dynamia.modules.functions.domain.DynamiaHttpFunction; +import tools.dynamia.navigation.Module; +import tools.dynamia.navigation.ModuleProvider; +import tools.dynamia.navigation.PageGroup; + +/** + * Contributes the Http Functions back office pages to the existing {@code saas} module (see + * {@code tools.dynamia.modules.saas.ui.SaasModuleProvider}), instead of registering a brand-new + * top-level module, since functions are managed as part of the account/integration configuration. + * + * @author Mario A. Serrano Leones + */ +@Provider +public class DynamiaHttpFunctionsModuleProvider implements ModuleProvider { + + @Override + public Module getModule() { + Module saas = Module.getRef("saas"); + + PageGroup group = new PageGroup("httpFunctions", "Http Functions"); + group.addPage(new CrudPage("httpFunctions", "Functions", DynamiaHttpFunction.class) + .icon("bolt") + .longName("Http Functions")); + + saas.addPageGroup(group); + return saas; + } +} diff --git a/extensions/http-functions/sources/ui/src/main/java/tools/dynamia/modules/functions/ui/actions/TestFunctionCallRequest.java b/extensions/http-functions/sources/ui/src/main/java/tools/dynamia/modules/functions/ui/actions/TestFunctionCallRequest.java new file mode 100644 index 00000000..d0ffb706 --- /dev/null +++ b/extensions/http-functions/sources/ui/src/main/java/tools/dynamia/modules/functions/ui/actions/TestFunctionCallRequest.java @@ -0,0 +1,29 @@ +package tools.dynamia.modules.functions.ui.actions; + +/** + * Backing bean for the "Test function" dialog: lets the operator pick the version to call and edit the + * call parameters as raw JSON before invoking {@link tools.dynamia.modules.functions.domain.DynamiaHttpFunction}. + * + * @author Mario A. Serrano Leones + */ +public class TestFunctionCallRequest { + + private Integer version; + private String parametersJson = "{}"; + + public Integer getVersion() { + return version; + } + + public void setVersion(Integer version) { + this.version = version; + } + + public String getParametersJson() { + return parametersJson; + } + + public void setParametersJson(String parametersJson) { + this.parametersJson = parametersJson; + } +} diff --git a/extensions/http-functions/sources/ui/src/main/java/tools/dynamia/modules/functions/ui/actions/TestHttpFunctionAction.java b/extensions/http-functions/sources/ui/src/main/java/tools/dynamia/modules/functions/ui/actions/TestHttpFunctionAction.java new file mode 100644 index 00000000..cb6b2961 --- /dev/null +++ b/extensions/http-functions/sources/ui/src/main/java/tools/dynamia/modules/functions/ui/actions/TestHttpFunctionAction.java @@ -0,0 +1,144 @@ +package tools.dynamia.modules.functions.ui.actions; + +import org.zkoss.zul.Messagebox; +import tools.jackson.databind.ObjectMapper; +import tools.dynamia.actions.FastAction; +import tools.dynamia.actions.InstallAction; +import tools.dynamia.commons.ApplicableClass; +import tools.dynamia.crud.AbstractCrudAction; +import tools.dynamia.crud.CrudActionEvent; +import tools.dynamia.crud.CrudState; +import tools.dynamia.domain.ValidationError; +import tools.dynamia.modules.functions.FunctionExecutionException; +import tools.dynamia.modules.functions.FunctionInactiveException; +import tools.dynamia.modules.functions.FunctionNotFoundException; +import tools.dynamia.modules.functions.FunctionResult; +import tools.dynamia.modules.functions.domain.DynamiaHttpFunction; +import tools.dynamia.modules.functions.domain.DynamiaHttpFunctionParameter; +import tools.dynamia.modules.functions.services.DynamiaHttpFunctionsService; +import tools.dynamia.ui.MessageType; +import tools.dynamia.ui.UIMessages; +import tools.dynamia.viewers.ViewDescriptor; +import tools.dynamia.zk.util.ZKUtil; +import tools.dynamia.zk.viewers.ui.Viewer; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static tools.dynamia.viewers.ViewDescriptorBuilder.field; +import static tools.dynamia.viewers.ViewDescriptorBuilder.viewDescriptor; + +/** + * Crud table/toolbar action that lets an operator try a {@link DynamiaHttpFunction} on demand, editing + * the call parameters as JSON and immediately seeing the {@link FunctionResult}, without leaving the + * back office or writing any code. + * + * @author Mario A. Serrano Leones + */ +@InstallAction +public class TestHttpFunctionAction extends AbstractCrudAction { + + private final DynamiaHttpFunctionsService functionsService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public TestHttpFunctionAction(DynamiaHttpFunctionsService functionsService) { + setName("Test"); + setDescription("Call this function with custom parameters and see the result"); + setImage("bolt"); + setMenuSupported(true); + setApplicableStates(CrudState.get(CrudState.READ, CrudState.CREATE, CrudState.UPDATE)); + this.functionsService = functionsService; + } + + @Override + public CrudState[] getApplicableStates() { + return CrudState.get(CrudState.READ); + } + + @Override + public ApplicableClass[] getApplicableClasses() { + return ApplicableClass.get(DynamiaHttpFunction.class); + } + + @Override + public void actionPerformed(CrudActionEvent evt) { + DynamiaHttpFunction function = (DynamiaHttpFunction) evt.getData(); + if (function != null) { + Viewer viewer = createView(function); + ZKUtil.showDialog("Test Function: " + function.getName() + " v" + function.getFunctionVersion(), viewer, "60%", null); + } else { + UIMessages.showMessage("Select a function to test", MessageType.WARNING); + } + } + + private Viewer createView(DynamiaHttpFunction function) { + ViewDescriptor descriptor = viewDescriptor("form", TestFunctionCallRequest.class, false) + .id("testHttpFunctionForm") + .fields(field("version"), + field("parametersJson") + .label("Parameters (JSON)") + .params("multiline", true, "height", "220px")) + .layout("columns", 1) + .build(); + + Viewer viewer = new Viewer(descriptor); + + TestFunctionCallRequest request = new TestFunctionCallRequest(); + request.setVersion(function.getFunctionVersion()); + request.setParametersJson(sampleParametersJson(function)); + viewer.setValue(request); + + viewer.addAction(new FastAction("Run", evt -> runFunction(function, request))); + viewer.setVflex(null); + viewer.setContentVflex(null); + return viewer; + } + + private void runFunction(DynamiaHttpFunction function, TestFunctionCallRequest request) { + try { + Map params = parseParams(request.getParametersJson()); + FunctionResult result = functionsService.call(function.getName(), request.getVersion(), params); + Messagebox.show(formatResult(result), "Function Result", Messagebox.OK, + result.isSuccess() ? Messagebox.INFORMATION : Messagebox.EXCLAMATION); + } catch (FunctionNotFoundException | FunctionInactiveException | ValidationError e) { + UIMessages.showMessage(e.getMessage(), MessageType.ERROR); + } catch (FunctionExecutionException e) { + UIMessages.showMessage(e.getMessage(), MessageType.ERROR); + } catch (Exception e) { + UIMessages.showMessage("Invalid parameters JSON: " + e.getMessage(), MessageType.ERROR); + } + } + + private Map parseParams(String json) { + if (json == null || json.isBlank()) { + return Map.of(); + } + return objectMapper.readValue(json, Map.class); + } + + private String formatResult(FunctionResult result) { + if (!result.isSuccess()) { + return "Error: " + result.getErrorMessage(); + } + if (result.isBinary()) { + return "Binary response (" + result.getContentType() + "), " + result.getBinaryData().length + " bytes"; + } + try { + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(result.getData()); + } catch (Exception e) { + return String.valueOf(result.getData()); + } + } + + private String sampleParametersJson(DynamiaHttpFunction function) { + Map sample = new LinkedHashMap<>(); + for (DynamiaHttpFunctionParameter parameter : function.getParameters()) { + sample.put(parameter.getName(), parameter.getDefaultValue() != null ? parameter.getDefaultValue() : ""); + } + try { + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(sample); + } catch (Exception e) { + return "{}"; + } + } +} diff --git a/extensions/http-functions/sources/ui/src/main/resources/META-INF/descriptors/DynamiaHttpFunctionCrud.yml b/extensions/http-functions/sources/ui/src/main/resources/META-INF/descriptors/DynamiaHttpFunctionCrud.yml new file mode 100644 index 00000000..e69db153 --- /dev/null +++ b/extensions/http-functions/sources/ui/src/main/resources/META-INF/descriptors/DynamiaHttpFunctionCrud.yml @@ -0,0 +1,6 @@ +view: crud +beanClass: tools.dynamia.modules.functions.domain.DynamiaHttpFunction +autofields: false + +params: + queryProjection: true diff --git a/extensions/http-functions/sources/ui/src/main/resources/META-INF/descriptors/DynamiaHttpFunctionForm.yml b/extensions/http-functions/sources/ui/src/main/resources/META-INF/descriptors/DynamiaHttpFunctionForm.yml new file mode 100644 index 00000000..22b4de34 --- /dev/null +++ b/extensions/http-functions/sources/ui/src/main/resources/META-INF/descriptors/DynamiaHttpFunctionForm.yml @@ -0,0 +1,73 @@ +view: form +beanClass: tools.dynamia.modules.functions.domain.DynamiaHttpFunction +autofields: false + +fields: + name: + params: + span: 2 + functionVersion: + params: + span: 1 + status: + params: + span: 1 + description: + params: + span: 4 + multiline: true + height: 60px + + method: + params: + span: 1 + url: + params: + span: 3 + contentType: + params: + span: 2 + headers: + params: + span: 2 + multiline: true + height: 100px + bodyTemplate: + label: Body Template + params: + span: 4 + multiline: true + height: 140px + + parameters: + component: crudview + params: + inplace: true + height: 300px + span: 4 + + interfaceName: + params: + span: 2 + methodName: + params: + span: 2 + metadata: + params: + span: 4 + multiline: true + height: 100px + +groups: + request: + label: Request + fields: [method, url, contentType, headers, bodyTemplate] + parameters: + label: Parameters + fields: [parameters] + advanced: + label: Advanced + fields: [interfaceName, methodName, metadata] + +layout: + columns: 4 diff --git a/extensions/http-functions/sources/ui/src/main/resources/META-INF/descriptors/DynamiaHttpFunctionParameterForm.yml b/extensions/http-functions/sources/ui/src/main/resources/META-INF/descriptors/DynamiaHttpFunctionParameterForm.yml new file mode 100644 index 00000000..4ed0057c --- /dev/null +++ b/extensions/http-functions/sources/ui/src/main/resources/META-INF/descriptors/DynamiaHttpFunctionParameterForm.yml @@ -0,0 +1,28 @@ +view: form +beanClass: tools.dynamia.modules.functions.domain.DynamiaHttpFunctionParameter +autofields: false + +fields: + name: + params: + span: 2 + type: + params: + span: 1 + required: + params: + span: 1 + defaultValue: + params: + span: 2 + position: + params: + span: 1 + description: + params: + span: 4 + multiline: true + height: 60px + +layout: + columns: 4 diff --git a/extensions/http-functions/sources/ui/src/main/resources/META-INF/descriptors/DynamiaHttpFunctionParameterTable.yml b/extensions/http-functions/sources/ui/src/main/resources/META-INF/descriptors/DynamiaHttpFunctionParameterTable.yml new file mode 100644 index 00000000..3294d4fb --- /dev/null +++ b/extensions/http-functions/sources/ui/src/main/resources/META-INF/descriptors/DynamiaHttpFunctionParameterTable.yml @@ -0,0 +1,21 @@ +view: table +beanClass: tools.dynamia.modules.functions.domain.DynamiaHttpFunctionParameter +autofields: false + +fields: + position: + params: + header: { width: 60px, align: center } + name: + type: + params: + header: { width: 110px } + required: + params: + header: { width: 90px, align: center } + defaultValue: + label: Default + description: + +params: + orderBy: position diff --git a/extensions/http-functions/sources/ui/src/main/resources/META-INF/descriptors/DynamiaHttpFunctionTable.yml b/extensions/http-functions/sources/ui/src/main/resources/META-INF/descriptors/DynamiaHttpFunctionTable.yml new file mode 100644 index 00000000..53653c96 --- /dev/null +++ b/extensions/http-functions/sources/ui/src/main/resources/META-INF/descriptors/DynamiaHttpFunctionTable.yml @@ -0,0 +1,30 @@ +view: table +beanClass: tools.dynamia.modules.functions.domain.DynamiaHttpFunction +autofields: false + +fields: + name: + functionVersion: + label: Version + params: + header: { width: 90px, align: center } + method: + params: + header: { width: 90px } + url: + status: + component: enumlabel + params: + defaultSclass: functionStatus + sclassPrefix: status + header: { width: 110px } + +params: + enumColors: + name: status + colors: + ACTIVE: "#e6f7e6" + DRAFT: "#fff7e0" + INACTIVE: "#f0f0f0" + DELETED: "#fde8e8" + orderBy: name diff --git a/extensions/pom.xml b/extensions/pom.xml index 6a3058a9..5408e145 100644 --- a/extensions/pom.xml +++ b/extensions/pom.xml @@ -26,6 +26,7 @@ file-importer/sources reports/sources finances/sources + http-functions/sources diff --git a/platform/core/web/src/main/java/tools/dynamia/web/HttpMethod.java b/platform/core/web/src/main/java/tools/dynamia/web/HttpMethod.java new file mode 100644 index 00000000..4324fcfb --- /dev/null +++ b/platform/core/web/src/main/java/tools/dynamia/web/HttpMethod.java @@ -0,0 +1,9 @@ +package tools.dynamia.web; + +/** + * Enum that represents the HTTP methods used in web requests. + * @author Mario A. Serrano Leones + */ +public enum HttpMethod { + POST, GET, PUT, DELETE, PATCH +}