feat(ext-openapi): document what the code already says
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
adcd6376b6
commit
2fbe65fcc5
@@ -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<T>`, a `Map<String, T>`
|
||||
- **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<NewUser> {
|
||||
@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<UserDto>` / `Set<UserDto>` / `UserDto[]` -> `array` with `items: UserDto`
|
||||
- `Map<String, UserDto>` -> `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.
|
||||
|
||||
+3
@@ -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 "";
|
||||
}
|
||||
|
||||
+298
-419
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<String, Object> 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<String, Map<String, Object>> paths = new LinkedHashMap<>();
|
||||
private final Map<String, Map<String, Class<?>>> 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<String, Object> 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<String, Object> operation = new LinkedHashMap<>();
|
||||
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", Arrays.asList(op.tags()));
|
||||
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<String, Object> build() {
|
||||
int r = revision;
|
||||
int current = revision;
|
||||
Map<String, Object> cached = cachedSpec;
|
||||
if (cached != null && builtRevision == r) return cached;
|
||||
if (cached != null && builtRevision == current) return cached;
|
||||
|
||||
List<OpenApiContributor> contributors = contributors();
|
||||
Map<String, Object> renderedPaths = new LinkedHashMap<>();
|
||||
for (var path : paths.entrySet()) {
|
||||
Map<String, Class<?>> handlers = operationHandlers.getOrDefault(path.getKey(), Map.of());
|
||||
Map<String, Object> pathItem = new LinkedHashMap<>();
|
||||
for (var method : path.getValue().entrySet()) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> declared = (Map<String, Object>) method.getValue();
|
||||
Map<String, Object> 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<String, Object> sharedResponses = hoistSharedResponses(renderedPaths);
|
||||
|
||||
Map<String, Object> info = new LinkedHashMap<>();
|
||||
info.put("title", title);
|
||||
info.put("version", version);
|
||||
if (!description.isEmpty()) info.put("description", description);
|
||||
|
||||
List<OpenApiContributor> contributors = contributorRegistry != null
|
||||
? contributorRegistry.contributors() : List.of();
|
||||
|
||||
Map<String, Object> renderedPaths = new LinkedHashMap<>();
|
||||
for (var pathEntry : paths.entrySet()) {
|
||||
Map<String, Object> renderedPathItem = new LinkedHashMap<>();
|
||||
Map<String, Class<?>> handlers = operationHandlers.getOrDefault(pathEntry.getKey(), Map.of());
|
||||
for (var methodEntry : pathEntry.getValue().entrySet()) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> original = (Map<String, Object>) methodEntry.getValue();
|
||||
Map<String, Object> 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<String, Object> spec = new LinkedHashMap<>();
|
||||
spec.put("openapi", OPENAPI_VERSION);
|
||||
spec.put("info", info);
|
||||
spec.put("paths", renderedPaths);
|
||||
|
||||
Map<String, Object> components = new LinkedHashMap<>();
|
||||
Map<String, Object> renderedSchemas = schemas.render();
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> op, Class<?> cls, Route route) {
|
||||
List<Map<String, Object>> params = new ArrayList<>();
|
||||
// ── Parameters ────────────────────────────────────────────────────────────
|
||||
|
||||
private void parameters(Map<String, Object> operation, Class<?> handlerClass, Route route) {
|
||||
List<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> op, Class<?> cls) {
|
||||
APIResponse[] anns = cls.getAnnotationsByType(APIResponse.class);
|
||||
Map<Integer, Map<String, Object>> responseByCode = new LinkedHashMap<>();
|
||||
|
||||
for (APIResponse ann : anns) {
|
||||
int code = parseStatus(ann.responseCode());
|
||||
responseByCode.put(code, buildAnnotatedResponse(code, ann, cls));
|
||||
private static Map<String, Object> examples(String[] values) {
|
||||
Map<String, Object> examples = new LinkedHashMap<>();
|
||||
for (int i = 0; i < values.length; i++) examples.put("example" + (i + 1), Map.of("value", values[i]));
|
||||
return examples;
|
||||
}
|
||||
|
||||
if (responseByCode.isEmpty()) {
|
||||
// Mutable: contributors merge descriptions and headers into it.
|
||||
responseByCode.put(200, new LinkedHashMap<>(Map.of("description", "OK")));
|
||||
// ── Request body ──────────────────────────────────────────────────────────
|
||||
|
||||
/** From {@link RequestBody}, or from the body type the handler declares in its own signature. */
|
||||
private void requestBody(Map<String, Object> 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<String, Object> schema = schemas.referenceFor(type);
|
||||
if (declared != null && declared.array()) schema = arrayOf(schema);
|
||||
|
||||
Map<String, Object> 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);
|
||||
}
|
||||
|
||||
applyContributorResponses(responseByCode, cls);
|
||||
// ── Responses ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void responses(Map<String, Object> operation, Class<?> handlerClass) {
|
||||
APIResponse[] declared = handlerClass.getAnnotationsByType(APIResponse.class);
|
||||
Map<Integer, Map<String, Object>> byStatus = new LinkedHashMap<>();
|
||||
Set<Integer> 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));
|
||||
|
||||
applyContributorResponses(byStatus, handlerClass, declaredStatuses);
|
||||
|
||||
Map<String, Object> 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<String, Object> response(int status, APIResponse declared, Class<?> handlerClass) {
|
||||
Map<String, Object> 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<String, Object> schema = schemaFor(content, status, handlerClass);
|
||||
if (schema == null) return response;
|
||||
|
||||
Map<String, Object> 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<String, Object> schemaFor(Content content, int status, Class<?> handlerClass) {
|
||||
if (content.schema() != Void.class) {
|
||||
Map<String, Object> schema = schemas.referenceFor(content.schema());
|
||||
return content.array() ? arrayOf(schema) : schema;
|
||||
}
|
||||
if (status >= 400) return errorSchema();
|
||||
|
||||
Map<String, Object> 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<String, Object> inferredResponse(Class<?> handlerClass) {
|
||||
Map<String, Object> response = new LinkedHashMap<>(Map.of("description", reasonFor(200)));
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> errorSchema() {
|
||||
errorsDocumented = true;
|
||||
return Map.of("$ref", ERROR_REF);
|
||||
}
|
||||
|
||||
// ── Contributors ──────────────────────────────────────────────────────────
|
||||
|
||||
private List<OpenApiContributor> contributors() {
|
||||
return contributorRegistry != null ? contributorRegistry.contributors() : List.of();
|
||||
}
|
||||
|
||||
private void applyContributorResponses(Map<Integer, Map<String, Object>> responseByCode, Class<?> handlerClass) {
|
||||
APIResponse[] manual = handlerClass.getAnnotationsByType(APIResponse.class);
|
||||
Set<Integer> manualStatusCodes = new HashSet<>();
|
||||
for (APIResponse ann : manual) {
|
||||
manualStatusCodes.add(parseStatus(ann.responseCode()));
|
||||
}
|
||||
|
||||
private void applyContributorResponses(Map<Integer, Map<String, Object>> byStatus, Class<?> handlerClass,
|
||||
Set<Integer> declaredStatuses) {
|
||||
for (OpenApiContributor contributor : contributors()) {
|
||||
OpenApiOperationContribution contribution = contributor.operationFor(handlerClass);
|
||||
if (contribution == null) continue;
|
||||
|
||||
for (Map.Entry<Integer, OpenApiResponseContribution> entry : contribution.responses().entrySet()) {
|
||||
int status = entry.getKey();
|
||||
OpenApiResponseContribution responseContribution = entry.getValue();
|
||||
if (responseContribution == null) continue;
|
||||
|
||||
Map<String, Object> 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<String, Object> 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<Integer, Map<String, Object>> 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<String, Object> response,
|
||||
OpenApiResponseContribution contribution,
|
||||
int status,
|
||||
Set<Integer> manualStatusCodes) {
|
||||
String desc = contribution.description();
|
||||
if (!manualStatusCodes.contains(status) && desc != null && !desc.isBlank()) {
|
||||
response.put("description", desc);
|
||||
}
|
||||
private void merge(Map<String, Object> response, OpenApiResponseContribution contributed, int status, boolean declared) {
|
||||
String description = contributed.description();
|
||||
if (!declared && description != null && !description.isBlank()) response.put("description", description);
|
||||
|
||||
Map<String, Map<String, Object>> headerContributions = contribution.headers();
|
||||
if (!headerContributions.isEmpty()) {
|
||||
Map<String, Map<String, Object>> headers = contributed.headers();
|
||||
if (!headers.isEmpty()) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> headers = (Map<String, Object>) response.computeIfAbsent("headers", __ -> new LinkedHashMap<>());
|
||||
for (Map.Entry<String, Map<String, Object>> h : headerContributions.entrySet()) {
|
||||
headers.put(h.getKey(), new LinkedHashMap<>(h.getValue()));
|
||||
Map<String, Object> target = (Map<String, Object>) response.computeIfAbsent("headers", h -> new LinkedHashMap<>());
|
||||
headers.forEach((name, header) -> target.put(name, new LinkedHashMap<>(header)));
|
||||
}
|
||||
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())));
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.containsKey("description")) {
|
||||
response.put("description", defaultDescription(status));
|
||||
}
|
||||
}
|
||||
|
||||
private static void applyContributorComponents(Map<String, Object> components, List<OpenApiContributor> contributors) {
|
||||
private void applyContributorSecurity(Map<String, Object> operation, Class<?> handlerClass,
|
||||
List<OpenApiContributor> contributors) {
|
||||
for (OpenApiContributor contributor : contributors) {
|
||||
Map<String, Object> 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<Map<String, List<String>>> security =
|
||||
(List<Map<String, List<String>>>) operation.computeIfAbsent("security", s -> new ArrayList<>());
|
||||
security.addAll(contribution.security());
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void deepMergeLastWins(Map<String, Object> target, Map<String, Object> incoming) {
|
||||
for (Map.Entry<String, Object> e : incoming.entrySet()) {
|
||||
Object existing = target.get(e.getKey());
|
||||
Object value = e.getValue();
|
||||
if (existing instanceof Map<?, ?> em && value instanceof Map<?, ?> vm) {
|
||||
Map<String, Object> merged = new LinkedHashMap<>((Map<String, Object>) em);
|
||||
deepMergeLastWins(merged, (Map<String, Object>) 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<String, Object> merged = new LinkedHashMap<>((Map<String, Object>) from);
|
||||
deepMergeLastWins(merged, (Map<String, Object>) to);
|
||||
target.put(key, merged);
|
||||
} else {
|
||||
target.put(e.getKey(), value);
|
||||
}
|
||||
target.put(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void applyContributorOperation(Map<String, Object> op, Class<?> handlerClass, List<OpenApiContributor> contributors) {
|
||||
for (OpenApiContributor contributor : contributors) {
|
||||
OpenApiOperationContribution contribution = contributor.operationFor(handlerClass);
|
||||
if (contribution == null || contribution.isEmpty()) continue;
|
||||
// ── Shared responses ──────────────────────────────────────────────────────
|
||||
|
||||
if (!contribution.security().isEmpty()) {
|
||||
/**
|
||||
* 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")
|
||||
List<Map<String, List<String>>> security = (List<Map<String, List<String>>>) op
|
||||
.computeIfAbsent("security", __ -> new ArrayList<>());
|
||||
security.addAll(contribution.security());
|
||||
private Map<String, Object> hoistSharedResponses(Map<String, Object> renderedPaths) {
|
||||
Map<Object, Integer> seen = new HashMap<>();
|
||||
for (Object pathItem : renderedPaths.values()) {
|
||||
for (Object operation : ((Map<String, Object>) pathItem).values()) {
|
||||
Map<String, Object> responses = (Map<String, Object>) ((Map<String, Object>) operation).get("responses");
|
||||
responses.forEach((status, response) -> seen.merge(key(status, response), 1, Integer::sum));
|
||||
}
|
||||
}
|
||||
|
||||
Map<Object, String> names = new LinkedHashMap<>();
|
||||
Map<String, Object> shared = new LinkedHashMap<>();
|
||||
for (Object pathItem : renderedPaths.values()) {
|
||||
for (Object operation : ((Map<String, Object>) pathItem).values()) {
|
||||
Map<String, Object> responses = (Map<String, Object>) ((Map<String, Object>) 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<String, Object> buildAnnotatedResponse(int code, APIResponse ann, Class<?> handlerClass) {
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> arrayOf(Map<String, Object> 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<String, Object> resolveResponseSchema(Content content, Class<?> handlerClass) {
|
||||
if (content.schema() != Void.class) {
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> asArraySchema(Map<String, Object> 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<String, Object> toExamples(String[] examples) {
|
||||
Map<String, Object> 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<Class<?>> 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<Class<?>, String> names = new LinkedHashMap<>();
|
||||
private final Map<String, Map<String, Object>> docs = new LinkedHashMap<>();
|
||||
private final Set<Class<?>> resolving = new HashSet<>();
|
||||
|
||||
Map<String, Object> referenceFor(Class<?> type) {
|
||||
return schemaFor(type);
|
||||
}
|
||||
|
||||
Map<String, Object> schemaForType(Type type) {
|
||||
return schemaFor(type);
|
||||
}
|
||||
|
||||
Map<String, Object> render() {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
for (var e : docs.entrySet()) out.put(e.getKey(), e.getValue());
|
||||
return out;
|
||||
}
|
||||
|
||||
private Map<String, Object> 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<String, Object> 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<String, Object> buildPojoSchema(Class<?> cls) {
|
||||
Schema typeSchema = cls.getAnnotation(Schema.class);
|
||||
JsonIgnoreProperties ignoredType = cls.getAnnotation(JsonIgnoreProperties.class);
|
||||
Set<String> ignored = ignoredType == null
|
||||
? Set.of()
|
||||
: new HashSet<>(Arrays.asList(ignoredType.value()));
|
||||
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("type", "object");
|
||||
if (typeSchema != null) applySchemaHints(out, typeSchema);
|
||||
|
||||
Map<String, Object> properties = new LinkedHashMap<>();
|
||||
List<String> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> withArrayType(Map<String, Object> property, ArraySchema array) {
|
||||
if ("array".equals(property.get("type"))) return property;
|
||||
Type itemType = array.itemClass() != Void.class ? array.itemClass() : Object.class;
|
||||
Map<String, Object> wrapped = new LinkedHashMap<>();
|
||||
wrapped.put("type", "array");
|
||||
wrapped.put("items", schemaFor(itemType));
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
private void applyArrayHints(Map<String, Object> property, ArraySchema array) {
|
||||
Map<String, Object> 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<String, Object> 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<String> 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;
|
||||
}
|
||||
}
|
||||
|
||||
+6
-13
@@ -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;
|
||||
* <p>If {@code flash-ext-jackson} is installed, this extension reuses its
|
||||
* {@link ObjectMapper}. Otherwise it uses a local default mapper.
|
||||
*
|
||||
* <p>Operations are collected at boot from handlers annotated with {@link ApiOperation}
|
||||
* that also have route metadata ({@link Route} or shorthand verb annotations).
|
||||
* <p>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.
|
||||
*
|
||||
* <pre>{@code
|
||||
* FlashApp.create(8080)
|
||||
@@ -35,7 +35,6 @@ import lombok.extern.slf4j.Slf4j;
|
||||
* .start();
|
||||
* }</pre>
|
||||
*/
|
||||
@Slf4j
|
||||
public class OpenApiExtension implements FlashExtension {
|
||||
|
||||
private static final String YAML_CONTENT_TYPE = "application/yaml";
|
||||
@@ -121,18 +120,12 @@ public class OpenApiExtension implements FlashExtension {
|
||||
"</html>";
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
|
||||
+31
@@ -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.
|
||||
*
|
||||
* <p>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 "";
|
||||
}
|
||||
+247
@@ -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.
|
||||
*
|
||||
* <p>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<Class<?>> 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<Class<?>, String> names = new LinkedHashMap<>();
|
||||
private final Map<String, Map<String, Object>> docs = new LinkedHashMap<>();
|
||||
private final Set<Class<?>> resolving = new HashSet<>();
|
||||
|
||||
Map<String, Object> referenceFor(Class<?> type) {
|
||||
return schemaFor(type);
|
||||
}
|
||||
|
||||
Map<String, Object> schemaForType(Type type) {
|
||||
return schemaFor(type);
|
||||
}
|
||||
|
||||
Map<String, Object> render() {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
for (var e : docs.entrySet()) out.put(e.getKey(), e.getValue());
|
||||
return out;
|
||||
}
|
||||
|
||||
private Map<String, Object> 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<String, Object> 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<String, Object> buildPojoSchema(Class<?> cls) {
|
||||
Schema typeSchema = cls.getAnnotation(Schema.class);
|
||||
JsonIgnoreProperties ignoredType = cls.getAnnotation(JsonIgnoreProperties.class);
|
||||
Set<String> ignored = ignoredType == null
|
||||
? Set.of()
|
||||
: new HashSet<>(Arrays.asList(ignoredType.value()));
|
||||
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("type", "object");
|
||||
if (typeSchema != null) applySchemaHints(out, typeSchema);
|
||||
|
||||
Map<String, Object> properties = new LinkedHashMap<>();
|
||||
List<String> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> withArrayType(Map<String, Object> property, ArraySchema array) {
|
||||
if ("array".equals(property.get("type"))) return property;
|
||||
Type itemType = array.itemClass() != Void.class ? array.itemClass() : Object.class;
|
||||
Map<String, Object> wrapped = new LinkedHashMap<>();
|
||||
wrapped.put("type", "array");
|
||||
wrapped.put("items", schemaFor(itemType));
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
private void applyArrayHints(Map<String, Object> property, ArraySchema array) {
|
||||
Map<String, Object> 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<String, Object> 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<String> 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;
|
||||
}
|
||||
}
|
||||
+144
@@ -133,6 +133,150 @@ class OpenApiBuilderTest {
|
||||
public List<String> tags;
|
||||
}
|
||||
|
||||
@dev.relism.flash.routing.POST("/bodies")
|
||||
@ApiOperation(summary = "Typed body")
|
||||
static final class TypedBodyHandler extends JsonLikeHandler<UserDto> {
|
||||
@Override protected UserDto handle(Request request, Response response, UserDto body) { return body; }
|
||||
}
|
||||
|
||||
@dev.relism.flash.routing.Consumes(ContentType.JSON)
|
||||
static abstract class JsonLikeHandler<B> extends dev.relism.flash.models.BodyHandler<B> {
|
||||
@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<String, Object> post = getOperation(b.build(), "/bodies", "post");
|
||||
Map<String, Object> body = cast(post.get("requestBody"));
|
||||
Map<String, Object> content = cast(body.get("content"));
|
||||
Map<String, Object> json = cast(content.get("application/json"));
|
||||
Map<String, Object> 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<String, Object> put = getOperation(b.build(), "/declared-body", "put");
|
||||
Map<String, Object> body = cast(put.get("requestBody"));
|
||||
Map<String, Object> content = cast(body.get("content"));
|
||||
Map<String, Object> json = cast(content.get("application/json"));
|
||||
Map<String, Object> 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<String, Object> get = getOperation(b.build(), "/bare", "get");
|
||||
Map<String, Object> responses = cast(get.get("responses"));
|
||||
Map<String, Object> ok = cast(responses.get("200"));
|
||||
Map<String, Object> content = cast(ok.get("content"));
|
||||
Map<String, Object> json = cast(content.get("application/json"));
|
||||
Map<String, Object> 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<String, Object> spec = b.build();
|
||||
Map<String, Object> get = getOperation(spec, "/secure", "get");
|
||||
Map<String, Object> responses = cast(get.get("responses"));
|
||||
Map<String, Object> forbidden = cast(responses.get("403"));
|
||||
Map<String, Object> content = cast(forbidden.get("content"));
|
||||
Map<String, Object> json = cast(content.get("application/json"));
|
||||
Map<String, Object> schema = cast(json.get("schema"));
|
||||
Map<String, Object> components = cast(spec.get("components"));
|
||||
Map<String, Object> 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<String, Object> spec = b.build();
|
||||
Map<String, Object> components = cast(spec.get("components"));
|
||||
Map<String, Object> shared = cast(components.get("responses"));
|
||||
Map<String, Object> firstResponses = cast(getOperation(spec, "/secure", "get").get("responses"));
|
||||
Map<String, Object> secondResponses = cast(getOperation(spec, "/secure-too", "get").get("responses"));
|
||||
Map<String, Object> first = cast(firstResponses.get("401"));
|
||||
Map<String, Object> second = cast(secondResponses.get("401"));
|
||||
Map<String, Object> 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<String, Object> get = getOperation(b.build(), "/example", "get");
|
||||
Map<String, Object> responses = cast(get.get("responses"));
|
||||
Map<String, Object> ok = cast(responses.get("200"));
|
||||
Map<String, Object> content = cast(ok.get("content"));
|
||||
Map<String, Object> 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");
|
||||
|
||||
Reference in New Issue
Block a user