weeks of bullshit
This commit is contained in:
+1
-1
@@ -10,7 +10,7 @@ import java.lang.annotation.Target;
|
||||
* Picked up by {@link OpenApiExtension} via {@link FlashApp#register}.
|
||||
*
|
||||
* <pre>{@code
|
||||
* @Route(method = HttpMethod.GET, path = "/api/blogs")
|
||||
* @GET("/api/blogs")
|
||||
* @ApiOperation(summary = "List all blogs", tags = {"blogs"})
|
||||
* public class ListBlogs extends JacksonHandler { ... }
|
||||
* }</pre>
|
||||
|
||||
+1
@@ -20,4 +20,5 @@ public @interface ApiResponse {
|
||||
int status();
|
||||
String description() default "";
|
||||
Class<?> schema() default Void.class;
|
||||
boolean useReturnType() default false;
|
||||
}
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package dev.relism.ext.openapi;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/** Optional array-specific schema metadata. */
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target({ElementType.FIELD, ElementType.METHOD})
|
||||
public @interface ArraySchema {
|
||||
Class<?> itemClass() default Void.class;
|
||||
boolean uniqueItems() default false;
|
||||
int minItems() default -1;
|
||||
int maxItems() default -1;
|
||||
}
|
||||
+274
-76
@@ -1,68 +1,65 @@
|
||||
package dev.relism.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.routing.Route;
|
||||
|
||||
import java.lang.reflect.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Accumulates OpenAPI 3.0 operations and builds the spec document as a plain
|
||||
* {@code Map} for Jackson to serialize. Operations are added at registration time
|
||||
* via {@link OpenApiExtension}'s {@link dev.relism.AnnotationProcessor}.
|
||||
* OpenAPI document assembler.
|
||||
*
|
||||
* Collects operation metadata at route registration time and renders an OpenAPI 3.0.3 map.
|
||||
* Response schemas are resolved automatically into components.schemas.
|
||||
*/
|
||||
public class OpenApiBuilder {
|
||||
public final class OpenApiBuilder {
|
||||
|
||||
private String title = "API";
|
||||
private String title = "API";
|
||||
private String version = "1.0.0";
|
||||
private String description = "";
|
||||
|
||||
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 OpenApiSecurityRegistry securityRegistry;
|
||||
// Build cache: OpenAPI is rendered only when the document revision changes.
|
||||
private int revision;
|
||||
private int builtRevision = -1;
|
||||
private Map<String, Object> cachedSpec;
|
||||
|
||||
// ── Configuration ─────────────────────────────────────────────────────────
|
||||
|
||||
public OpenApiBuilder title(String title) { this.title = title; return this; }
|
||||
public OpenApiBuilder version(String version) { this.version = version; return this; }
|
||||
public OpenApiBuilder title(String title) { this.title = title; return this; }
|
||||
public OpenApiBuilder version(String version) { this.version = version; return this; }
|
||||
public OpenApiBuilder description(String description) { this.description = description; return this; }
|
||||
void setSecurityRegistry(OpenApiSecurityRegistry registry) { this.securityRegistry = registry; }
|
||||
|
||||
void setSecurityRegistry(OpenApiSecurityRegistry registry) {
|
||||
this.securityRegistry = registry;
|
||||
}
|
||||
|
||||
// ── Operation registration ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Adds an operation derived from the handler's {@link Route}, {@link ApiOperation},
|
||||
* {@link ApiResponse}, and {@link ApiParam} annotations.
|
||||
*/
|
||||
public void addOperation(Route route, ApiOperation op, Class<?> handlerClass) {
|
||||
String path = normalizePath(route.path());
|
||||
String method = route.method().name().toLowerCase();
|
||||
String path = normalizePath(route.path());
|
||||
String method = route.method().name().toLowerCase(Locale.ROOT);
|
||||
|
||||
Map<String, Object> pathItem = paths.computeIfAbsent(path, k -> new LinkedHashMap<>());
|
||||
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.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);
|
||||
|
||||
buildParameters(operation, handlerClass, route);
|
||||
buildResponses(operation, handlerClass);
|
||||
|
||||
pathItem.put(method, operation);
|
||||
paths.computeIfAbsent(path, k -> new LinkedHashMap<>()).put(method, operation);
|
||||
operationHandlers.computeIfAbsent(path, k -> new LinkedHashMap<>()).put(method, handlerClass);
|
||||
revision++;
|
||||
}
|
||||
|
||||
// ── Spec build ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the complete OpenAPI 3.0.3 spec as a plain map ready for JSON
|
||||
* serialization. Called on each request to {@code /openapi.json} so that
|
||||
* handlers registered after the extension is installed are included.
|
||||
*/
|
||||
public Map<String, Object> build() {
|
||||
int r = revision;
|
||||
Map<String, Object> cached = cachedSpec;
|
||||
if (cached != null && builtRevision == r) return cached;
|
||||
|
||||
Map<String, Object> info = new LinkedHashMap<>();
|
||||
info.put("title", title);
|
||||
info.put("version", version);
|
||||
@@ -71,8 +68,6 @@ public class OpenApiBuilder {
|
||||
List<OpenApiSecurityContributor> contributors = securityRegistry != null
|
||||
? securityRegistry.contributors() : List.of();
|
||||
|
||||
// Build paths with security injected per-operation (fresh copy each time so
|
||||
// repeated calls don't accumulate duplicate security entries)
|
||||
Map<String, Object> renderedPaths = new LinkedHashMap<>();
|
||||
for (var pathEntry : paths.entrySet()) {
|
||||
Map<String, Object> renderedPathItem = new LinkedHashMap<>();
|
||||
@@ -80,7 +75,7 @@ public class OpenApiBuilder {
|
||||
for (var methodEntry : pathEntry.getValue().entrySet()) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> original = (Map<String, Object>) methodEntry.getValue();
|
||||
Map<String, Object> op = new LinkedHashMap<>(original); // shallow copy
|
||||
Map<String, Object> op = new LinkedHashMap<>(original);
|
||||
Class<?> handler = handlers.get(methodEntry.getKey());
|
||||
if (handler != null && !contributors.isEmpty()) {
|
||||
List<Map<String, List<String>>> security = buildOperationSecurity(contributors, handler);
|
||||
@@ -96,23 +91,24 @@ public class OpenApiBuilder {
|
||||
spec.put("info", info);
|
||||
spec.put("paths", renderedPaths);
|
||||
|
||||
Map<String, Object> components = new LinkedHashMap<>();
|
||||
Map<String, Object> renderedSchemas = schemas.render();
|
||||
if (!renderedSchemas.isEmpty()) components.put("schemas", renderedSchemas);
|
||||
if (!contributors.isEmpty()) {
|
||||
Map<String, Object> schemes = new LinkedHashMap<>();
|
||||
for (OpenApiSecurityContributor c : contributors) {
|
||||
schemes.put(c.schemeName(), c.schemeDefinition());
|
||||
}
|
||||
spec.put("components", Map.of("securitySchemes", schemes));
|
||||
Map<String, Object> securitySchemes = new LinkedHashMap<>();
|
||||
for (OpenApiSecurityContributor c : contributors) securitySchemes.put(c.schemeName(), c.schemeDefinition());
|
||||
components.put("securitySchemes", securitySchemes);
|
||||
}
|
||||
if (!components.isEmpty()) spec.put("components", components);
|
||||
|
||||
cachedSpec = spec;
|
||||
builtRevision = r;
|
||||
return spec;
|
||||
}
|
||||
|
||||
// ── Internals ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void buildParameters(Map<String, Object> op, Class<?> cls, Route route) {
|
||||
List<Map<String, Object>> params = new ArrayList<>();
|
||||
|
||||
// Path params from @Route path — add them automatically as required
|
||||
String path = route.path();
|
||||
int i = 0;
|
||||
while (i < path.length()) {
|
||||
@@ -121,18 +117,16 @@ public class OpenApiBuilder {
|
||||
int close = path.indexOf('}', open);
|
||||
if (close < 0) break;
|
||||
String name = path.substring(open + 1, close);
|
||||
Map<String, Object> p = new LinkedHashMap<>();
|
||||
p.put("name", name);
|
||||
p.put("in", "path");
|
||||
p.put("required", true);
|
||||
p.put("schema", Map.of("type", "string"));
|
||||
params.add(p);
|
||||
params.add(new LinkedHashMap<>(Map.of(
|
||||
"name", name,
|
||||
"in", "path",
|
||||
"required", true,
|
||||
"schema", Map.of("type", "string")
|
||||
)));
|
||||
i = close + 1;
|
||||
}
|
||||
|
||||
// Explicit @ApiParam annotations
|
||||
ApiParam[] apiParams = cls.getAnnotationsByType(ApiParam.class);
|
||||
for (ApiParam ann : apiParams) {
|
||||
for (ApiParam ann : cls.getAnnotationsByType(ApiParam.class)) {
|
||||
Map<String, Object> p = new LinkedHashMap<>();
|
||||
p.put("name", ann.name());
|
||||
p.put("in", ann.in());
|
||||
@@ -149,27 +143,46 @@ public class OpenApiBuilder {
|
||||
}
|
||||
|
||||
private void buildResponses(Map<String, Object> op, Class<?> cls) {
|
||||
ApiResponse[] annotations = cls.getAnnotationsByType(ApiResponse.class);
|
||||
ApiResponse[] anns = cls.getAnnotationsByType(ApiResponse.class);
|
||||
Map<String, Object> responses = new LinkedHashMap<>();
|
||||
|
||||
if (annotations.length == 0) {
|
||||
if (anns.length == 0) {
|
||||
responses.put("200", Map.of("description", "OK"));
|
||||
} else {
|
||||
for (ApiResponse ann : annotations) {
|
||||
Map<String, Object> r = new LinkedHashMap<>();
|
||||
r.put("description", ann.description().isEmpty() ? httpPhrase(ann.status()) : ann.description());
|
||||
if (ann.schema() != Void.class) {
|
||||
r.put("content", Map.of(
|
||||
"application/json", Map.of(
|
||||
"schema", Map.of("$ref", "#/components/schemas/" + ann.schema().getSimpleName()))));
|
||||
}
|
||||
responses.put(String.valueOf(ann.status()), r);
|
||||
}
|
||||
op.put("responses", responses);
|
||||
return;
|
||||
}
|
||||
|
||||
for (ApiResponse ann : anns) {
|
||||
Map<String, Object> r = new LinkedHashMap<>();
|
||||
r.put("description", ann.description().isEmpty() ? httpPhrase(ann.status()) : ann.description());
|
||||
|
||||
Class<?> schemaType = resolveSchemaType(ann, cls);
|
||||
if (schemaType != null && schemaType != Void.class) {
|
||||
r.put("content", Map.of(
|
||||
"application/json", Map.of("schema", schemas.referenceFor(schemaType))
|
||||
));
|
||||
}
|
||||
responses.put(String.valueOf(ann.status()), r);
|
||||
}
|
||||
|
||||
op.put("responses", responses);
|
||||
}
|
||||
|
||||
/** Converts Flash path params ({id}) to OpenAPI path params ({id}) — already compatible. */
|
||||
private static Class<?> resolveSchemaType(ApiResponse ann, Class<?> handlerClass) {
|
||||
if (ann.schema() != Void.class) return ann.schema();
|
||||
if (!ann.useReturnType()) return null;
|
||||
try {
|
||||
Method handle = handlerClass.getMethod("handle", dev.relism.models.Request.class, dev.relism.models.Response.class);
|
||||
Type ret = handle.getGenericReturnType();
|
||||
Class<?> raw = rawType(ret);
|
||||
if (raw == null || raw == Object.class || raw == dev.relism.models.Response.class || raw == Void.class || raw == void.class)
|
||||
return null;
|
||||
return raw;
|
||||
} catch (NoSuchMethodException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizePath(String path) {
|
||||
return path.startsWith("/") ? path : "/" + path;
|
||||
}
|
||||
@@ -186,19 +199,204 @@ public class OpenApiBuilder {
|
||||
case 409 -> "Conflict";
|
||||
case 422 -> "Unprocessable Entity";
|
||||
case 500 -> "Internal Server Error";
|
||||
default -> "";
|
||||
default -> "";
|
||||
};
|
||||
}
|
||||
|
||||
private static List<Map<String, List<String>>> buildOperationSecurity(
|
||||
List<OpenApiSecurityContributor> contributors, Class<?> handlerClass) {
|
||||
private static List<Map<String, List<String>>> buildOperationSecurity(List<OpenApiSecurityContributor> contributors,
|
||||
Class<?> handlerClass) {
|
||||
List<Map<String, List<String>>> security = new ArrayList<>();
|
||||
for (OpenApiSecurityContributor c : contributors) {
|
||||
List<String> scopes = c.requiredFor(handlerClass);
|
||||
if (scopes != null) {
|
||||
security.add(Map.of(c.schemeName(), scopes));
|
||||
}
|
||||
if (scopes != null) security.add(Map.of(c.schemeName(), scopes));
|
||||
}
|
||||
return security;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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> 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 && !typeSchema.description().isEmpty()) out.put("description", typeSchema.description());
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
properties.put(name, property);
|
||||
if ((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> property, Schema schema) {
|
||||
if (!schema.description().isEmpty()) property.put("description", schema.description());
|
||||
if (!schema.format().isEmpty()) property.put("format", schema.format());
|
||||
if (!schema.example().isEmpty()) property.put("example", schema.example());
|
||||
if (schema.nullable()) property.put("nullable", true);
|
||||
}
|
||||
|
||||
private static void applySchemaHints(Map<String, Object> property, SchemaProperty schema) {
|
||||
if (!schema.description().isEmpty()) property.put("description", schema.description());
|
||||
if (!schema.format().isEmpty()) property.put("format", schema.format());
|
||||
if (!schema.example().isEmpty()) property.put("example", schema.example());
|
||||
if (schema.nullable()) property.put("nullable", 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 Map<String, Object> simpleSchema(Class<?> cls) {
|
||||
if (cls == String.class || cls == CharSequence.class || cls == UUID.class) return Map.of("type", "string");
|
||||
if (cls == boolean.class || cls == Boolean.class) return Map.of("type", "boolean");
|
||||
if (cls == byte.class || cls == Byte.class || cls == short.class || cls == Short.class ||
|
||||
cls == int.class || cls == Integer.class) return Map.of("type", "integer", "format", "int32");
|
||||
if (cls == long.class || cls == Long.class) return Map.of("type", "integer", "format", "int64");
|
||||
if (cls == float.class || cls == Float.class) return Map.of("type", "number", "format", "float");
|
||||
if (cls == double.class || cls == Double.class) return Map.of("type", "number", "format", "double");
|
||||
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(((Enum<?>) c).name());
|
||||
return Map.of("type", "string", "enum", values);
|
||||
}
|
||||
if (!SIMPLE.contains(cls) && cls.getName().startsWith("java.")) return Map.of("type", "string");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+56
-40
@@ -1,13 +1,17 @@
|
||||
package dev.relism.ext.openapi;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
|
||||
import dev.relism.extension.FlashContext;
|
||||
import dev.relism.extension.FlashExtension;
|
||||
import dev.relism.extension.FlashRegistrar;
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.http.HttpMethod;
|
||||
import dev.relism.routing.Route;
|
||||
|
||||
import java.lang.annotation.Annotation;
|
||||
|
||||
/**
|
||||
* Generates and serves an OpenAPI 3.0 spec and Swagger UI under a configurable base path.
|
||||
*
|
||||
@@ -18,17 +22,16 @@ import dev.relism.routing.Route;
|
||||
* <li>{@code GET /openapi/swagger} — Swagger UI pointing at {@code /openapi.json}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>Requires</b> {@code flash-ext-jackson} to be installed first (shares its
|
||||
* {@link ObjectMapper}). The YAML endpoint uses its own {@link YAMLMapper} instance.
|
||||
* <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 automatically from handlers annotated with
|
||||
* {@link ApiOperation} as they are registered via {@link FlashApp#register}.
|
||||
* <p>Operations are collected at boot from handlers annotated with {@link ApiOperation}
|
||||
* that also have route metadata ({@link Route} or shorthand verb annotations).
|
||||
*
|
||||
* <pre>{@code
|
||||
* FlashApp.of(new HttpServer(config))
|
||||
* FlashApp.create(8080)
|
||||
* .install(new JacksonExtension())
|
||||
* .install(new OpenApiExtension("/openapi", "My API", "2.0.0"))
|
||||
* .register(new BlogHandlers.Index())
|
||||
* .start();
|
||||
* }</pre>
|
||||
*/
|
||||
@@ -60,54 +63,40 @@ public class OpenApiExtension implements FlashExtension {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
// ── FlashExtension ────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public void install(FlashRegistrar app, FlashContext ctx) {
|
||||
ObjectMapper jsonMapper = ctx.require(ObjectMapper.class);
|
||||
YAMLMapper yamlMapper = new YAMLMapper();
|
||||
|
||||
OpenApiBuilder builder = new OpenApiBuilder()
|
||||
.title(title)
|
||||
.version(version)
|
||||
.description(description);
|
||||
|
||||
public void provide(FlashContext ctx) {
|
||||
OpenApiBuilder builder = new OpenApiBuilder().title(title).version(version).description(description);
|
||||
OpenApiSecurityRegistry secRegistry = new OpenApiSecurityRegistry();
|
||||
|
||||
ctx.provide(OpenApiSecurityRegistry.class, secRegistry);
|
||||
ctx.provide(OpenApiBuilder.class, builder);
|
||||
builder.setSecurityRegistry(secRegistry);
|
||||
|
||||
ctx.provide(OpenApiBuilder.class, builder);
|
||||
|
||||
// Collect operation metadata at handler-registration time (no middleware injected)
|
||||
// Collect operation metadata at handler-registration time (no middleware injected).
|
||||
ctx.addAnnotationProcessor(handlerClass -> {
|
||||
ApiOperation op = handlerClass.getAnnotation(ApiOperation.class);
|
||||
Route route = handlerClass.getAnnotation(Route.class);
|
||||
if (op != null && route != null) {
|
||||
builder.addOperation(route, op, handlerClass);
|
||||
}
|
||||
Route route = routeOf(handlerClass);
|
||||
if (op != null && route != null) builder.addOperation(route, op, handlerClass);
|
||||
return java.util.List.of();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
ObjectMapper jsonMapper = ctx.find(ObjectMapper.class).orElseGet(() -> JsonMapper.builder().build());
|
||||
YAMLMapper yamlMapper = new YAMLMapper();
|
||||
OpenApiBuilder builder = ctx.require(OpenApiBuilder.class);
|
||||
|
||||
String jsonPath = basePath + ".json";
|
||||
String yamlPath = basePath + ".yaml";
|
||||
String swaggerPath = basePath + "/swagger";
|
||||
|
||||
// JSON spec
|
||||
app.get(jsonPath, (req, res) -> {
|
||||
res.setContentType(ContentType.JSON);
|
||||
return jsonMapper.writeValueAsString(builder.build());
|
||||
}).with();
|
||||
|
||||
// YAML spec
|
||||
app.get(yamlPath, (req, res) -> {
|
||||
res.type(YAML_CONTENT_TYPE);
|
||||
return yamlMapper.writeValueAsString(builder.build());
|
||||
}).with();
|
||||
|
||||
// Swagger UI — loads from CDN, points at the JSON spec
|
||||
String swaggerHtml = buildSwaggerHtml(jsonPath);
|
||||
app.get(swaggerPath, (req, res) -> {
|
||||
res.setContentType(ContentType.TEXT_HTML);
|
||||
return swaggerHtml;
|
||||
}).with();
|
||||
|
||||
app.get(jsonPath, (req, res) -> { res.type(ContentType.JSON); return jsonMapper.writeValueAsString(builder.build()); });
|
||||
app.get(yamlPath, (req, res) -> { res.type(YAML_CONTENT_TYPE); return yamlMapper.writeValueAsString(builder.build()); });
|
||||
app.get(swaggerPath, (req, res) -> { res.type(ContentType.TEXT_HTML); return swaggerHtml; });
|
||||
}
|
||||
|
||||
// ── Swagger UI HTML ───────────────────────────────────────────────────────
|
||||
@@ -136,4 +125,31 @@ public class OpenApiExtension implements FlashExtension {
|
||||
"</body>\n" +
|
||||
"</html>";
|
||||
}
|
||||
|
||||
private 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);
|
||||
if (meta == null) continue;
|
||||
String path = readPathValue(ann);
|
||||
if (path == null) continue;
|
||||
HttpMethod method = meta.method();
|
||||
return new Route() {
|
||||
@Override public HttpMethod method() { return method; }
|
||||
@Override public String path() { return path; }
|
||||
@Override public Class<? extends Annotation> annotationType() { return Route.class; }
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String readPathValue(Annotation ann) {
|
||||
try {
|
||||
Object v = ann.annotationType().getMethod("value").invoke(ann);
|
||||
return v instanceof String s ? s : null;
|
||||
} catch (ReflectiveOperationException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package dev.relism.ext.openapi;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/** Minimal OpenAPI schema metadata for model classes and properties. */
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD})
|
||||
public @interface Schema {
|
||||
String name() default "";
|
||||
String description() default "";
|
||||
String format() default "";
|
||||
String example() default "";
|
||||
boolean nullable() default false;
|
||||
boolean required() default false;
|
||||
boolean hidden() default false;
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package dev.relism.ext.openapi;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/** Optional alias for property-level schema metadata. */
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target({ElementType.FIELD, ElementType.METHOD})
|
||||
public @interface SchemaProperty {
|
||||
String name() default "";
|
||||
String description() default "";
|
||||
String format() default "";
|
||||
String example() default "";
|
||||
boolean nullable() default false;
|
||||
boolean required() default false;
|
||||
boolean hidden() default false;
|
||||
}
|
||||
Reference in New Issue
Block a user