From 2fbe65fcc50795564ef3408594b5a0eb3c4fc1a6 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 23 Sep 2026 13:31:28 +0000 Subject: [PATCH] feat(ext-openapi): document what the code already says MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every class-based route is documented now, annotated or not: a route without @ApiOperation used to be dropped with a warning, which made the document lie by omission. Read off the handler: the request body from the type it declares (or from the new @RequestBody, for one that reads the body itself), the success schema from the most specific handle it implements, and the media type from @Consumes. Failures are described too. Any 4xx or 5xx without an explicit schema documents the error object Flash actually answers with, written once under components.schemas.Error — before this, a declared 4xx inherited the success schema, which was simply wrong. And an answer two or more operations give identically is hoisted into components.responses and referenced, so the 401 of every guarded route appears once rather than on every path. @Content gained an example, and the schema registry moved into its own class. Co-Authored-By: Claude Opus 5 --- flash-extensions/flash-ext-openapi/README.md | 184 +++-- .../dev/relism/flash/ext/openapi/Content.java | 3 + .../flash/ext/openapi/OpenApiBuilder.java | 727 ++++++++---------- .../flash/ext/openapi/OpenApiExtension.java | 19 +- .../relism/flash/ext/openapi/RequestBody.java | 31 + .../dev/relism/flash/ext/openapi/Schemas.java | 247 ++++++ .../flash/ext/openapi/OpenApiBuilderTest.java | 144 ++++ 7 files changed, 821 insertions(+), 534 deletions(-) create mode 100644 flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/RequestBody.java create mode 100644 flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Schemas.java diff --git a/flash-extensions/flash-ext-openapi/README.md b/flash-extensions/flash-ext-openapi/README.md index 4d0c868..1a1e797 100644 --- a/flash-extensions/flash-ext-openapi/README.md +++ b/flash-extensions/flash-ext-openapi/README.md @@ -1,6 +1,7 @@ # flash-ext-openapi -OpenAPI 3.0.3 generation + Swagger UI for Flash. +OpenAPI 3.0.3 generation and Swagger UI, built from what the handlers already say about +themselves. ## What it provides @@ -10,8 +11,6 @@ OpenAPI 3.0.3 generation + Swagger UI for Flash. | `GET /openapi.yaml` | OpenAPI spec YAML | | `GET /openapi/swagger` | Swagger UI | -## Install - ```java FlashApp.create(8080) .install(new JacksonExtension()) @@ -20,131 +19,122 @@ FlashApp.create(8080) .startAndBlock(); ``` -## Operation annotation +## What you get without writing anything + +Every class-based route is documented, annotated or not. Read off the code: + +- **path parameters**, from `/{id}` in the route +- **the request body**, from the handler's own body type (see below) +- **the response schema**, from what `handle` returns — an object, a `List`, a `Map` +- **error responses**, in the one shape Flash answers failures with: `{"error": "...", "status": 404}` +- **security and rate limiting**, from the extensions that enforce them + +Annotations add what the code cannot say: prose, extra statuses, examples. They never repeat it. + +## Request bodies + +A handler that extends `BodyHandler` — `JsonHandler` and `XmlHandler`, and anything else that +reads a format — declares its body type in its signature, and that is the whole documentation: + +```java +@POST("/users") +public final class CreateUser extends JsonHandler { + @Override protected Object handle(Request req, Response res, NewUser body) { + return users.create(body); + } +} +``` + +```yaml +requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/NewUser' } +``` + +The media type comes from `@Consumes` on the base class, so an XML handler documents itself as +XML without a word from the route. + +For a handler that reads the body by hand, or to describe it as something else, declare it: + +```java +@PUT("/users") +@RequestBody(value = User.class, array = true, description = "Users to store") +public final class ReplaceUsers extends RequestHandler { ... } +``` + +## Operations ```java @GET("/users/{id}") @ApiOperation(summary = "Get user", description = "Returns one user", tags = {"users"}) @Parameter(name = "expand", in = ParameterIn.QUERY, type = SchemaType.STRING, examples = {"roles", "permissions"}) -@APIResponse( - responseCode = "200", - description = "User found", - content = @Content(contentType = ContentType.JSON, schema = UserDto.class) -) public final class GetUser extends RequestHandler { ... } ``` -## Response patterns +`@ApiOperation` is optional: without it the route is still in the document, with no summary. -### Single object +## Responses + +The success response is inferred. Declare one only to say more: ```java -@APIResponse( - responseCode = "200", - description = "User found", - content = @Content(contentType = ContentType.JSON, schema = UserDto.class) -) +@APIResponse(responseCode = "200", description = "User found", + content = @Content(schema = UserDto.class, example = "{\"id\":\"usr-1\"}")) +@APIResponse(responseCode = "409", description = "That email is taken") +@APIResponse(responseCode = "204", content = @Content(contentType = ContentType.NONE)) ``` -### Array +- `content.schema` omitted on a 2xx: the handler's return type. +- Any 4xx or 5xx without an explicit schema: Flash's error object, referenced from `components`. +- `content.array = true` wraps whichever schema was chosen. +- `contentType = NONE` documents a response with no body. + +**A response several operations share is written once.** Identical answers — the 401 of every +guarded route, the 429 of every limited one — become `components.responses` entries referenced by +`$ref`, instead of being repeated on every path. + +## DTO schemas ```java -@APIResponse( - responseCode = "200", - description = "Users listed", - content = @Content(contentType = ContentType.JSON, schema = UserDto.class, array = true) -) +@Schema(name = "User", title = "User DTO", description = "Public user") +public record UserDto( + @SchemaProperty(title = "ID", example = "USR-100") String id, + @SchemaProperty(hidden = true) String internalDebug) {} ``` -### No content - -```java -@APIResponse( - responseCode = "204", - description = "Deleted", - content = @Content(contentType = ContentType.NONE) -) -``` - -### Inferred from handler return type - -```java -@APIResponse( - responseCode = "200", - content = @Content -) -``` - -If `content.schema` is omitted, schema is inferred from the handler `handle(...)` return type. -Explicit `content.schema` always wins over inference. - -Inference defaults: - -- `UserDto` -> object schema for `UserDto` -- `List` / `Set` / `UserDto[]` -> `array` with `items: UserDto` -- `Map` -> `object` with `additionalProperties: UserDto` - -## DTO schema metadata - -```java -@Schema(name = "User", title = "User DTO", description = "Public user", deprecated = false) -public class UserDto { - - @SchemaProperty(title = "ID", required = true, example = "USR-100", enumeration = {"USR-100", "USR-101"}) - public String id; - - @SchemaProperty(hidden = true) - public String internalDebug; -} -``` - -Supported field-level exclusion: - -- `@Schema(hidden = true)` / `@SchemaProperty(hidden = true)` -- `@JsonIgnore` -- `@JsonIgnoreProperties(...)` -- `transient` / `static` +Each type is described once under `components.schemas` and referenced everywhere it appears. +Field-level exclusion: `@Schema(hidden = true)`, `@SchemaProperty(hidden = true)`, `@JsonIgnore`, +`@JsonIgnoreProperties`, `transient`, `static`. `jakarta.validation` constraints (`@NotNull`, +`@NotBlank`, `@NotEmpty`, `@Size`, `@Min`, `@Max`, `@Email`, `@Pattern`) become the schema's own +bounds and required fields, so a rule is written once and documented for free. ## Contributor API -OpenAPI is extension-agnostic. Other extensions contribute with `OpenApiContributor` via -`OpenApiContributorRegistry`. +OpenAPI is extension-agnostic. Other extensions contribute through `OpenApiContributor`, held in +`OpenApiContributorRegistry`: -Supported contribution surfaces: - -- `components` fragments (merged with last-wins) +- `components` fragments (merged last-wins) - operation `security` requirements (additive) - operation `responses` and response `headers` (additive) -Merge policy: +Manual `@APIResponse` description always wins over a contributor's for the same status. -- contributor collisions use **last-wins** -- manual `@APIResponse` description always wins over contributors for the same status - -## Security interop +### Security interop With `flash-ext-security-core` installed, every registered mechanism's scheme lands under `components.securitySchemes`, and every operation carrying a security annotation lists them as `security` alternatives with automatic `401` and — for roles or scopes — `403` responses. -Manual `@APIResponse` for the same status code always wins. +### Limiter interop -## Limiter interop - -When `flash-ext-limiter` is installed, handlers with `@Limit` automatically get response -headers documented in OpenAPI: - -- `X-RateLimit-Limit` -- `X-RateLimit-Remaining` -- `X-RateLimit-Reset` -- `Retry-After` on `429` - -If `429` is missing, it is auto-added as `Too Many Requests`. +When `flash-ext-limiter` is installed, handlers with `@Limit` document `X-RateLimit-Limit`, +`X-RateLimit-Remaining`, `X-RateLimit-Reset`, and a `429` with `Retry-After`. ## Notes -- Operations are collected from final boot-time routes for class-based handlers with `@ApiOperation`. -- Documented paths always match runtime paths (including scope namespaces/prefixes/rewrites). -- Route path params are auto-discovered from `/{id}`. -- Parameter annotations are mainly for query/header/cookie enrichment. -- Output responses are sorted by numeric status code. +- Operations come from the final boot-time routes, so documented paths match runtime paths, + namespaces, prefixes and rewrites included. +- Lambda routes are not documented: there is no class to read. +- Responses are sorted by status code; the document is rebuilt only when a route is added. diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Content.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Content.java index b93bc9b..b86a38c 100644 --- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Content.java +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Content.java @@ -16,4 +16,7 @@ public @interface Content { ContentType contentType() default ContentType.JSON; Class schema() default Void.class; boolean array() default false; + + /** One example body, shown beside the schema. */ + String example() default ""; } diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java index 63c18c4..e305878 100644 --- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java @@ -1,47 +1,50 @@ package dev.relism.flash.ext.openapi; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.annotation.JsonProperty.Access; -import dev.relism.flash.http.HttpMethod; import dev.relism.flash.http.ContentType; +import dev.relism.flash.http.HttpMethod; import dev.relism.flash.http.HttpStatus; -import dev.relism.flash.models.Request; -import dev.relism.flash.models.Response; +import dev.relism.flash.models.BodyHandler; +import dev.relism.flash.routing.Consumes; import dev.relism.flash.routing.Route; import java.lang.annotation.Annotation; -import java.lang.reflect.Array; -import java.lang.reflect.Field; -import java.lang.reflect.GenericArrayType; import java.lang.reflect.Method; -import java.lang.reflect.Modifier; -import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; -import java.time.Instant; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.OffsetDateTime; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; import java.util.Comparator; +import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; -import java.util.UUID; -import java.nio.charset.StandardCharsets; /** - * OpenAPI document assembler. + * Assembles the OpenAPI document from what the handlers already say about themselves. + * + *

A route is documented whether or not it carries annotations: its path parameters come from + * the path, its request body from the handler's own body type, its response schema from what + * {@code handle} returns, and its error bodies from the one shape Flash answers failures with. + * Annotations add what code cannot say — a summary, an example, a second status — and never have + * to repeat what it can. + * + *

A response that several operations share is written once under {@code components} and + * referenced, so the security and rate-limiting answers appear once rather than on every path. */ public final class OpenApiBuilder { private static final String OPENAPI_VERSION = "3.0.3"; + private static final String ERROR_SCHEMA = "Error"; + private static final String ERROR_REF = "#/components/schemas/" + ERROR_SCHEMA; + private static final String RESPONSE_REF = "#/components/responses/"; + + /** What {@code AbstractRouter} answers every failure with: one object, everywhere. */ + private static final Map ERROR_SHAPE = Map.of( + "type", "object", + "properties", Map.of("error", Map.of("type", "string"), "status", Map.of("type", "integer")), + "required", List.of("error", "status")); private String title = "API"; private String version = "1.0.0"; @@ -49,8 +52,9 @@ public final class OpenApiBuilder { private final Map> paths = new LinkedHashMap<>(); private final Map>> operationHandlers = new LinkedHashMap<>(); - private final SchemaRegistry schemas = new SchemaRegistry(); + private final Schemas schemas = new Schemas(); private OpenApiContributorRegistry contributorRegistry; + private boolean errorsDocumented; private int revision; private int builtRevision = -1; private Map cachedSpec; @@ -60,274 +64,374 @@ public final class OpenApiBuilder { public OpenApiBuilder description(String description) { this.description = description; return this; } void setContributorRegistry(OpenApiContributorRegistry registry) { this.contributorRegistry = registry; } + /** Documents one route. {@code op} is optional: a route without it is still an operation. */ public void addOperation(Route route, ApiOperation op, Class handlerClass) { String path = normalizePath(route.path()); String method = route.method().name().toLowerCase(Locale.ROOT); Map operation = new LinkedHashMap<>(); - if (!op.operationId().isEmpty()) operation.put("operationId", op.operationId()); - if (!op.summary().isEmpty()) operation.put("summary", op.summary()); - if (!op.description().isEmpty()) operation.put("description", op.description()); - if (op.tags().length > 0) operation.put("tags", Arrays.asList(op.tags())); - if (op.deprecated()) operation.put("deprecated", true); + if (op != null) { + if (!op.operationId().isEmpty()) operation.put("operationId", op.operationId()); + if (!op.summary().isEmpty()) operation.put("summary", op.summary()); + if (!op.description().isEmpty()) operation.put("description", op.description()); + if (op.tags().length > 0) operation.put("tags", List.of(op.tags())); + if (op.deprecated()) operation.put("deprecated", true); + } - buildParameters(operation, handlerClass, route); - buildResponses(operation, handlerClass); + parameters(operation, handlerClass, route); + requestBody(operation, handlerClass); + responses(operation, handlerClass); - paths.computeIfAbsent(path, k -> new LinkedHashMap<>()).put(method, operation); - operationHandlers.computeIfAbsent(path, k -> new LinkedHashMap<>()).put(method, handlerClass); + paths.computeIfAbsent(path, p -> new LinkedHashMap<>()).put(method, operation); + operationHandlers.computeIfAbsent(path, p -> new LinkedHashMap<>()).put(method, handlerClass); revision++; } public Map build() { - int r = revision; + int current = revision; Map cached = cachedSpec; - if (cached != null && builtRevision == r) return cached; + if (cached != null && builtRevision == current) return cached; + + List contributors = contributors(); + Map renderedPaths = new LinkedHashMap<>(); + for (var path : paths.entrySet()) { + Map> handlers = operationHandlers.getOrDefault(path.getKey(), Map.of()); + Map pathItem = new LinkedHashMap<>(); + for (var method : path.getValue().entrySet()) { + @SuppressWarnings("unchecked") + Map declared = (Map) method.getValue(); + Map operation = new LinkedHashMap<>(declared); + Class handler = handlers.get(method.getKey()); + if (handler != null && !contributors.isEmpty()) applyContributorSecurity(operation, handler, contributors); + pathItem.put(method.getKey(), operation); + } + renderedPaths.put(path.getKey(), pathItem); + } + + Map sharedResponses = hoistSharedResponses(renderedPaths); Map info = new LinkedHashMap<>(); info.put("title", title); info.put("version", version); if (!description.isEmpty()) info.put("description", description); - List contributors = contributorRegistry != null - ? contributorRegistry.contributors() : List.of(); - - Map renderedPaths = new LinkedHashMap<>(); - for (var pathEntry : paths.entrySet()) { - Map renderedPathItem = new LinkedHashMap<>(); - Map> handlers = operationHandlers.getOrDefault(pathEntry.getKey(), Map.of()); - for (var methodEntry : pathEntry.getValue().entrySet()) { - @SuppressWarnings("unchecked") - Map original = (Map) methodEntry.getValue(); - Map op = new LinkedHashMap<>(original); - Class handler = handlers.get(methodEntry.getKey()); - if (handler != null && !contributors.isEmpty()) { - applyContributorOperation(op, handler, contributors); - } - renderedPathItem.put(methodEntry.getKey(), op); - } - renderedPaths.put(pathEntry.getKey(), renderedPathItem); - } - Map spec = new LinkedHashMap<>(); spec.put("openapi", OPENAPI_VERSION); spec.put("info", info); spec.put("paths", renderedPaths); Map components = new LinkedHashMap<>(); - Map renderedSchemas = schemas.render(); + Map renderedSchemas = new LinkedHashMap<>(schemas.render()); + if (errorsDocumented) renderedSchemas.put(ERROR_SCHEMA, ERROR_SHAPE); if (!renderedSchemas.isEmpty()) components.put("schemas", renderedSchemas); - if (!contributors.isEmpty()) applyContributorComponents(components, contributors); + if (!sharedResponses.isEmpty()) components.put("responses", sharedResponses); + for (OpenApiContributor contributor : contributors) { + Map contributed = contributor.componentContributions(); + if (contributed != null && !contributed.isEmpty()) deepMergeLastWins(components, contributed); + } if (!components.isEmpty()) spec.put("components", components); cachedSpec = spec; - builtRevision = r; + builtRevision = current; return spec; } - private void buildParameters(Map op, Class cls, Route route) { - List> params = new ArrayList<>(); + // ── Parameters ──────────────────────────────────────────────────────────── + + private void parameters(Map operation, Class handlerClass, Route route) { + List> parameters = new ArrayList<>(); String path = route.path(); - int i = 0; - while (i < path.length()) { - int open = path.indexOf('{', i); - if (open < 0) break; + for (int open = path.indexOf('{'); open >= 0; open = path.indexOf('{', open + 1)) { int close = path.indexOf('}', open); if (close < 0) break; - String name = path.substring(open + 1, close); - params.add(new LinkedHashMap<>(Map.of( - "name", name, + parameters.add(new LinkedHashMap<>(Map.of( + "name", path.substring(open + 1, close), "in", "path", "required", true, - "schema", Map.of("type", "string") - ))); - i = close + 1; + "schema", Map.of("type", "string")))); + open = close; } - for (Parameter ann : cls.getAnnotationsByType(Parameter.class)) { - Map p = new LinkedHashMap<>(); - p.put("name", ann.name()); - p.put("in", ann.in().wireValue()); - p.put("required", ann.required()); - if (!ann.description().isEmpty()) p.put("description", ann.description()); - if (!ann.style().isEmpty()) p.put("style", ann.style()); - if (ann.explode()) p.put("explode", true); - if (ann.allowEmptyValue()) p.put("allowEmptyValue", true); + for (Parameter declared : handlerClass.getAnnotationsByType(Parameter.class)) { + Map parameter = new LinkedHashMap<>(); + parameter.put("name", declared.name()); + parameter.put("in", declared.in().wireValue()); + parameter.put("required", declared.required()); + if (!declared.description().isEmpty()) parameter.put("description", declared.description()); + if (!declared.style().isEmpty()) parameter.put("style", declared.style()); + if (declared.explode()) parameter.put("explode", true); + if (declared.allowEmptyValue()) parameter.put("allowEmptyValue", true); Map schema = new LinkedHashMap<>(); - schema.put("type", ann.type().wireValue()); - if (!ann.example().isEmpty()) schema.put("example", ann.example()); - p.put("schema", schema); - if (ann.examples().length > 0) p.put("examples", toExamples(ann.examples())); - params.add(p); + schema.put("type", declared.type().wireValue()); + if (!declared.example().isEmpty()) schema.put("example", declared.example()); + parameter.put("schema", schema); + if (declared.examples().length > 0) parameter.put("examples", examples(declared.examples())); + parameters.add(parameter); } - if (!params.isEmpty()) op.put("parameters", params); + if (!parameters.isEmpty()) operation.put("parameters", parameters); } - private void buildResponses(Map op, Class cls) { - APIResponse[] anns = cls.getAnnotationsByType(APIResponse.class); - Map> responseByCode = new LinkedHashMap<>(); + private static Map examples(String[] values) { + Map examples = new LinkedHashMap<>(); + for (int i = 0; i < values.length; i++) examples.put("example" + (i + 1), Map.of("value", values[i])); + return examples; + } - for (APIResponse ann : anns) { - int code = parseStatus(ann.responseCode()); - responseByCode.put(code, buildAnnotatedResponse(code, ann, cls)); + // ── Request body ────────────────────────────────────────────────────────── + + /** From {@link RequestBody}, or from the body type the handler declares in its own signature. */ + private void requestBody(Map operation, Class handlerClass) { + RequestBody declared = handlerClass.getAnnotation(RequestBody.class); + Class type = declared != null ? declared.value() : BodyHandler.bodyTypeOf(handlerClass); + if (type == null || type == Void.class || type == Object.class) return; + + Consumes consumes = handlerClass.getAnnotation(Consumes.class); + ContentType contentType = declared != null ? declared.contentType() + : consumes != null ? consumes.value() : ContentType.JSON; + + Map schema = schemas.referenceFor(type); + if (declared != null && declared.array()) schema = arrayOf(schema); + + Map body = new LinkedHashMap<>(); + if (declared != null && !declared.description().isEmpty()) body.put("description", declared.description()); + body.put("required", declared == null || declared.required()); + body.put("content", Map.of(mediaTypeOf(contentType), Map.of("schema", schema))); + operation.put("requestBody", body); + } + + // ── Responses ───────────────────────────────────────────────────────────── + + private void responses(Map operation, Class handlerClass) { + APIResponse[] declared = handlerClass.getAnnotationsByType(APIResponse.class); + Map> byStatus = new LinkedHashMap<>(); + Set declaredStatuses = new HashSet<>(); + + for (APIResponse response : declared) { + int status = parseStatus(response.responseCode()); + declaredStatuses.add(status); + byStatus.put(status, response(status, response, handlerClass)); } + if (byStatus.isEmpty()) byStatus.put(200, inferredResponse(handlerClass)); - if (responseByCode.isEmpty()) { - // Mutable: contributors merge descriptions and headers into it. - responseByCode.put(200, new LinkedHashMap<>(Map.of("description", "OK"))); - } - - applyContributorResponses(responseByCode, cls); + applyContributorResponses(byStatus, handlerClass, declaredStatuses); Map responses = new LinkedHashMap<>(); - responseByCode.entrySet().stream() + byStatus.entrySet().stream() .sorted(Map.Entry.comparingByKey(Comparator.naturalOrder())) - .forEach(e -> responses.put(String.valueOf(e.getKey()), e.getValue())); - op.put("responses", responses); + .forEach(entry -> responses.put(String.valueOf(entry.getKey()), entry.getValue())); + operation.put("responses", responses); } + private Map response(int status, APIResponse declared, Class handlerClass) { + Map response = new LinkedHashMap<>(); + response.put("description", declared.description().isEmpty() ? reasonFor(status) : declared.description()); + + Content content = declared.content(); + if (content.contentType() == ContentType.NONE) return response; + + Map schema = schemaFor(content, status, handlerClass); + if (schema == null) return response; + + Map media = new LinkedHashMap<>(); + media.put("schema", schema); + if (!content.example().isEmpty()) media.put("example", content.example()); + response.put("content", Map.of(mediaTypeOf(content.contentType()), media)); + return response; + } + + /** Explicit first, then what the handler returns for a success, and the error shape for a failure. */ + private Map schemaFor(Content content, int status, Class handlerClass) { + if (content.schema() != Void.class) { + Map schema = schemas.referenceFor(content.schema()); + return content.array() ? arrayOf(schema) : schema; + } + if (status >= 400) return errorSchema(); + + Map inferred = returnSchema(handlerClass); + if (inferred == null) return null; + return content.array() && !"array".equals(inferred.get("type")) ? arrayOf(inferred) : inferred; + } + + /** A route that documents nothing still answers something: describe what it returns. */ + private Map inferredResponse(Class handlerClass) { + Map response = new LinkedHashMap<>(Map.of("description", reasonFor(200))); + Map schema = returnSchema(handlerClass); + if (schema != null) response.put("content", Map.of(mediaTypeOf(ContentType.JSON), Map.of("schema", schema))); + return response; + } + + /** The schema of whatever {@code handle} gives back, or null when it says nothing useful. */ + private Map returnSchema(Class handlerClass) { + Type returned = returnTypeOf(handlerClass); + Class raw = Schemas.rawType(returned); + if (raw == null || raw == Object.class || raw == Void.class || raw == void.class) return null; + if (raw.getName().equals("dev.relism.flash.models.Response")) return null; + + Map schema = schemas.schemaForType(returned); + return schema == null || schema.isEmpty() ? null : schema; + } + + /** + * The {@code handle} a handler writes itself, not the one its base class fixes: a handler that + * takes a body implements the three-argument one, and that is where its return type is. + */ + private static Type returnTypeOf(Class handlerClass) { + for (Class current = handlerClass; current != null && current != Object.class; current = current.getSuperclass()) { + Method found = null; + for (Method method : current.getDeclaredMethods()) { + if (!method.getName().equals("handle") || method.isBridge() || method.isSynthetic()) continue; + if (found == null || method.getParameterCount() > found.getParameterCount()) found = method; + } + if (found != null) return found.getGenericReturnType(); + } + return null; + } + + private Map errorSchema() { + errorsDocumented = true; + return Map.of("$ref", ERROR_REF); + } + + // ── Contributors ────────────────────────────────────────────────────────── + private List contributors() { return contributorRegistry != null ? contributorRegistry.contributors() : List.of(); } - private void applyContributorResponses(Map> responseByCode, Class handlerClass) { - APIResponse[] manual = handlerClass.getAnnotationsByType(APIResponse.class); - Set manualStatusCodes = new HashSet<>(); - for (APIResponse ann : manual) { - manualStatusCodes.add(parseStatus(ann.responseCode())); - } - + private void applyContributorResponses(Map> byStatus, Class handlerClass, + Set declaredStatuses) { for (OpenApiContributor contributor : contributors()) { OpenApiOperationContribution contribution = contributor.operationFor(handlerClass); if (contribution == null) continue; - for (Map.Entry entry : contribution.responses().entrySet()) { - int status = entry.getKey(); - OpenApiResponseContribution responseContribution = entry.getValue(); - if (responseContribution == null) continue; - - Map response = responseByCode.computeIfAbsent(status, __ -> new LinkedHashMap<>()); - mergeContributorResponse(response, responseContribution, status, manualStatusCodes); + for (var contributed : contribution.responses().entrySet()) { + if (contributed.getValue() == null) continue; + int status = contributed.getKey(); + Map response = byStatus.computeIfAbsent(status, s -> new LinkedHashMap<>()); + merge(response, contributed.getValue(), status, declaredStatuses.contains(status)); } - OpenApiResponseContribution allResponses = contribution.allResponses(); - if (allResponses != null) { - for (Map.Entry> entry : responseByCode.entrySet()) { - mergeContributorResponse(entry.getValue(), allResponses, entry.getKey(), manualStatusCodes); - } + OpenApiResponseContribution everywhere = contribution.allResponses(); + if (everywhere == null) continue; + for (var response : byStatus.entrySet()) { + merge(response.getValue(), everywhere, response.getKey(), declaredStatuses.contains(response.getKey())); } } } - private static void mergeContributorResponse(Map response, - OpenApiResponseContribution contribution, - int status, - Set manualStatusCodes) { - String desc = contribution.description(); - if (!manualStatusCodes.contains(status) && desc != null && !desc.isBlank()) { - response.put("description", desc); - } + private void merge(Map response, OpenApiResponseContribution contributed, int status, boolean declared) { + String description = contributed.description(); + if (!declared && description != null && !description.isBlank()) response.put("description", description); - Map> headerContributions = contribution.headers(); - if (!headerContributions.isEmpty()) { + Map> headers = contributed.headers(); + if (!headers.isEmpty()) { @SuppressWarnings("unchecked") - Map headers = (Map) response.computeIfAbsent("headers", __ -> new LinkedHashMap<>()); - for (Map.Entry> h : headerContributions.entrySet()) { - headers.put(h.getKey(), new LinkedHashMap<>(h.getValue())); - } + Map target = (Map) response.computeIfAbsent("headers", h -> new LinkedHashMap<>()); + headers.forEach((name, header) -> target.put(name, new LinkedHashMap<>(header))); } - - if (!response.containsKey("description")) { - response.put("description", defaultDescription(status)); + if (!response.containsKey("description")) response.put("description", reasonFor(status)); + // A status a contributor added is a failure Flash answers in its own shape. + if (status >= 400 && !response.containsKey("content")) { + response.put("content", Map.of(mediaTypeOf(ContentType.JSON), Map.of("schema", errorSchema()))); } } - private static void applyContributorComponents(Map components, List contributors) { + private void applyContributorSecurity(Map operation, Class handlerClass, + List contributors) { for (OpenApiContributor contributor : contributors) { - Map c = contributor.componentContributions(); - if (c == null || c.isEmpty()) continue; - deepMergeLastWins(components, c); + OpenApiOperationContribution contribution = contributor.operationFor(handlerClass); + if (contribution == null || contribution.security().isEmpty()) continue; + @SuppressWarnings("unchecked") + List>> security = + (List>>) operation.computeIfAbsent("security", s -> new ArrayList<>()); + security.addAll(contribution.security()); } } @SuppressWarnings("unchecked") private static void deepMergeLastWins(Map target, Map incoming) { - for (Map.Entry e : incoming.entrySet()) { - Object existing = target.get(e.getKey()); - Object value = e.getValue(); - if (existing instanceof Map em && value instanceof Map vm) { - Map merged = new LinkedHashMap<>((Map) em); - deepMergeLastWins(merged, (Map) vm); - target.put(e.getKey(), merged); + incoming.forEach((key, value) -> { + Object existing = target.get(key); + if (existing instanceof Map from && value instanceof Map to) { + Map merged = new LinkedHashMap<>((Map) from); + deepMergeLastWins(merged, (Map) to); + target.put(key, merged); } else { - target.put(e.getKey(), value); + target.put(key, value); } - } + }); } - private void applyContributorOperation(Map op, Class handlerClass, List contributors) { - for (OpenApiContributor contributor : contributors) { - OpenApiOperationContribution contribution = contributor.operationFor(handlerClass); - if (contribution == null || contribution.isEmpty()) continue; + // ── Shared responses ────────────────────────────────────────────────────── - if (!contribution.security().isEmpty()) { - @SuppressWarnings("unchecked") - List>> security = (List>>) op - .computeIfAbsent("security", __ -> new ArrayList<>()); - security.addAll(contribution.security()); + /** + * Whatever answer more than one operation gives identically is written once under + * {@code components.responses} and referenced. Authentication and rate limiting say the same + * thing on every route they guard; the document should say it once. + */ + @SuppressWarnings("unchecked") + private Map hoistSharedResponses(Map renderedPaths) { + Map seen = new HashMap<>(); + for (Object pathItem : renderedPaths.values()) { + for (Object operation : ((Map) pathItem).values()) { + Map responses = (Map) ((Map) operation).get("responses"); + responses.forEach((status, response) -> seen.merge(key(status, response), 1, Integer::sum)); } } + + Map names = new LinkedHashMap<>(); + Map shared = new LinkedHashMap<>(); + for (Object pathItem : renderedPaths.values()) { + for (Object operation : ((Map) pathItem).values()) { + Map responses = (Map) ((Map) operation).get("responses"); + for (var response : responses.entrySet()) { + Object key = key(response.getKey(), response.getValue()); + if (seen.getOrDefault(key, 0) < 2) continue; + + String name = names.get(key); + if (name == null) { + name = uniqueName(reasonFor(Integer.parseInt(response.getKey())), shared); + names.put(key, name); + shared.put(name, response.getValue()); + } + response.setValue(Map.of("$ref", RESPONSE_REF + name)); + } + } + } + return shared; } - private Map buildAnnotatedResponse(int code, APIResponse ann, Class handlerClass) { - Map out = new LinkedHashMap<>(); - out.put("description", ann.description().isEmpty() ? defaultDescription(code) : ann.description()); + private static Object key(String status, Object response) { + return status + response; + } - Content content = ann.content(); - if (content.contentType() == ContentType.NONE) return out; + private static String uniqueName(String reason, Map taken) { + String base = reason.isEmpty() ? "Response" : reason.replace(" ", ""); + String name = base; + for (int i = 2; taken.containsKey(name); i++) name = base + i; + return name; + } - Map schema = resolveResponseSchema(content, handlerClass); - if (schema == null || schema.isEmpty()) return out; + // ── Odds and ends ───────────────────────────────────────────────────────── - out.put("content", Map.of(mediaTypeOf(content.contentType()), Map.of("schema", schema))); - return out; + private static Map arrayOf(Map items) { + return Map.of("type", "array", "items", items); } private static int parseStatus(String code) { try { return Integer.parseInt(code.trim()); - } catch (Exception e) { + } catch (NumberFormatException e) { throw new IllegalStateException("Invalid APIResponse.responseCode: " + code); } } - private Map resolveResponseSchema(Content content, Class handlerClass) { - if (content.schema() != Void.class) { - Map base = schemas.referenceFor(content.schema()); - return content.array() ? asArraySchema(base) : base; - } - - try { - Method handle = handlerClass.getMethod("handle", Request.class, Response.class); - Type ret = handle.getGenericReturnType(); - Class raw = rawType(ret); - if (raw == null || raw == Object.class || raw == Response.class || raw == Void.class || raw == void.class) - return null; - - Map inferred = schemas.schemaForType(ret); - if (inferred == null || inferred.isEmpty()) return null; - if (content.array() && !"array".equals(inferred.get("type"))) return asArraySchema(inferred); - return inferred; - } catch (NoSuchMethodException e) { - return null; - } - } - - private static Map asArraySchema(Map itemSchema) { - return Map.of("type", "array", "items", itemSchema); + private static String reasonFor(int status) { + String reason = HttpStatus.reasonForCode(status); + return reason == null ? "" : reason; } private static String mediaTypeOf(ContentType type) { @@ -335,245 +439,21 @@ public final class OpenApiBuilder { return bytes.length == 0 ? "application/octet-stream" : new String(bytes, StandardCharsets.UTF_8); } - private static Map toExamples(String[] examples) { - Map out = new LinkedHashMap<>(); - for (int i = 0; i < examples.length; i++) { - out.put("ex" + (i + 1), Map.of("value", examples[i])); - } - return out; - } - private static String normalizePath(String path) { String normalized = path.startsWith("/") ? path : "/" + path; - while (normalized.startsWith("//")) { - normalized = normalized.substring(1); - } + while (normalized.startsWith("//")) normalized = normalized.substring(1); return normalized; } - private static String defaultDescription(int status) { - String reason = HttpStatus.reasonForCode(status); - return reason == null ? "" : reason; - } - - private static Class rawType(Type type) { - if (type instanceof Class c) return c; - if (type instanceof ParameterizedType p && p.getRawType() instanceof Class c) return c; - if (type instanceof GenericArrayType a) { - Class component = rawType(a.getGenericComponentType()); - return component == null ? null : Array.newInstance(component, 0).getClass(); - } - return null; - } - - /** Resolved once: jakarta.validation is an optional dependency of this module. */ - private static final boolean CONSTRAINTS_PRESENT = ConstraintHints.available(); - - private static final class SchemaRegistry { - private static final Set> SIMPLE = Set.of( - String.class, CharSequence.class, - Boolean.class, Byte.class, Short.class, Integer.class, Long.class, Float.class, Double.class, - boolean.class, byte.class, short.class, int.class, long.class, float.class, double.class, - UUID.class, LocalDate.class, LocalDateTime.class, OffsetDateTime.class, Instant.class - ); - - private final Map, String> names = new LinkedHashMap<>(); - private final Map> docs = new LinkedHashMap<>(); - private final Set> resolving = new HashSet<>(); - - Map referenceFor(Class type) { - return schemaFor(type); - } - - Map schemaForType(Type type) { - return schemaFor(type); - } - - Map render() { - Map out = new LinkedHashMap<>(); - for (var e : docs.entrySet()) out.put(e.getKey(), e.getValue()); - return out; - } - - private Map schemaFor(Type type) { - if (type instanceof ParameterizedType p) { - Class raw = rawType(p); - if (raw != null && Collection.class.isAssignableFrom(raw)) { - Type item = p.getActualTypeArguments()[0]; - return Map.of("type", "array", "items", schemaFor(item)); - } - if (raw != null && Map.class.isAssignableFrom(raw)) { - Type value = p.getActualTypeArguments().length > 1 ? p.getActualTypeArguments()[1] : Object.class; - return Map.of("type", "object", "additionalProperties", schemaFor(value)); - } - if (raw != null) return schemaFor(raw); - } - - Class cls = rawType(type); - if (cls == null || cls == Object.class) return Map.of("type", "object"); - - if (cls.isArray()) return Map.of("type", "array", "items", schemaFor(cls.getComponentType())); - if (Collection.class.isAssignableFrom(cls)) return Map.of("type", "array", "items", Map.of("type", "object")); - if (Map.class.isAssignableFrom(cls)) return Map.of("type", "object", "additionalProperties", Map.of("type", "object")); - - Map simple = simpleSchema(cls); - if (simple != null) return simple; - - return Map.of("$ref", "#/components/schemas/" + registerPojo(cls)); - } - - private String registerPojo(Class cls) { - String existing = names.get(cls); - if (existing != null) return existing; - - String base = schemaName(cls); - String name = base; - int i = 2; - while (docs.containsKey(name)) name = base + i++; - names.put(cls, name); - - if (resolving.contains(cls)) return name; - - resolving.add(cls); - docs.put(name, buildPojoSchema(cls)); - resolving.remove(cls); - return name; - } - - private Map buildPojoSchema(Class cls) { - Schema typeSchema = cls.getAnnotation(Schema.class); - JsonIgnoreProperties ignoredType = cls.getAnnotation(JsonIgnoreProperties.class); - Set ignored = ignoredType == null - ? Set.of() - : new HashSet<>(Arrays.asList(ignoredType.value())); - - Map out = new LinkedHashMap<>(); - out.put("type", "object"); - if (typeSchema != null) applySchemaHints(out, typeSchema); - - Map properties = new LinkedHashMap<>(); - List required = new ArrayList<>(); - - for (Field f : cls.getDeclaredFields()) { - int mod = f.getModifiers(); - if (Modifier.isStatic(mod) || Modifier.isTransient(mod)) continue; - if (f.isAnnotationPresent(JsonIgnore.class)) continue; - if (ignored.contains(f.getName())) continue; - - String name = f.getName(); - JsonProperty jp = f.getAnnotation(JsonProperty.class); - if (jp != null && !jp.value().isEmpty()) name = jp.value(); - - Schema ps = f.getAnnotation(Schema.class); - SchemaProperty sp = f.getAnnotation(SchemaProperty.class); - ArraySchema array = f.getAnnotation(ArraySchema.class); - if ((ps != null && ps.hidden()) || (sp != null && sp.hidden())) continue; - - if (sp != null && !sp.name().isEmpty()) name = sp.name(); - - Map property = new LinkedHashMap<>(schemaFor(f.getGenericType())); - if (ps != null) applySchemaHints(property, ps); - if (sp != null) applySchemaHints(property, sp); - if (array != null) applyArrayHints(property, array); - if (jp != null) { - if (jp.access() == Access.READ_ONLY) property.put("readOnly", true); - if (jp.access() == Access.WRITE_ONLY) property.put("writeOnly", true); - } - - // Constraints declared for flash-ext-validation also describe the contract, so - // mirror them here rather than making callers restate every rule as @Schema. - boolean constrainedRequired = CONSTRAINTS_PRESENT && ConstraintHints.apply(f, property); - - properties.put(name, property); - if (constrainedRequired - || (ps != null && ps.required()) || (sp != null && sp.required()) || (jp != null && jp.required())) - required.add(name); - } - - if (!properties.isEmpty()) out.put("properties", properties); - if (!required.isEmpty()) out.put("required", required); - return out; - } - - private static void applySchemaHints(Map target, Schema schema) { - if (!schema.title().isEmpty()) target.put("title", schema.title()); - if (!schema.description().isEmpty()) target.put("description", schema.description()); - if (!schema.format().isEmpty()) target.put("format", schema.format()); - if (!schema.example().isEmpty()) target.put("example", schema.example()); - if (schema.enumeration().length > 0) target.put("enum", Arrays.asList(schema.enumeration())); - if (schema.nullable()) target.put("nullable", true); - if (schema.deprecated()) target.put("deprecated", true); - } - - private static void applySchemaHints(Map target, SchemaProperty schema) { - if (!schema.title().isEmpty()) target.put("title", schema.title()); - if (!schema.description().isEmpty()) target.put("description", schema.description()); - if (!schema.format().isEmpty()) target.put("format", schema.format()); - if (!schema.example().isEmpty()) target.put("example", schema.example()); - if (schema.enumeration().length > 0) target.put("enum", Arrays.asList(schema.enumeration())); - if (schema.nullable()) target.put("nullable", true); - if (schema.deprecated()) target.put("deprecated", true); - } - - private Map withArrayType(Map property, ArraySchema array) { - if ("array".equals(property.get("type"))) return property; - Type itemType = array.itemClass() != Void.class ? array.itemClass() : Object.class; - Map wrapped = new LinkedHashMap<>(); - wrapped.put("type", "array"); - wrapped.put("items", schemaFor(itemType)); - return wrapped; - } - - private void applyArrayHints(Map property, ArraySchema array) { - Map target = withArrayType(property, array); - if (target != property) { - property.clear(); - property.putAll(target); - } - if (array.uniqueItems()) property.put("uniqueItems", true); - if (array.minItems() >= 0) property.put("minItems", array.minItems()); - if (array.maxItems() >= 0) property.put("maxItems", array.maxItems()); - } - - private static String schemaName(Class cls) { - Schema schema = cls.getAnnotation(Schema.class); - if (schema != null && !schema.name().isEmpty()) return schema.name(); - return cls.getSimpleName(); - } - - private static Map simpleSchema(Class cls) { - if (!SIMPLE.contains(cls) && !cls.isEnum()) return null; - - if (cls == String.class || CharSequence.class.isAssignableFrom(cls)) return Map.of("type", "string"); - if (cls == Boolean.class || cls == boolean.class) return Map.of("type", "boolean"); - if (cls == Integer.class || cls == int.class || cls == Long.class || cls == long.class || - cls == Short.class || cls == short.class || cls == Byte.class || cls == byte.class) { - return Map.of("type", "integer"); - } - if (cls == Float.class || cls == float.class || cls == Double.class || cls == double.class) { - return Map.of("type", "number"); - } - if (cls == UUID.class) return Map.of("type", "string", "format", "uuid"); - if (cls == LocalDate.class) return Map.of("type", "string", "format", "date"); - if (cls == LocalDateTime.class || cls == OffsetDateTime.class || cls == Instant.class) - return Map.of("type", "string", "format", "date-time"); - if (cls.isEnum()) { - Object[] constants = cls.getEnumConstants(); - List values = new ArrayList<>(constants.length); - for (Object c : constants) values.add(String.valueOf(c)); - return Map.of("type", "string", "enum", values); - } - return null; - } - } - + /** The route a handler class declares, through {@code @Route} or any shorthand carrying it. */ static Route routeOf(Class cls) { Route direct = cls.getAnnotation(Route.class); if (direct != null) return direct; - for (Annotation ann : cls.getAnnotations()) { - Route meta = ann.annotationType().getAnnotation(Route.class); + + for (Annotation annotation : cls.getAnnotations()) { + Route meta = annotation.annotationType().getAnnotation(Route.class); if (meta == null) continue; - String path = readPathValue(ann); + String path = pathOf(annotation); if (path == null) continue; HttpMethod method = meta.method(); return new Route() { @@ -585,11 +465,10 @@ public final class OpenApiBuilder { return null; } - private static String readPathValue(Annotation ann) { + private static String pathOf(Annotation annotation) { try { - Object v = ann.annotationType().getMethod("value").invoke(ann); - return v instanceof String s ? s : null; - } catch (ReflectiveOperationException ignored) { + return annotation.annotationType().getMethod("value").invoke(annotation) instanceof String path ? path : null; + } catch (ReflectiveOperationException absent) { return null; } } diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiExtension.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiExtension.java index e914e1d..49cd16c 100644 --- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiExtension.java +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiExtension.java @@ -10,7 +10,6 @@ import dev.relism.flash.extension.RouteEvent; import dev.relism.flash.http.ContentType; import dev.relism.flash.http.HttpMethod; import dev.relism.flash.routing.Route; -import lombok.extern.slf4j.Slf4j; /** * Generates and serves an OpenAPI 3.0 spec and Swagger UI under a configurable base path. @@ -25,8 +24,9 @@ import lombok.extern.slf4j.Slf4j; *

If {@code flash-ext-jackson} is installed, this extension reuses its * {@link ObjectMapper}. Otherwise it uses a local default mapper. * - *

Operations are collected at boot from handlers annotated with {@link ApiOperation} - * that also have route metadata ({@link Route} or shorthand verb annotations). + *

Every class-based route is collected at boot, whether or not it is annotated: a path, the + * body its handler takes and what it returns are already in the code. {@link ApiOperation} adds + * the prose. * *

{@code
  * FlashApp.create(8080)
@@ -35,7 +35,6 @@ import lombok.extern.slf4j.Slf4j;
  *     .start();
  * }
*/ -@Slf4j public class OpenApiExtension implements FlashExtension { private static final String YAML_CONTENT_TYPE = "application/yaml"; @@ -121,18 +120,12 @@ public class OpenApiExtension implements FlashExtension { ""; } + /** Every class-based route is an operation; {@link ApiOperation} only adds what the code cannot say. */ private static void addOperationFromEvent(OpenApiBuilder builder, RouteEvent event) { Class handlerClass = event.handlerClass(); - if (handlerClass == null) return; // lambda route: no annotation metadata + if (handlerClass == null) return; // a lambda route has nothing to read - ApiOperation op = handlerClass.getAnnotation(ApiOperation.class); - if (op == null) { - log.warn("{} {} ({}) has no @ApiOperation — omitted from the OpenAPI spec", - event.method(), event.path(), handlerClass.getSimpleName()); - return; - } - - builder.addOperation(routeOf(event), op, handlerClass); + builder.addOperation(routeOf(event), handlerClass.getAnnotation(ApiOperation.class), handlerClass); } private static Route routeOf(RouteEvent event) { diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/RequestBody.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/RequestBody.java new file mode 100644 index 0000000..707e52d --- /dev/null +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/RequestBody.java @@ -0,0 +1,31 @@ +package dev.relism.flash.ext.openapi; + +import dev.relism.flash.http.ContentType; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * The body this operation takes, for a handler that reads it by hand. + * + *

A handler extending {@code BodyHandler} — {@code JsonHandler} and its like — needs none of + * this: its body type is its type argument and its media type comes from {@code @Consumes}. Use + * this when the body is read straight off the request, or to describe it as something other than + * what the handler parses. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface RequestBody { + + Class value(); + + ContentType contentType() default ContentType.JSON; + + boolean array() default false; + + boolean required() default true; + + String description() default ""; +} diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Schemas.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Schemas.java new file mode 100644 index 0000000..1af3bc4 --- /dev/null +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Schemas.java @@ -0,0 +1,247 @@ +package dev.relism.flash.ext.openapi; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonProperty.Access; + +import java.lang.reflect.Array; +import java.lang.reflect.Field; +import java.lang.reflect.GenericArrayType; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** + * Every schema the document names, and the types they were built from. + * + *

A type is described once and referenced by {@code $ref} everywhere it appears, so a document + * over a hundred routes carries one copy of each model. What a field means comes from the type + * itself: Jackson's annotations decide what is exposed, {@code jakarta.validation} constraints + * become the schema's own bounds, and {@link Schema}/{@link SchemaProperty} say the rest. + */ +final class Schemas { + + /** Resolved once: jakarta.validation is an optional dependency of this module. */ + private static final boolean CONSTRAINTS_PRESENT = ConstraintHints.available(); + + private static final Set> SIMPLE = Set.of( + String.class, CharSequence.class, + Boolean.class, Byte.class, Short.class, Integer.class, Long.class, Float.class, Double.class, + boolean.class, byte.class, short.class, int.class, long.class, float.class, double.class, + UUID.class, LocalDate.class, LocalDateTime.class, OffsetDateTime.class, Instant.class + ); + + private final Map, String> names = new LinkedHashMap<>(); + private final Map> docs = new LinkedHashMap<>(); + private final Set> resolving = new HashSet<>(); + + Map referenceFor(Class type) { + return schemaFor(type); + } + + Map schemaForType(Type type) { + return schemaFor(type); + } + + Map render() { + Map out = new LinkedHashMap<>(); + for (var e : docs.entrySet()) out.put(e.getKey(), e.getValue()); + return out; + } + + private Map schemaFor(Type type) { + if (type instanceof ParameterizedType p) { + Class raw = rawType(p); + if (raw != null && Collection.class.isAssignableFrom(raw)) { + Type item = p.getActualTypeArguments()[0]; + return Map.of("type", "array", "items", schemaFor(item)); + } + if (raw != null && Map.class.isAssignableFrom(raw)) { + Type value = p.getActualTypeArguments().length > 1 ? p.getActualTypeArguments()[1] : Object.class; + return Map.of("type", "object", "additionalProperties", schemaFor(value)); + } + if (raw != null) return schemaFor(raw); + } + + Class cls = rawType(type); + if (cls == null || cls == Object.class) return Map.of("type", "object"); + + if (cls.isArray()) return Map.of("type", "array", "items", schemaFor(cls.getComponentType())); + if (Collection.class.isAssignableFrom(cls)) return Map.of("type", "array", "items", Map.of("type", "object")); + if (Map.class.isAssignableFrom(cls)) return Map.of("type", "object", "additionalProperties", Map.of("type", "object")); + + Map simple = simpleSchema(cls); + if (simple != null) return simple; + + return Map.of("$ref", "#/components/schemas/" + registerPojo(cls)); + } + + private String registerPojo(Class cls) { + String existing = names.get(cls); + if (existing != null) return existing; + + String base = schemaName(cls); + String name = base; + int i = 2; + while (docs.containsKey(name)) name = base + i++; + names.put(cls, name); + + if (resolving.contains(cls)) return name; + + resolving.add(cls); + docs.put(name, buildPojoSchema(cls)); + resolving.remove(cls); + return name; + } + + private Map buildPojoSchema(Class cls) { + Schema typeSchema = cls.getAnnotation(Schema.class); + JsonIgnoreProperties ignoredType = cls.getAnnotation(JsonIgnoreProperties.class); + Set ignored = ignoredType == null + ? Set.of() + : new HashSet<>(Arrays.asList(ignoredType.value())); + + Map out = new LinkedHashMap<>(); + out.put("type", "object"); + if (typeSchema != null) applySchemaHints(out, typeSchema); + + Map properties = new LinkedHashMap<>(); + List required = new ArrayList<>(); + + for (Field f : cls.getDeclaredFields()) { + int mod = f.getModifiers(); + if (Modifier.isStatic(mod) || Modifier.isTransient(mod)) continue; + if (f.isAnnotationPresent(JsonIgnore.class)) continue; + if (ignored.contains(f.getName())) continue; + + String name = f.getName(); + JsonProperty jp = f.getAnnotation(JsonProperty.class); + if (jp != null && !jp.value().isEmpty()) name = jp.value(); + + Schema ps = f.getAnnotation(Schema.class); + SchemaProperty sp = f.getAnnotation(SchemaProperty.class); + ArraySchema array = f.getAnnotation(ArraySchema.class); + if ((ps != null && ps.hidden()) || (sp != null && sp.hidden())) continue; + + if (sp != null && !sp.name().isEmpty()) name = sp.name(); + + Map property = new LinkedHashMap<>(schemaFor(f.getGenericType())); + if (ps != null) applySchemaHints(property, ps); + if (sp != null) applySchemaHints(property, sp); + if (array != null) applyArrayHints(property, array); + if (jp != null) { + if (jp.access() == Access.READ_ONLY) property.put("readOnly", true); + if (jp.access() == Access.WRITE_ONLY) property.put("writeOnly", true); + } + + // Constraints a body is checked against also describe it, so + // mirror them here rather than making callers restate every rule as @Schema. + boolean constrainedRequired = CONSTRAINTS_PRESENT && ConstraintHints.apply(f, property); + + properties.put(name, property); + if (constrainedRequired + || (ps != null && ps.required()) || (sp != null && sp.required()) || (jp != null && jp.required())) + required.add(name); + } + + if (!properties.isEmpty()) out.put("properties", properties); + if (!required.isEmpty()) out.put("required", required); + return out; + } + + private static void applySchemaHints(Map target, Schema schema) { + if (!schema.title().isEmpty()) target.put("title", schema.title()); + if (!schema.description().isEmpty()) target.put("description", schema.description()); + if (!schema.format().isEmpty()) target.put("format", schema.format()); + if (!schema.example().isEmpty()) target.put("example", schema.example()); + if (schema.enumeration().length > 0) target.put("enum", Arrays.asList(schema.enumeration())); + if (schema.nullable()) target.put("nullable", true); + if (schema.deprecated()) target.put("deprecated", true); + } + + private static void applySchemaHints(Map target, SchemaProperty schema) { + if (!schema.title().isEmpty()) target.put("title", schema.title()); + if (!schema.description().isEmpty()) target.put("description", schema.description()); + if (!schema.format().isEmpty()) target.put("format", schema.format()); + if (!schema.example().isEmpty()) target.put("example", schema.example()); + if (schema.enumeration().length > 0) target.put("enum", Arrays.asList(schema.enumeration())); + if (schema.nullable()) target.put("nullable", true); + if (schema.deprecated()) target.put("deprecated", true); + } + + private Map withArrayType(Map property, ArraySchema array) { + if ("array".equals(property.get("type"))) return property; + Type itemType = array.itemClass() != Void.class ? array.itemClass() : Object.class; + Map wrapped = new LinkedHashMap<>(); + wrapped.put("type", "array"); + wrapped.put("items", schemaFor(itemType)); + return wrapped; + } + + private void applyArrayHints(Map property, ArraySchema array) { + Map target = withArrayType(property, array); + if (target != property) { + property.clear(); + property.putAll(target); + } + if (array.uniqueItems()) property.put("uniqueItems", true); + if (array.minItems() >= 0) property.put("minItems", array.minItems()); + if (array.maxItems() >= 0) property.put("maxItems", array.maxItems()); + } + + private static String schemaName(Class cls) { + Schema schema = cls.getAnnotation(Schema.class); + if (schema != null && !schema.name().isEmpty()) return schema.name(); + return cls.getSimpleName(); + } + + private static Map simpleSchema(Class cls) { + if (!SIMPLE.contains(cls) && !cls.isEnum()) return null; + + if (cls == String.class || CharSequence.class.isAssignableFrom(cls)) return Map.of("type", "string"); + if (cls == Boolean.class || cls == boolean.class) return Map.of("type", "boolean"); + if (cls == Integer.class || cls == int.class || cls == Long.class || cls == long.class || + cls == Short.class || cls == short.class || cls == Byte.class || cls == byte.class) { + return Map.of("type", "integer"); + } + if (cls == Float.class || cls == float.class || cls == Double.class || cls == double.class) { + return Map.of("type", "number"); + } + if (cls == UUID.class) return Map.of("type", "string", "format", "uuid"); + if (cls == LocalDate.class) return Map.of("type", "string", "format", "date"); + if (cls == LocalDateTime.class || cls == OffsetDateTime.class || cls == Instant.class) + return Map.of("type", "string", "format", "date-time"); + if (cls.isEnum()) { + Object[] constants = cls.getEnumConstants(); + List values = new ArrayList<>(constants.length); + for (Object c : constants) values.add(String.valueOf(c)); + return Map.of("type", "string", "enum", values); + } + return null; + } + + +static Class rawType(Type type) { + if (type instanceof Class c) return c; + if (type instanceof ParameterizedType p && p.getRawType() instanceof Class c) return c; + if (type instanceof GenericArrayType a) { + Class component = rawType(a.getGenericComponentType()); + return component == null ? null : Array.newInstance(component, 0).getClass(); + } + return null; +} +} diff --git a/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java b/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java index b3f79aa..40baa7b 100644 --- a/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java +++ b/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java @@ -133,6 +133,150 @@ class OpenApiBuilderTest { public List tags; } + @dev.relism.flash.routing.POST("/bodies") + @ApiOperation(summary = "Typed body") + static final class TypedBodyHandler extends JsonLikeHandler { + @Override protected UserDto handle(Request request, Response response, UserDto body) { return body; } + } + + @dev.relism.flash.routing.Consumes(ContentType.JSON) + static abstract class JsonLikeHandler extends dev.relism.flash.models.BodyHandler { + @Override protected B body(Request request) { return null; } + } + + @dev.relism.flash.routing.PUT("/declared-body") + @ApiOperation(summary = "Declared body") + @RequestBody(value = UserDto.class, array = true, required = false, description = "Users to store") + static class DeclaredBodyHandler extends RequestHandler { + @Override public Object handle(Request request, Response response) { return null; } + } + + @GET("/bare") + static class BareHandler extends RequestHandler { + @Override public UserDto handle(Request request, Response response) { return null; } + } + + @GET("/example") + @ApiOperation(summary = "Example") + @APIResponse(responseCode = "200", content = @Content(schema = UserDto.class, example = "{\"id\":\"usr-1\"}")) + static class ExampleHandler extends RequestHandler { + @Override public Object handle(Request request, Response response) { return null; } + } + + @GET("/secure-too") + @ApiOperation(summary = "Secure too") + static class SecondSecureHandler extends RequestHandler { + @Override public Object handle(Request request, Response response) { return null; } + } + + @Test + void a_typed_handler_documents_its_body_without_saying_the_type_twice() { + OpenApiBuilder b = new OpenApiBuilder(); + b.addOperation(OpenApiBuilder.routeOf(TypedBodyHandler.class), TypedBodyHandler.class.getAnnotation(ApiOperation.class), TypedBodyHandler.class); + + Map post = getOperation(b.build(), "/bodies", "post"); + Map body = cast(post.get("requestBody")); + Map content = cast(body.get("content")); + Map json = cast(content.get("application/json")); + Map schema = cast(json.get("schema")); + + assertEquals(true, body.get("required")); + assertEquals("#/components/schemas/UserDTO", schema.get("$ref")); + } + + @Test + void a_declared_body_wins_and_carries_its_own_shape() { + OpenApiBuilder b = new OpenApiBuilder(); + b.addOperation(OpenApiBuilder.routeOf(DeclaredBodyHandler.class), DeclaredBodyHandler.class.getAnnotation(ApiOperation.class), DeclaredBodyHandler.class); + + Map put = getOperation(b.build(), "/declared-body", "put"); + Map body = cast(put.get("requestBody")); + Map content = cast(body.get("content")); + Map json = cast(content.get("application/json")); + Map schema = cast(json.get("schema")); + + assertEquals("Users to store", body.get("description")); + assertEquals(false, body.get("required")); + assertEquals("array", schema.get("type")); + } + + @Test + void a_route_with_no_annotations_is_still_documented() { + OpenApiBuilder b = new OpenApiBuilder(); + b.addOperation(OpenApiBuilder.routeOf(BareHandler.class), null, BareHandler.class); + + Map get = getOperation(b.build(), "/bare", "get"); + Map responses = cast(get.get("responses")); + Map ok = cast(responses.get("200")); + Map content = cast(ok.get("content")); + Map json = cast(content.get("application/json")); + Map schema = cast(json.get("schema")); + + assertEquals("#/components/schemas/UserDTO", schema.get("$ref")); + } + + @Test + void a_failure_is_documented_with_the_shape_flash_answers_with() { + OpenApiBuilder b = new OpenApiBuilder(); + b.addOperation(OpenApiBuilder.routeOf(SecureHandler.class), SecureHandler.class.getAnnotation(ApiOperation.class), SecureHandler.class); + + Map spec = b.build(); + Map get = getOperation(spec, "/secure", "get"); + Map responses = cast(get.get("responses")); + Map forbidden = cast(responses.get("403")); + Map content = cast(forbidden.get("content")); + Map json = cast(content.get("application/json")); + Map schema = cast(json.get("schema")); + Map components = cast(spec.get("components")); + Map schemas = cast(components.get("schemas")); + + assertEquals("#/components/schemas/Error", schema.get("$ref")); + assertTrue(schemas.containsKey("Error")); + } + + @Test + void an_answer_two_operations_share_is_written_once() { + OpenApiBuilder b = new OpenApiBuilder(); + OpenApiContributorRegistry registry = new OpenApiContributorRegistry(); + registry.add(new OpenApiContributor() { + @Override public OpenApiOperationContribution operationFor(Class handlerClass) { + return OpenApiOperationContribution.builder() + .response(401, OpenApiResponseContribution.of("Authentication required")) + .build(); + } + }); + b.setContributorRegistry(registry); + b.addOperation(OpenApiBuilder.routeOf(SecureHandler.class), SecureHandler.class.getAnnotation(ApiOperation.class), SecureHandler.class); + b.addOperation(OpenApiBuilder.routeOf(SecondSecureHandler.class), SecondSecureHandler.class.getAnnotation(ApiOperation.class), SecondSecureHandler.class); + + Map spec = b.build(); + Map components = cast(spec.get("components")); + Map shared = cast(components.get("responses")); + Map firstResponses = cast(getOperation(spec, "/secure", "get").get("responses")); + Map secondResponses = cast(getOperation(spec, "/secure-too", "get").get("responses")); + Map first = cast(firstResponses.get("401")); + Map second = cast(secondResponses.get("401")); + Map unauthorized = cast(shared.get("Unauthorized")); + + assertEquals("#/components/responses/Unauthorized", first.get("$ref")); + assertEquals("#/components/responses/Unauthorized", second.get("$ref")); + assertEquals("Authentication required", unauthorized.get("description")); + } + + @Test + void an_example_sits_beside_the_schema() { + OpenApiBuilder b = new OpenApiBuilder(); + b.addOperation(OpenApiBuilder.routeOf(ExampleHandler.class), ExampleHandler.class.getAnnotation(ApiOperation.class), ExampleHandler.class); + + Map get = getOperation(b.build(), "/example", "get"); + Map responses = cast(get.get("responses")); + Map ok = cast(responses.get("200")); + Map content = cast(ok.get("content")); + Map json = cast(content.get("application/json")); + + assertEquals("{\"id\":\"usr-1\"}", json.get("example")); + } + @Test void builds_single_response_and_parameters_and_schema() { OpenApiBuilder b = new OpenApiBuilder().title("X").version("1");