diff --git a/CHANGELOG.md b/CHANGELOG.md index 1636f1a..ce4d814 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Add the typed Functions API client, execution and deployment operations, and examples. + ## 2.0.0 - Establish the initial SDK release with the multi-module build, shared transport, typed failures, pagination, polling, and SSE support. diff --git a/README.md b/README.md index e75e70d..fdf542e 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ The configured request timeout applies to each ordinary call attempt and `stream Browse the [service API reference](./docs/api/README.md) for all named operations and route signatures. +Functions endpoints are not in the current OpenAPI snapshot; see the [Functions API guide](./docs/api/functions.md) for its typed client and example. ## Build and verify diff --git a/core/src/main/java/dev/frontal/sdk/api/ApiClient.java b/core/src/main/java/dev/frontal/sdk/api/ApiClient.java index e3329ea..faf63f1 100644 --- a/core/src/main/java/dev/frontal/sdk/api/ApiClient.java +++ b/core/src/main/java/dev/frontal/sdk/api/ApiClient.java @@ -178,7 +178,7 @@ public ApiStream streamBody(Endpoint endpoint, List pathParams, QueryPar String requestId = UUID.randomUUID().toString(); RequestBody requestBody = requestBody(endpoint.method(), body); String contentType = contentType(body); - int attempts = endpoint.method().wireMethod().equals("GET") ? config.maxRetries() + 1 : 1; + int attempts = endpoint.method().wireMethod().equals("GET") ? config.maxRetriesFor(endpoint.service()) + 1 : 1; for (int attempt = 0; attempt < attempts; attempt++) { Request request = buildRequest(endpoint, pathParams, query, requestBody, contentType, "*/*", requestId); Response response; @@ -369,7 +369,7 @@ private ApiResponse exchange( String accept) throws IOException, InterruptedException { String requestId = UUID.randomUUID().toString(); - int attempts = endpoint.method().wireMethod().equals("GET") ? config.maxRetries() + 1 : 1; + int attempts = endpoint.method().wireMethod().equals("GET") ? config.maxRetriesFor(endpoint.service()) + 1 : 1; for (int attempt = 0; attempt < attempts; attempt++) { Request request = buildRequest(endpoint, pathParams, query, requestBody, contentType, accept, requestId); Response response; diff --git a/core/src/main/java/dev/frontal/sdk/config/ClientConfig.java b/core/src/main/java/dev/frontal/sdk/config/ClientConfig.java index 641b91c..c0232d6 100644 --- a/core/src/main/java/dev/frontal/sdk/config/ClientConfig.java +++ b/core/src/main/java/dev/frontal/sdk/config/ClientConfig.java @@ -12,6 +12,8 @@ public final class ClientConfig { public static final URI DEFAULT_API_BASE_URL = URI.create("https://api.frontal.dev/v1"); public static final URI DEFAULT_AI_BASE_URL = URI.create("https://ai.frontal.dev"); + private static final int DEFAULT_MAX_RETRIES = 2; + private static final int DEFAULT_FUNCTIONS_MAX_RETRIES = 3; private final @Nullable String apiKey; private final URI apiBaseUrl; @@ -19,6 +21,7 @@ public final class ClientConfig { private final Duration connectTimeout; private final Duration requestTimeout; private final int maxRetries; + private final boolean maxRetriesExplicit; private final long maxResponseBytes; private final int maxErrorBodyBytes; private final String userAgent; @@ -42,6 +45,7 @@ private ClientConfig(Builder builder) { throw new IllegalArgumentException("maxErrorBodyBytes must be positive"); } maxRetries = builder.maxRetries; + maxRetriesExplicit = builder.maxRetriesExplicit; maxResponseBytes = builder.maxResponseBytes; maxErrorBodyBytes = builder.maxErrorBodyBytes; userAgent = requireText(builder.userAgent, "userAgent"); @@ -131,6 +135,14 @@ public int maxRetries() { return maxRetries; } + int maxRetriesFor(ApiService service) { + Objects.requireNonNull(service, "service"); + if (!maxRetriesExplicit && service == ApiService.FUNCTIONS) { + return DEFAULT_FUNCTIONS_MAX_RETRIES; + } + return maxRetries; + } + public long maxResponseBytes() { return maxResponseBytes; } @@ -213,7 +225,8 @@ public static final class Builder { private URI aiBaseUrl = DEFAULT_AI_BASE_URL; private Duration connectTimeout = Duration.ofSeconds(10); private Duration requestTimeout = Duration.ofSeconds(30); - private int maxRetries = 2; + private int maxRetries = DEFAULT_MAX_RETRIES; + private boolean maxRetriesExplicit; private long maxResponseBytes = 64L * 1024 * 1024; private int maxErrorBodyBytes = 16 * 1024; private String userAgent = "frontal-java-sdk/2.0.0"; @@ -259,6 +272,7 @@ public Builder requestTimeout(Duration requestTimeout) { public Builder maxRetries(int maxRetries) { this.maxRetries = maxRetries; + this.maxRetriesExplicit = true; return this; } diff --git a/core/src/main/java/dev/frontal/sdk/models/ApiService.java b/core/src/main/java/dev/frontal/sdk/models/ApiService.java index babcfd6..18f5eb4 100644 --- a/core/src/main/java/dev/frontal/sdk/models/ApiService.java +++ b/core/src/main/java/dev/frontal/sdk/models/ApiService.java @@ -21,6 +21,7 @@ public enum ApiService { WORKFLOWS("workflows"), CONNECTION_TESTS("connection-tests"), EVENTS("events"), + FUNCTIONS("functions"), INVOCATIONS("invocations"), PROVIDERS("providers"), WEBHOOK_ENDPOINTS("webhook-endpoints"); diff --git a/docs/README.md b/docs/README.md index fd99a1d..cf66699 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,3 +1,3 @@ # Java documentation -Read `OVERVIEW.md` for the SDK surface, `ARCHITECTURE.md` for the module and request flow, and `ONBOARDING.md` to set up the toolchain. Browse [the service API reference](./api/README.md) for the generated operation catalog. See the root README for pagination, error handling, streaming, and serialization examples. Use `TESTING.md` for verification and `PUBLISHING.md` for release steps. +Read `OVERVIEW.md` for the SDK surface, `ARCHITECTURE.md` for the module and request flow, and `ONBOARDING.md` to set up the toolchain. Browse [the service API reference](./api/README.md) for the generated operation catalog and the [Functions API guide](./api/functions.md) for the Functions routes, which are not in the current contract snapshot. See the root README for pagination, error handling, streaming, and serialization examples. Use `TESTING.md` for verification and `PUBLISHING.md` for release steps. diff --git a/docs/api/README.md b/docs/api/README.md index e1d401d..2b65887 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -2,29 +2,4 @@ Generated from the committed route catalog for SDK service clients. -Call-shaped listing of every operation: [methods as code](./methods-as-code.md). - -| Service | Routes | Reference | -| --- | ---: | --- | -| agents | 14 | [agents](./agents.md) | -| ai | 9 | [ai](./ai.md) | -| audit | 4 | [audit](./audit.md) | -| auth | 66 | [auth](./auth.md) | -| billing | 23 | [billing](./billing.md) | -| blob | 8 | [blob](./blob.md) | -| connection-tests | 1 | [connection-tests](./connection-tests.md) | -| connectors | 12 | [connectors](./connectors.md) | -| data | 151 | [data](./data.md) | -| events | 17 | [events](./events.md) | -| governance | 24 | [governance](./governance.md) | -| invocations | 1 | [invocations](./invocations.md) | -| lineage | 7 | [lineage](./lineage.md) | -| observability | 31 | [observability](./observability.md) | -| ontology | 171 | [ontology](./ontology.md) | -| pipelines | 11 | [pipelines](./pipelines.md) | -| providers | 1 | [providers](./providers.md) | -| react | 0 | [react](./react.md) | -| schedules | 10 | [schedules](./schedules.md) | -| webhook-endpoints | 7 | [webhook-endpoints](./webhook-endpoints.md) | -| webhooks | 10 | [webhooks](./webhooks.md) | -| workflows | 30 | [workflows](./workflows.md) | +The Functions endpoints currently come from the TypeScript SDK because they are not in the committed contract inventory. See the [Functions API guide](./functions.md) for their Java client methods and models. diff --git a/docs/api/functions.md b/docs/api/functions.md new file mode 100644 index 0000000..f7a3ca1 --- /dev/null +++ b/docs/api/functions.md @@ -0,0 +1,60 @@ +# Functions API + +The Java Functions client follows the operation and payload shapes in the [TypeScript Functions package](https://github.com/frontal-labs/sdk-typescript/blob/master/packages/functions/src/namespace.ts). These routes are not present in the checked-in OpenAPI snapshots or `contracts/sdk-endpoints.json` yet, so Functions uses the shared Java transport with a local route catalog. + +## Client and configuration + +Use `Frontal.functions()` to access this API. It shares the configured bearer API key, API base URL, timeout, retry behavior, environment header, and Jackson mapper with the rest of the SDK. + +```java +try (Frontal frontal = Frontal.builder() + .apiKey(System.getenv("FRONTAL_API_KEY")) + .apiBaseUrl("https://api.frontal.dev/v1") + .requestTimeout(Duration.ofSeconds(30)) + .maxRetries(3) + .build()) { + FunctionDefinition definition = FunctionDefinition.builder("hello") + .runtime(FunctionRuntime.NODEJS22) + .entrypoint("index.handler") + .build(); + FunctionResource function = frontal.functions().create(definition); + FunctionInvocationResult result = frontal.functions() + .executions() + .invoke(new FunctionInvocationInput(function.id())); +} +``` + +The default API URL is `https://api.frontal.dev/v1`, request timeout is 30 seconds, and Functions GET requests retry up to three times by default, matching the TypeScript package. Setting `maxRetries` on the builder explicitly overrides the retry count for Functions and other services. Other Java services retain the shared default of two retries. + +## Operations + +| Java call | HTTP operation | +| --- | --- | +| `functions.create(definition)` | `POST /functions` | +| `functions.list(query)` | `GET /functions?cursor=&limit=` | +| `functions.get(functionId)` | `GET /functions/{functionId}` | +| `functions.update(functionId, definition)` | `PATCH /functions/{functionId}` | +| `functions.delete(functionId)` | `DELETE /functions/{functionId}` | +| `functions.versions().list(functionId, query)` | `GET /functions/{functionId}/versions?cursor=&limit=` | +| `functions.versions().get(functionId, version)` | `GET /functions/{functionId}/versions/{version}` | +| `functions.versions().publish(functionId, version)` | `POST /functions/{functionId}/versions/{version}/publish` | +| `functions.deployments().deploy(functionId, version)` | `POST /functions/{functionId}/versions/{version}/deploy` | +| `functions.deployments().status(functionId, version)` | `GET /functions/{functionId}/versions/{version}/deployment/status` | +| `functions.executions().invoke(input)` | `POST /functions/invoke` | +| `functions.executions().invokeAsync(input)` | `POST /functions/invoke-async` | +| `functions.executions().getExecution(executionId)` | `GET /functions/executions/{executionId}` | +| `functions.executions().getResult(executionId)` | `GET /functions/executions/{executionId}/result` | +| `functions.executions().listExecutions(query)` | `GET /functions/executions?cursor=&limit=&functionId=&status=` | +| `functions.executions().cancelExecution(executionId)` | `POST /functions/executions/{executionId}/cancel` | + +List query values are provided with `QueryParams.builder()`. IDs are encoded as individual URL path segments. Service calls are synchronous and report transport/API failures through the SDK's `IOException` hierarchy. + +## Models and JSON values + +`FunctionDefinition` requires `name`, `runtime`, and `entrypoint`. Its builder accepts optional `description`, `source`, `inputSchema`, `outputSchema`, `dependencies`, `envVars`, `secrets`, `memory` (MB), `timeout` (seconds), and `permissions` (`ontology` and `actions` string lists). Runtime values are `NODEJS20`, `NODEJS22`, and `PYTHON311`; their JSON forms are `nodejs20`, `nodejs22`, and `python311`. + +Schemas and invocation inputs/results use Jackson `JsonNode`, so nested object keys and arbitrary JSON values remain under caller control. Resource statuses use `FunctionStatus` (`DRAFT`, `ACTIVE`, `DEPRECATED`, `FAILED`) with the corresponding lowercase wire values. List models expose their `functions`, `versions`, or `executions` collection and `FunctionPagination` metadata. + +## Runnable example + +See [`FunctionsQuickstartTest`](../../examples/src/test/java/dev/frontal/examples/FunctionsQuickstartTest.java) for an executable example that constructs a configured client, defines a function, and invokes it against MockWebServer. \ No newline at end of file diff --git a/examples/src/test/java/dev/frontal/examples/FunctionsQuickstartTest.java b/examples/src/test/java/dev/frontal/examples/FunctionsQuickstartTest.java new file mode 100644 index 0000000..68b19dd --- /dev/null +++ b/examples/src/test/java/dev/frontal/examples/FunctionsQuickstartTest.java @@ -0,0 +1,57 @@ +package dev.frontal.examples; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.fasterxml.jackson.databind.JsonNode; +import dev.frontal.sdk.Frontal; +import dev.frontal.sdk.FunctionDefinition; +import dev.frontal.sdk.FunctionInvocationInput; +import dev.frontal.sdk.FunctionPermission; +import dev.frontal.sdk.FunctionRuntime; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.Test; + +class FunctionsQuickstartTest { + @Test + void definesAndInvokesAFunctionWithThePublicClient() throws Exception { + try (MockWebServer mock = new MockWebServer()) { + mock.start(); + mock.enqueue(new MockResponse() + .setBody("{\"id\":\"fn_123\",\"name\":\"hello\"," + + "\"runtime\":\"nodejs22\",\"entrypoint\":\"index.handler\"," + + "\"status\":\"active\",\"version\":1,\"createdAt\":\"2026-10-09T10:00:00Z\"," + + "\"updatedAt\":\"2026-10-09T10:00:00Z\"}")); + mock.enqueue(new MockResponse() + .setBody("{\"executionId\":\"exec_123\",\"result\":{\"message\":\"hello, Ada\"}}")); + + try (Frontal frontal = Frontal.builder() + .apiKey(System.getenv().getOrDefault("FRONTAL_API_KEY", "frt_example_key")) + .apiBaseUrl(mock.url("/v1").toString()) + .requestTimeout(Duration.ofSeconds(5)) + .maxRetries(0) + .build()) { + FunctionDefinition definition = FunctionDefinition.builder("hello") + .description("Greets a person") + .runtime(FunctionRuntime.NODEJS22) + .entrypoint("index.handler") + .source("blob://functions/hello") + .dependencies(List.of("zod")) + .envVars(Map.of("GREETING", "hello")) + .permissions(new FunctionPermission(List.of(), List.of("read:name"))) + .build(); + var created = frontal.functions().create(definition); + JsonNode input = + frontal.apiClient().objectMapper().createObjectNode().put("name", "Ada"); + var invocation = + frontal.functions().executions().invoke(new FunctionInvocationInput(created.id(), input)); + + assertEquals("fn_123", created.id()); + assertEquals("hello, Ada", invocation.result().path("message").asText()); + } + } + } +} diff --git a/sdk/src/main/java/dev/frontal/sdk/api/Frontal.java b/sdk/src/main/java/dev/frontal/sdk/api/Frontal.java index 8c8f53b..80f6db1 100644 --- a/sdk/src/main/java/dev/frontal/sdk/api/Frontal.java +++ b/sdk/src/main/java/dev/frontal/sdk/api/Frontal.java @@ -43,6 +43,7 @@ public Frontal(ApiClient apiClient) { case CONNECTION_TESTS -> new ConnectionTestsClient(apiClient); case DATA -> new DataClient(apiClient); case EVENTS -> new EventsClient(apiClient); + case FUNCTIONS -> new FunctionsClient(apiClient); case GOVERNANCE -> new GovernanceClient(apiClient); case INVOCATIONS -> new InvocationsClient(apiClient); case LINEAGE -> new LineageClient(apiClient); @@ -120,6 +121,11 @@ public EventsClient events() { return (EventsClient) service(ApiService.EVENTS); } + /** Returns the Functions API client. */ + public FunctionsClient functions() { + return (FunctionsClient) service(ApiService.FUNCTIONS); + } + public GovernanceClient governance() { return (GovernanceClient) service(ApiService.GOVERNANCE); } diff --git a/sdk/src/test/java/dev/frontal/sdk/FunctionsClientTest.java b/sdk/src/test/java/dev/frontal/sdk/FunctionsClientTest.java new file mode 100644 index 0000000..355ced1 --- /dev/null +++ b/sdk/src/test/java/dev/frontal/sdk/FunctionsClientTest.java @@ -0,0 +1,287 @@ +package dev.frontal.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class FunctionsClientTest { + private static final ObjectMapper JSON = new ObjectMapper(); + private static final String FUNCTION = "{\"id\":\"fn_1\",\"name\":\"calculate\",\"runtime\":\"nodejs22\"," + + "\"entrypoint\":\"index.handler\",\"status\":\"active\",\"version\":1," + + "\"createdAt\":\"2026-10-09T10:00:00Z\",\"updatedAt\":\"2026-10-09T10:00:00Z\"}"; + + private MockWebServer server; + + @BeforeEach + void startServer() throws Exception { + server = new MockWebServer(); + server.start(); + } + + @AfterEach + void stopServer() throws Exception { + server.shutdown(); + } + + @Test + void exposesAllFunctionsRoutesWithExpectedMethodsAndBearerAuthentication() throws Exception { + enqueue(FUNCTION); + enqueue("{\"functions\":[],\"pagination\":{\"hasMore\":false}}"); + enqueue(FUNCTION); + enqueue(FUNCTION); + enqueue(""); + enqueue("{\"versions\":[],\"pagination\":{\"cursor\":\"v1\",\"hasMore\":true}}"); + enqueue("{\"version\":1,\"source\":\"blob://fn/1\",\"createdAt\":\"2026-10-09T10:00:00Z\"}"); + enqueue("{\"version\":1,\"source\":\"blob://fn/1\",\"createdAt\":\"2026-10-09T10:00:00Z\"}"); + enqueue(""); + enqueue("{\"status\":\"ready\",\"details\":{\"region\":\"eu\"}}"); + enqueue("{\"executionId\":\"exec_1\",\"result\":{\"ok\":true}}"); + enqueue("{\"executionId\":\"exec_2\"}"); + enqueue("{\"id\":\"exec_1\",\"functionId\":\"fn_1\",\"version\":1,\"status\":\"active\"}"); + enqueue("{\"executionId\":\"exec_1\",\"result\":{\"ok\":true}}"); + enqueue("{\"executions\":[],\"pagination\":{\"hasMore\":false}}"); + enqueue(""); + + try (Frontal frontal = client()) { + FunctionDefinition definition = definition(JSON.createObjectNode().put("type", "object")); + frontal.functions().create(definition); + frontal.functions() + .list(QueryParams.builder() + .add("cursor", "c 1") + .add("limit", 10) + .build()); + frontal.functions().get("fn_1"); + frontal.functions().update("fn_1", definition); + frontal.functions().delete("fn_1"); + frontal.functions().versions().list("fn_1", QueryParams.of("limit", "5")); + frontal.functions().versions().get("fn_1", 1); + frontal.functions().versions().publish("fn_1", 1); + frontal.functions().deployments().deploy("fn_1", 1); + assertEquals( + "ready", frontal.functions().deployments().status("fn_1", 1).status()); + frontal.functions().executions().invoke(new FunctionInvocationInput("fn_1")); + frontal.functions().executions().invokeAsync(new FunctionInvocationInput("fn_1")); + frontal.functions().executions().getExecution("exec_1"); + frontal.functions().executions().getResult("exec_1"); + frontal.functions() + .executions() + .listExecutions(QueryParams.builder() + .add("cursor", "c1") + .add("limit", 20) + .add("functionId", "fn_1") + .add("status", "active") + .build()); + frontal.functions().executions().cancelExecution("exec_1"); + } + + List expected = List.of( + "POST /v1/functions", + "GET /v1/functions?cursor=c%201&limit=10", + "GET /v1/functions/fn_1", + "PATCH /v1/functions/fn_1", + "DELETE /v1/functions/fn_1", + "GET /v1/functions/fn_1/versions?limit=5", + "GET /v1/functions/fn_1/versions/1", + "POST /v1/functions/fn_1/versions/1/publish", + "POST /v1/functions/fn_1/versions/1/deploy", + "GET /v1/functions/fn_1/versions/1/deployment/status", + "POST /v1/functions/invoke", + "POST /v1/functions/invoke-async", + "GET /v1/functions/executions/exec_1", + "GET /v1/functions/executions/exec_1/result", + "GET /v1/functions/executions?cursor=c1&limit=20&functionId=fn_1&status=active", + "POST /v1/functions/executions/exec_1/cancel"); + for (String route : expected) { + RecordedRequest request = server.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(request); + assertEquals(route.split(" ", 2)[0], request.getMethod()); + assertEquals(route.split(" ", 2)[1], request.getPath()); + assertEquals("Bearer frt_test_key", request.getHeader("Authorization")); + } + } + + @Test + void serializesCamelCaseDefinitionFieldsAndPreservesNestedJsonKeys() throws Exception { + enqueue(FUNCTION); + JsonNode schema = JSON.readTree("{\"type\":\"object\",\"properties\":{\"camelCase\":{\"type\":\"string\"}}}"); + FunctionDefinition definition = FunctionDefinition.builder("calculate") + .description("Calculates a value") + .runtime(FunctionRuntime.NODEJS22) + .entrypoint("index.handler") + .inputSchema(schema) + .envVars(Map.of("MODE_NAME", "test")) + .permissions(new FunctionPermission(List.of("entities"), List.of("read:data"))) + .build(); + + try (Frontal frontal = client()) { + FunctionResource result = frontal.functions().create(definition); + assertEquals(FunctionRuntime.NODEJS22, result.runtime()); + RecordedRequest request = server.takeRequest(1, TimeUnit.SECONDS); + JsonNode body = JSON.readTree(request.getBody().readUtf8()); + assertEquals("nodejs22", body.path("runtime").asText()); + assertTrue(body.has("inputSchema")); + assertEquals( + "string", + body.path("inputSchema") + .path("properties") + .path("camelCase") + .path("type") + .asText()); + assertEquals("test", body.path("envVars").path("MODE_NAME").asText()); + assertEquals( + "read:data", body.path("permissions").path("actions").get(0).asText()); + assertTrue(!body.has("input_schema")); + assertTrue(!body.has("env_vars")); + assertTrue(!body.has("source")); + } + } + + @Test + void serializesArbitraryInvocationJsonAndDecodesNestedResultWithoutRenamingKeys() throws Exception { + enqueue("{\"executionId\":\"exec_1\",\"result\":{\"camelCase\":{\"user-key\":[1,{\"keepMe\":true}]}}}"); + JsonNode input = JSON.readTree("{\"camelCase\":{\"user-key\":[1,{\"keepMe\":true}]}}"); + try (Frontal frontal = client()) { + FunctionInvocationResult response = + frontal.functions().executions().invoke(new FunctionInvocationInput("fn_1", 2, input)); + assertEquals("exec_1", response.executionId()); + assertEquals(input, response.result()); + RecordedRequest request = server.takeRequest(1, TimeUnit.SECONDS); + JsonNode body = JSON.readTree(request.getBody().readUtf8()); + assertEquals("fn_1", body.path("functionId").asText()); + assertEquals(2, body.path("version").asInt()); + assertEquals(input, body.path("input")); + assertTrue(body.has("functionId")); + assertTrue(!body.has("function_id")); + } + } + + @Test + void ignoresAdditionalPropertiesInResourceListAndNestedPermissionResponses() throws Exception { + enqueue("{\"id\":\"fn_1\",\"name\":\"calculate\",\"runtime\":\"nodejs22\"," + + "\"entrypoint\":\"index.handler\",\"status\":\"active\",\"version\":1," + + "\"permissions\":{\"actions\":[\"read\"],\"futurePermissionField\":true}," + + "\"futureResourceField\":{\"enabled\":true}," + + "\"createdAt\":\"2026-10-09T10:00:00Z\",\"updatedAt\":\"2026-10-09T10:00:00Z\"}"); + enqueue("{\"functions\":[" + FUNCTION.substring(0, FUNCTION.length() - 1) + + ",\"futureResourceField\":true}],\"pagination\":{\"hasMore\":false," + + "\"futurePaginationField\":\"ignored\"},\"futureEnvelopeField\":1}"); + + try (Frontal frontal = client()) { + FunctionResource function = frontal.functions().get("fn_1"); + FunctionListResponse response = frontal.functions().list(); + assertEquals("fn_1", function.id()); + FunctionPermission permissions = Objects.requireNonNull(function.permissions()); + assertEquals(List.of("read"), permissions.actions()); + assertEquals("fn_1", response.functions().get(0).id()); + assertEquals(false, response.pagination().hasMore()); + } + } + + @Test + void encodesReservedPathIdsAsOnePathSegment() throws Exception { + enqueue(FUNCTION); + try (Frontal frontal = client()) { + frontal.functions().get("fn/a b"); + RecordedRequest request = server.takeRequest(1, TimeUnit.SECONDS); + assertEquals("/v1/functions/fn%2Fa%20b", request.getRequestUrl().encodedPath()); + } + } + + @Test + void mapsValidationErrorsThroughTheSharedExceptionHierarchy() { + enqueue(new MockResponse() + .setResponseCode(422) + .addHeader("x-request-id", "req_fn_1") + .setBody("{\"error\":{\"code\":\"INVALID_FUNCTION\",\"message\":\"runtime is required\"}}")); + try (Frontal frontal = client()) { + ValidationException exception = assertThrows( + ValidationException.class, + () -> frontal.functions() + .create(FunctionDefinition.builder("calculate") + .runtime(FunctionRuntime.PYTHON311) + .entrypoint("main.handler") + .build())); + assertEquals("INVALID_FUNCTION", exception.code()); + assertEquals("req_fn_1", exception.requestId()); + } + } + + @Test + void retriesFunctionGetAccordingToExplicitSharedConfiguration() throws Exception { + enqueue(new MockResponse().setResponseCode(503).setBody("temporary")); + enqueue(FUNCTION); + try (Frontal frontal = Frontal.builder() + .apiKey("frt_test_key") + .apiBaseUrl(server.url("/v1").toString()) + .requestTimeout(Duration.ofSeconds(3)) + .maxRetries(1) + .build()) { + assertEquals("fn_1", frontal.functions().get("fn_1").id()); + assertEquals(2, server.getRequestCount()); + } + } + + @Test + void usesThreeRetriesForFunctionsByDefault() throws Exception { + enqueue(new MockResponse() + .setResponseCode(503) + .addHeader("Retry-After", "0") + .setBody("temporary")); + enqueue(new MockResponse() + .setResponseCode(503) + .addHeader("Retry-After", "0") + .setBody("temporary")); + enqueue(new MockResponse() + .setResponseCode(503) + .addHeader("Retry-After", "0") + .setBody("temporary")); + enqueue(FUNCTION); + try (Frontal frontal = Frontal.builder() + .apiKey("frt_test_key") + .apiBaseUrl(server.url("/v1").toString()) + .build()) { + assertEquals("fn_1", frontal.functions().get("fn_1").id()); + assertEquals(4, server.getRequestCount()); + } + } + + private Frontal client() { + return Frontal.builder() + .apiKey("frt_test_key") + .apiBaseUrl(server.url("/v1").toString()) + .requestTimeout(Duration.ofSeconds(3)) + .maxRetries(0) + .build(); + } + + private FunctionDefinition definition(JsonNode schema) { + return FunctionDefinition.builder("calculate") + .runtime(FunctionRuntime.NODEJS22) + .entrypoint("index.handler") + .inputSchema(schema) + .build(); + } + + private void enqueue(String body) { + enqueue(new MockResponse().setBody(body)); + } + + private void enqueue(MockResponse response) { + server.enqueue(response); + } +} diff --git a/services/src/main/java/dev/frontal/sdk/models/FunctionAsyncInvocation.java b/services/src/main/java/dev/frontal/sdk/models/FunctionAsyncInvocation.java new file mode 100644 index 0000000..8856de8 --- /dev/null +++ b/services/src/main/java/dev/frontal/sdk/models/FunctionAsyncInvocation.java @@ -0,0 +1,9 @@ +package dev.frontal.sdk; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** Identifier returned when an asynchronous function invocation is started. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record FunctionAsyncInvocation( + @JsonProperty("executionId") String executionId) {} diff --git a/services/src/main/java/dev/frontal/sdk/models/FunctionDefinition.java b/services/src/main/java/dev/frontal/sdk/models/FunctionDefinition.java new file mode 100644 index 0000000..2ea4a10 --- /dev/null +++ b/services/src/main/java/dev/frontal/sdk/models/FunctionDefinition.java @@ -0,0 +1,154 @@ +package dev.frontal.sdk; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** Create or update payload for a function. JSON schemas remain caller-defined JSON values. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record FunctionDefinition( + @JsonProperty("name") String name, + @JsonProperty("description") @Nullable String description, + @JsonProperty("runtime") FunctionRuntime runtime, + @JsonProperty("entrypoint") String entrypoint, + @JsonProperty("source") @Nullable String source, + @JsonProperty("inputSchema") @Nullable JsonNode inputSchema, + @JsonProperty("outputSchema") @Nullable JsonNode outputSchema, + @JsonProperty("dependencies") @Nullable List dependencies, + @JsonProperty("envVars") @Nullable Map envVars, + @JsonProperty("secrets") @Nullable List secrets, + @JsonProperty("memory") @Nullable Integer memory, + @JsonProperty("timeout") @Nullable Integer timeout, + @JsonProperty("permissions") @Nullable FunctionPermission permissions) { + + public FunctionDefinition { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(runtime, "runtime"); + Objects.requireNonNull(entrypoint, "entrypoint"); + if (name.isBlank()) { + throw new IllegalArgumentException("name must not be blank"); + } + if (memory != null && memory <= 0) { + throw new IllegalArgumentException("memory must be positive"); + } + if (timeout != null && timeout <= 0) { + throw new IllegalArgumentException("timeout must be positive"); + } + if (dependencies != null) { + dependencies = List.copyOf(dependencies); + } + if (envVars != null) { + envVars = Map.copyOf(envVars); + } + if (secrets != null) { + secrets = List.copyOf(secrets); + } + } + + /** Starts a builder with the required function name. */ + public static Builder builder(String name) { + return new Builder(name); + } + + /** Builder for a function definition with the required fields called out explicitly. */ + public static final class Builder { + private final String name; + private @Nullable String description; + private @Nullable FunctionRuntime runtime; + private @Nullable String entrypoint; + private @Nullable String source; + private @Nullable JsonNode inputSchema; + private @Nullable JsonNode outputSchema; + private @Nullable List dependencies; + private @Nullable Map envVars; + private @Nullable List secrets; + private @Nullable Integer memory; + private @Nullable Integer timeout; + private @Nullable FunctionPermission permissions; + + private Builder(String name) { + this.name = Objects.requireNonNull(name, "name"); + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder runtime(FunctionRuntime runtime) { + this.runtime = runtime; + return this; + } + + public Builder entrypoint(String entrypoint) { + this.entrypoint = entrypoint; + return this; + } + + public Builder source(String source) { + this.source = source; + return this; + } + + public Builder inputSchema(JsonNode inputSchema) { + this.inputSchema = inputSchema; + return this; + } + + public Builder outputSchema(JsonNode outputSchema) { + this.outputSchema = outputSchema; + return this; + } + + public Builder dependencies(List dependencies) { + this.dependencies = dependencies; + return this; + } + + public Builder envVars(Map envVars) { + this.envVars = envVars; + return this; + } + + public Builder secrets(List secrets) { + this.secrets = secrets; + return this; + } + + public Builder memory(int memory) { + this.memory = memory; + return this; + } + + public Builder timeout(int timeout) { + this.timeout = timeout; + return this; + } + + public Builder permissions(FunctionPermission permissions) { + this.permissions = permissions; + return this; + } + + public FunctionDefinition build() { + return new FunctionDefinition( + name, + description, + Objects.requireNonNull(runtime, "runtime"), + Objects.requireNonNull(entrypoint, "entrypoint"), + source, + inputSchema, + outputSchema, + dependencies, + envVars, + secrets, + memory, + timeout, + permissions); + } + } +} diff --git a/services/src/main/java/dev/frontal/sdk/models/FunctionDeploymentStatus.java b/services/src/main/java/dev/frontal/sdk/models/FunctionDeploymentStatus.java new file mode 100644 index 0000000..763f5a2 --- /dev/null +++ b/services/src/main/java/dev/frontal/sdk/models/FunctionDeploymentStatus.java @@ -0,0 +1,14 @@ +package dev.frontal.sdk; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import org.jspecify.annotations.Nullable; + +/** Deployment state and optional JSON details for a function version. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record FunctionDeploymentStatus( + @JsonProperty("status") String status, + @JsonProperty("details") @Nullable JsonNode details) {} diff --git a/services/src/main/java/dev/frontal/sdk/models/FunctionExecution.java b/services/src/main/java/dev/frontal/sdk/models/FunctionExecution.java new file mode 100644 index 0000000..42a1885 --- /dev/null +++ b/services/src/main/java/dev/frontal/sdk/models/FunctionExecution.java @@ -0,0 +1,22 @@ +package dev.frontal.sdk; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import org.jspecify.annotations.Nullable; + +/** Current status and output of a function execution. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record FunctionExecution( + @JsonProperty("id") String id, + @JsonProperty("functionId") String functionId, + @JsonProperty("version") int version, + @JsonProperty("status") FunctionStatus status, + @JsonProperty("input") @Nullable JsonNode input, + @JsonProperty("output") @Nullable JsonNode output, + @JsonProperty("error") @Nullable String error, + @JsonProperty("startedAt") @Nullable String startedAt, + @JsonProperty("completedAt") @Nullable String completedAt, + @JsonProperty("durationMs") @Nullable Long durationMs) {} diff --git a/services/src/main/java/dev/frontal/sdk/models/FunctionExecutionListResponse.java b/services/src/main/java/dev/frontal/sdk/models/FunctionExecutionListResponse.java new file mode 100644 index 0000000..462587d --- /dev/null +++ b/services/src/main/java/dev/frontal/sdk/models/FunctionExecutionListResponse.java @@ -0,0 +1,17 @@ +package dev.frontal.sdk; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Objects; + +/** First page of executions returned by the Functions API. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record FunctionExecutionListResponse( + @JsonProperty("executions") List executions, + @JsonProperty("pagination") FunctionPagination pagination) { + public FunctionExecutionListResponse { + executions = List.copyOf(Objects.requireNonNull(executions, "executions")); + Objects.requireNonNull(pagination, "pagination"); + } +} diff --git a/services/src/main/java/dev/frontal/sdk/models/FunctionInvocationInput.java b/services/src/main/java/dev/frontal/sdk/models/FunctionInvocationInput.java new file mode 100644 index 0000000..91ba9be --- /dev/null +++ b/services/src/main/java/dev/frontal/sdk/models/FunctionInvocationInput.java @@ -0,0 +1,29 @@ +package dev.frontal.sdk; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** Input accepted by synchronous and asynchronous invocation operations. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record FunctionInvocationInput( + @JsonProperty("functionId") String functionId, + @JsonProperty("version") @Nullable Integer version, + @JsonProperty("input") @Nullable JsonNode input) { + public FunctionInvocationInput { + Objects.requireNonNull(functionId, "functionId"); + if (version != null && version <= 0) { + throw new IllegalArgumentException("version must be positive"); + } + } + + public FunctionInvocationInput(String functionId, JsonNode input) { + this(functionId, null, input); + } + + public FunctionInvocationInput(String functionId) { + this(functionId, null, null); + } +} diff --git a/services/src/main/java/dev/frontal/sdk/models/FunctionInvocationResult.java b/services/src/main/java/dev/frontal/sdk/models/FunctionInvocationResult.java new file mode 100644 index 0000000..57d641d --- /dev/null +++ b/services/src/main/java/dev/frontal/sdk/models/FunctionInvocationResult.java @@ -0,0 +1,16 @@ +package dev.frontal.sdk; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import org.jspecify.annotations.Nullable; + +/** Result of a function invocation. The result retains arbitrary caller-defined JSON. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record FunctionInvocationResult( + @JsonProperty("executionId") String executionId, + @JsonProperty("result") @Nullable JsonNode result, + @JsonProperty("error") @Nullable String error, + @JsonProperty("status") @Nullable FunctionStatus status) {} diff --git a/services/src/main/java/dev/frontal/sdk/models/FunctionListResponse.java b/services/src/main/java/dev/frontal/sdk/models/FunctionListResponse.java new file mode 100644 index 0000000..b218bec --- /dev/null +++ b/services/src/main/java/dev/frontal/sdk/models/FunctionListResponse.java @@ -0,0 +1,17 @@ +package dev.frontal.sdk; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Objects; + +/** First page of functions returned by the Functions API. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record FunctionListResponse( + @JsonProperty("functions") List functions, + @JsonProperty("pagination") FunctionPagination pagination) { + public FunctionListResponse { + functions = List.copyOf(Objects.requireNonNull(functions, "functions")); + Objects.requireNonNull(pagination, "pagination"); + } +} diff --git a/services/src/main/java/dev/frontal/sdk/models/FunctionPagination.java b/services/src/main/java/dev/frontal/sdk/models/FunctionPagination.java new file mode 100644 index 0000000..d793c5f --- /dev/null +++ b/services/src/main/java/dev/frontal/sdk/models/FunctionPagination.java @@ -0,0 +1,13 @@ +package dev.frontal.sdk; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.jspecify.annotations.Nullable; + +/** Cursor metadata returned with a Functions API list response. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record FunctionPagination( + @JsonProperty("cursor") @Nullable String cursor, + @JsonProperty("hasMore") boolean hasMore) {} diff --git a/services/src/main/java/dev/frontal/sdk/models/FunctionPermission.java b/services/src/main/java/dev/frontal/sdk/models/FunctionPermission.java new file mode 100644 index 0000000..eb588d1 --- /dev/null +++ b/services/src/main/java/dev/frontal/sdk/models/FunctionPermission.java @@ -0,0 +1,14 @@ +package dev.frontal.sdk; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** Permission declarations attached to a function definition. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record FunctionPermission( + @JsonProperty("ontology") @Nullable List ontology, + @JsonProperty("actions") @Nullable List actions) {} diff --git a/services/src/main/java/dev/frontal/sdk/models/FunctionResource.java b/services/src/main/java/dev/frontal/sdk/models/FunctionResource.java new file mode 100644 index 0000000..9ee4c75 --- /dev/null +++ b/services/src/main/java/dev/frontal/sdk/models/FunctionResource.java @@ -0,0 +1,24 @@ +package dev.frontal.sdk; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.jspecify.annotations.Nullable; + +/** A function returned by the Functions API. Timestamps are ISO-8601 strings. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record FunctionResource( + @JsonProperty("id") String id, + @JsonProperty("name") String name, + @JsonProperty("description") @Nullable String description, + @JsonProperty("runtime") FunctionRuntime runtime, + @JsonProperty("entrypoint") String entrypoint, + @JsonProperty("status") FunctionStatus status, + @JsonProperty("version") int version, + @JsonProperty("latestVersion") @Nullable Integer latestVersion, + @JsonProperty("memory") @Nullable Integer memory, + @JsonProperty("timeout") @Nullable Integer timeout, + @JsonProperty("permissions") @Nullable FunctionPermission permissions, + @JsonProperty("createdAt") String createdAt, + @JsonProperty("updatedAt") String updatedAt) {} diff --git a/services/src/main/java/dev/frontal/sdk/models/FunctionRuntime.java b/services/src/main/java/dev/frontal/sdk/models/FunctionRuntime.java new file mode 100644 index 0000000..6fb9738 --- /dev/null +++ b/services/src/main/java/dev/frontal/sdk/models/FunctionRuntime.java @@ -0,0 +1,32 @@ +package dev.frontal.sdk; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +/** Runtime supported by the Frontal Functions API. */ +public enum FunctionRuntime { + NODEJS20("nodejs20"), + NODEJS22("nodejs22"), + PYTHON311("python311"); + + private final String wireValue; + + FunctionRuntime(String wireValue) { + this.wireValue = wireValue; + } + + @JsonValue + public String wireValue() { + return wireValue; + } + + @JsonCreator + public static FunctionRuntime fromWireValue(String value) { + for (FunctionRuntime runtime : values()) { + if (runtime.wireValue.equals(value)) { + return runtime; + } + } + throw new IllegalArgumentException("Unsupported Functions runtime: " + value); + } +} diff --git a/services/src/main/java/dev/frontal/sdk/models/FunctionStatus.java b/services/src/main/java/dev/frontal/sdk/models/FunctionStatus.java new file mode 100644 index 0000000..b1dd635 --- /dev/null +++ b/services/src/main/java/dev/frontal/sdk/models/FunctionStatus.java @@ -0,0 +1,33 @@ +package dev.frontal.sdk; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +/** Status values used by function and execution resources. */ +public enum FunctionStatus { + DRAFT("draft"), + ACTIVE("active"), + DEPRECATED("deprecated"), + FAILED("failed"); + + private final String wireValue; + + FunctionStatus(String wireValue) { + this.wireValue = wireValue; + } + + @JsonValue + public String wireValue() { + return wireValue; + } + + @JsonCreator + public static FunctionStatus fromWireValue(String value) { + for (FunctionStatus status : values()) { + if (status.wireValue.equals(value)) { + return status; + } + } + throw new IllegalArgumentException("Unsupported Functions status: " + value); + } +} diff --git a/services/src/main/java/dev/frontal/sdk/models/FunctionVersion.java b/services/src/main/java/dev/frontal/sdk/models/FunctionVersion.java new file mode 100644 index 0000000..65b81d6 --- /dev/null +++ b/services/src/main/java/dev/frontal/sdk/models/FunctionVersion.java @@ -0,0 +1,15 @@ +package dev.frontal.sdk; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.jspecify.annotations.Nullable; + +/** Immutable source version of a function. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record FunctionVersion( + @JsonProperty("version") int version, + @JsonProperty("source") String source, + @JsonProperty("createdAt") String createdAt, + @JsonProperty("publishedBy") @Nullable String publishedBy) {} diff --git a/services/src/main/java/dev/frontal/sdk/models/FunctionVersionListResponse.java b/services/src/main/java/dev/frontal/sdk/models/FunctionVersionListResponse.java new file mode 100644 index 0000000..db48195 --- /dev/null +++ b/services/src/main/java/dev/frontal/sdk/models/FunctionVersionListResponse.java @@ -0,0 +1,17 @@ +package dev.frontal.sdk; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Objects; + +/** First page of versions returned for a function. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record FunctionVersionListResponse( + @JsonProperty("versions") List versions, + @JsonProperty("pagination") FunctionPagination pagination) { + public FunctionVersionListResponse { + versions = List.copyOf(Objects.requireNonNull(versions, "versions")); + Objects.requireNonNull(pagination, "pagination"); + } +} diff --git a/services/src/main/java/dev/frontal/sdk/resources/FunctionsClient.java b/services/src/main/java/dev/frontal/sdk/resources/FunctionsClient.java new file mode 100644 index 0000000..855dbd4 --- /dev/null +++ b/services/src/main/java/dev/frontal/sdk/resources/FunctionsClient.java @@ -0,0 +1,84 @@ +package dev.frontal.sdk; + +import com.fasterxml.jackson.databind.JsonNode; +import java.io.IOException; +import java.util.List; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** Client for creating and managing user-defined Functions. */ +public final class FunctionsClient extends ServiceClient { + FunctionsClient(ApiClient client) { + super(ApiService.FUNCTIONS, client); + } + + /** Creates a function and its initial version. */ + public FunctionResource create(FunctionDefinition definition) throws IOException, InterruptedException { + JsonNode body = client().objectMapper().valueToTree(Objects.requireNonNull(definition, "definition")); + return required( + request(FunctionsEndpoints.CREATE, List.of(), QueryParams.empty(), body, FunctionResource.class)); + } + + /** Lists functions, optionally using {@code cursor} and {@code limit}. */ + public FunctionListResponse list(QueryParams query) throws IOException, InterruptedException { + return required(request( + FunctionsEndpoints.LIST, + List.of(), + Objects.requireNonNull(query, "query"), + null, + FunctionListResponse.class)); + } + + /** Lists the first page of functions. */ + public FunctionListResponse list() throws IOException, InterruptedException { + return list(QueryParams.empty()); + } + + /** Retrieves a function by ID. */ + public FunctionResource get(String functionId) throws IOException, InterruptedException { + return required(request( + FunctionsEndpoints.GET, + List.of(Objects.requireNonNull(functionId, "functionId")), + FunctionResource.class)); + } + + /** Updates a function, creating a new version. */ + public FunctionResource update(String functionId, FunctionDefinition definition) + throws IOException, InterruptedException { + JsonNode body = client().objectMapper().valueToTree(Objects.requireNonNull(definition, "definition")); + return required(request( + FunctionsEndpoints.UPDATE, + List.of(Objects.requireNonNull(functionId, "functionId")), + QueryParams.empty(), + body, + FunctionResource.class)); + } + + /** Deletes a function. */ + public void delete(String functionId) throws IOException, InterruptedException { + execute( + FunctionsEndpoints.DELETE, + List.of(Objects.requireNonNull(functionId, "functionId")), + QueryParams.empty(), + null); + } + + /** Returns the function-version operations. */ + public FunctionsVersionsClient versions() { + return new FunctionsVersionsClient(client()); + } + + /** Returns the deployment operations. */ + public FunctionsDeploymentsClient deployments() { + return new FunctionsDeploymentsClient(client()); + } + + /** Returns function-execution operations. */ + public FunctionsExecutionsClient executions() { + return new FunctionsExecutionsClient(client()); + } + + private static T required(@Nullable T value) { + return Objects.requireNonNull(value, "Functions API returned an empty response body"); + } +} diff --git a/services/src/main/java/dev/frontal/sdk/resources/FunctionsDeploymentsClient.java b/services/src/main/java/dev/frontal/sdk/resources/FunctionsDeploymentsClient.java new file mode 100644 index 0000000..1abf8e3 --- /dev/null +++ b/services/src/main/java/dev/frontal/sdk/resources/FunctionsDeploymentsClient.java @@ -0,0 +1,31 @@ +package dev.frontal.sdk; + +import java.io.IOException; +import java.util.List; +import java.util.Objects; + +/** Operations for deploying function versions. */ +public final class FunctionsDeploymentsClient extends ServiceClient { + FunctionsDeploymentsClient(ApiClient client) { + super(ApiService.FUNCTIONS, client); + } + + /** Deploys a function version. */ + public void deploy(String functionId, int version) throws IOException, InterruptedException { + execute( + FunctionsEndpoints.DEPLOY_VERSION, + List.of(Objects.requireNonNull(functionId, "functionId"), Integer.toString(version)), + QueryParams.empty(), + client().objectMapper().createObjectNode()); + } + + /** Reads deployment status and any server-provided details. */ + public FunctionDeploymentStatus status(String functionId, int version) throws IOException, InterruptedException { + return Objects.requireNonNull( + request( + FunctionsEndpoints.DEPLOYMENT_STATUS, + List.of(Objects.requireNonNull(functionId, "functionId"), Integer.toString(version)), + FunctionDeploymentStatus.class), + "Functions API returned an empty response body"); + } +} diff --git a/services/src/main/java/dev/frontal/sdk/resources/FunctionsEndpoints.java b/services/src/main/java/dev/frontal/sdk/resources/FunctionsEndpoints.java new file mode 100644 index 0000000..f655b7b --- /dev/null +++ b/services/src/main/java/dev/frontal/sdk/resources/FunctionsEndpoints.java @@ -0,0 +1,28 @@ +package dev.frontal.sdk; + +/** Routes for the Functions API, which is not present in the checked-in OpenAPI snapshots. */ +final class FunctionsEndpoints { + static final Endpoint CREATE = endpoint(HttpMethod.POST, "/functions"); + static final Endpoint LIST = endpoint(HttpMethod.GET, "/functions"); + static final Endpoint GET = endpoint(HttpMethod.GET, "/functions/{param}"); + static final Endpoint UPDATE = endpoint(HttpMethod.PATCH, "/functions/{param}"); + static final Endpoint DELETE = endpoint(HttpMethod.DELETE, "/functions/{param}"); + static final Endpoint LIST_VERSIONS = endpoint(HttpMethod.GET, "/functions/{param}/versions"); + static final Endpoint GET_VERSION = endpoint(HttpMethod.GET, "/functions/{param}/versions/{param}"); + static final Endpoint PUBLISH_VERSION = endpoint(HttpMethod.POST, "/functions/{param}/versions/{param}/publish"); + static final Endpoint DEPLOY_VERSION = endpoint(HttpMethod.POST, "/functions/{param}/versions/{param}/deploy"); + static final Endpoint DEPLOYMENT_STATUS = + endpoint(HttpMethod.GET, "/functions/{param}/versions/{param}/deployment/status"); + static final Endpoint INVOKE = endpoint(HttpMethod.POST, "/functions/invoke"); + static final Endpoint INVOKE_ASYNC = endpoint(HttpMethod.POST, "/functions/invoke-async"); + static final Endpoint GET_EXECUTION = endpoint(HttpMethod.GET, "/functions/executions/{param}"); + static final Endpoint GET_RESULT = endpoint(HttpMethod.GET, "/functions/executions/{param}/result"); + static final Endpoint LIST_EXECUTIONS = endpoint(HttpMethod.GET, "/functions/executions"); + static final Endpoint CANCEL_EXECUTION = endpoint(HttpMethod.POST, "/functions/executions/{param}/cancel"); + + private FunctionsEndpoints() {} + + private static Endpoint endpoint(HttpMethod method, String path) { + return new Endpoint(ApiService.FUNCTIONS, method, path); + } +} diff --git a/services/src/main/java/dev/frontal/sdk/resources/FunctionsExecutionsClient.java b/services/src/main/java/dev/frontal/sdk/resources/FunctionsExecutionsClient.java new file mode 100644 index 0000000..591f465 --- /dev/null +++ b/services/src/main/java/dev/frontal/sdk/resources/FunctionsExecutionsClient.java @@ -0,0 +1,82 @@ +package dev.frontal.sdk; + +import com.fasterxml.jackson.databind.JsonNode; +import java.io.IOException; +import java.util.List; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** Operations for invoking and tracking function executions. */ +public final class FunctionsExecutionsClient extends ServiceClient { + FunctionsExecutionsClient(ApiClient client) { + super(ApiService.FUNCTIONS, client); + } + + /** Invokes a function and waits for its result. */ + public FunctionInvocationResult invoke(FunctionInvocationInput input) throws IOException, InterruptedException { + return required(request( + FunctionsEndpoints.INVOKE, + List.of(), + QueryParams.empty(), + body(input), + FunctionInvocationResult.class)); + } + + /** Starts an asynchronous invocation and returns its execution ID. */ + public FunctionAsyncInvocation invokeAsync(FunctionInvocationInput input) throws IOException, InterruptedException { + return required(request( + FunctionsEndpoints.INVOKE_ASYNC, + List.of(), + QueryParams.empty(), + body(input), + FunctionAsyncInvocation.class)); + } + + /** Retrieves an execution's current status. */ + public FunctionExecution getExecution(String executionId) throws IOException, InterruptedException { + return required(request( + FunctionsEndpoints.GET_EXECUTION, + List.of(Objects.requireNonNull(executionId, "executionId")), + FunctionExecution.class)); + } + + /** Retrieves an execution result. */ + public FunctionInvocationResult getResult(String executionId) throws IOException, InterruptedException { + return required(request( + FunctionsEndpoints.GET_RESULT, + List.of(Objects.requireNonNull(executionId, "executionId")), + FunctionInvocationResult.class)); + } + + /** Lists executions and supports {@code cursor}, {@code limit}, {@code functionId}, and {@code status}. */ + public FunctionExecutionListResponse listExecutions(QueryParams query) throws IOException, InterruptedException { + return required(request( + FunctionsEndpoints.LIST_EXECUTIONS, + List.of(), + Objects.requireNonNull(query, "query"), + null, + FunctionExecutionListResponse.class)); + } + + /** Lists the first page of executions. */ + public FunctionExecutionListResponse listExecutions() throws IOException, InterruptedException { + return listExecutions(QueryParams.empty()); + } + + /** Cancels an execution. */ + public void cancelExecution(String executionId) throws IOException, InterruptedException { + execute( + FunctionsEndpoints.CANCEL_EXECUTION, + List.of(Objects.requireNonNull(executionId, "executionId")), + QueryParams.empty(), + client().objectMapper().createObjectNode()); + } + + private JsonNode body(Object value) { + return client().objectMapper().valueToTree(Objects.requireNonNull(value, "input")); + } + + private static T required(@Nullable T value) { + return Objects.requireNonNull(value, "Functions API returned an empty response body"); + } +} diff --git a/services/src/main/java/dev/frontal/sdk/resources/FunctionsVersionsClient.java b/services/src/main/java/dev/frontal/sdk/resources/FunctionsVersionsClient.java new file mode 100644 index 0000000..55c5453 --- /dev/null +++ b/services/src/main/java/dev/frontal/sdk/resources/FunctionsVersionsClient.java @@ -0,0 +1,51 @@ +package dev.frontal.sdk; + +import java.io.IOException; +import java.util.List; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** Operations for function versions. */ +public final class FunctionsVersionsClient extends ServiceClient { + FunctionsVersionsClient(ApiClient client) { + super(ApiService.FUNCTIONS, client); + } + + /** Lists versions for a function, optionally using {@code cursor} and {@code limit}. */ + public FunctionVersionListResponse list(String functionId, QueryParams query) + throws IOException, InterruptedException { + return required(request( + FunctionsEndpoints.LIST_VERSIONS, + List.of(Objects.requireNonNull(functionId, "functionId")), + Objects.requireNonNull(query, "query"), + null, + FunctionVersionListResponse.class)); + } + + /** Lists the first page of versions for a function. */ + public FunctionVersionListResponse list(String functionId) throws IOException, InterruptedException { + return list(functionId, QueryParams.empty()); + } + + /** Retrieves a specific function version. */ + public FunctionVersion get(String functionId, int version) throws IOException, InterruptedException { + return required(request( + FunctionsEndpoints.GET_VERSION, + List.of(Objects.requireNonNull(functionId, "functionId"), Integer.toString(version)), + FunctionVersion.class)); + } + + /** Publishes a specific function version. */ + public FunctionVersion publish(String functionId, int version) throws IOException, InterruptedException { + return required(request( + FunctionsEndpoints.PUBLISH_VERSION, + List.of(Objects.requireNonNull(functionId, "functionId"), Integer.toString(version)), + QueryParams.empty(), + client().objectMapper().createObjectNode(), + FunctionVersion.class)); + } + + private static T required(@Nullable T value) { + return Objects.requireNonNull(value, "Functions API returned an empty response body"); + } +}