feat: typed bodies, injected services, one Jackson module per format #20
@@ -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 "";
|
||||
}
|
||||
|
||||
+303
-424
@@ -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.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<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<>();
|
||||
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;
|
||||
}
|
||||
|
||||
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<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);
|
||||
}
|
||||
|
||||
// ── 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));
|
||||
|
||||
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<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", 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<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()) {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, List<String>>> security = (List<Map<String, List<String>>>) 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<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