weeks of bullshit

This commit is contained in:
Relism
2026-04-17 08:18:11 +02:00
parent b5d4481502
commit 9efbe38c0c
157 changed files with 5171 additions and 1273 deletions
@@ -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>
@@ -20,4 +20,5 @@ public @interface ApiResponse {
int status();
String description() default "";
Class<?> schema() default Void.class;
boolean useReturnType() default false;
}
@@ -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;
}
@@ -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;
}
}
}
@@ -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;
}
@@ -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;
}