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
+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