i spent the last year just spinning

This commit is contained in:
Relism
2026-04-17 18:56:06 +02:00
parent 9efbe38c0c
commit e161497f2c
77 changed files with 2545 additions and 877 deletions
@@ -1,27 +0,0 @@
package dev.relism.ext.openapi;
import java.lang.annotation.*;
/**
* Declares a single parameter (query, path, header, or cookie) for an operation.
* Repeatable — place multiple annotations on the same handler class.
*
* <pre>{@code
* @ApiParam(name = "limit", in = "query", type = "integer", description = "Max results (default 20)")
* @ApiParam(name = "offset", in = "query", type = "integer", description = "Pagination offset")
* public class ListBlogs extends JacksonHandler { ... }
* }</pre>
*/
@Repeatable(ApiParams.class)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface ApiParam {
String name();
/** "query", "path", "header", or "cookie". */
String in() default "query";
/** OpenAPI primitive type: "string", "integer", "number", "boolean". */
String type() default "string";
String description() default "";
boolean required() default false;
String example() default "";
}
@@ -1,24 +1,19 @@
package dev.relism.ext.openapi;
import java.lang.annotation.*;
import java.lang.annotation.ElementType;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Declares a single response for an operation. Repeatable — use multiple
* {@code @ApiResponse} annotations on the same handler to document several status codes.
*
* <pre>{@code
* @ApiResponse(status = 200, description = "Blog created", schema = Blog.class)
* @ApiResponse(status = 400, description = "Invalid input")
* @ApiResponse(status = 409, description = "Slug already exists")
* public class CreateBlog extends JacksonHandler { ... }
* }</pre>
* Declares one OpenAPI response for a class-based handler operation.
*/
@Repeatable(ApiResponses.class)
@Repeatable(APIResponses.class)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface ApiResponse {
int status();
public @interface APIResponse {
String responseCode();
String description() default "";
Class<?> schema() default Void.class;
boolean useReturnType() default false;
Content content() default @Content;
}
@@ -5,9 +5,9 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/** Container for repeated {@link ApiResponse} annotations. */
/** Container for repeated {@link APIResponse} annotations. */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface ApiResponses {
ApiResponse[] value();
public @interface APIResponses {
APIResponse[] value();
}
@@ -0,0 +1,19 @@
package dev.relism.ext.openapi;
import dev.relism.http.ContentType;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* OpenAPI response content descriptor.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD})
public @interface Content {
ContentType contentType() default ContentType.JSON;
Class<?> schema() default Void.class;
boolean array() default false;
}
@@ -4,20 +4,46 @@ 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.http.ContentType;
import dev.relism.http.HttpStatus;
import dev.relism.models.Request;
import dev.relism.models.Response;
import dev.relism.routing.Route;
import java.lang.reflect.*;
import java.time.*;
import java.util.*;
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.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.LinkedHashSet;
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.
*
* Collects operation metadata at route registration time and renders an OpenAPI 3.0.3 map.
* Response schemas are resolved automatically into components.schemas.
*/
public final class OpenApiBuilder {
private static final String OPENAPI_VERSION = "3.0.3";
private String title = "API";
private String version = "1.0.0";
private String description = "";
@@ -26,7 +52,6 @@ public final class OpenApiBuilder {
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;
@@ -87,7 +112,7 @@ public final class OpenApiBuilder {
}
Map<String, Object> spec = new LinkedHashMap<>();
spec.put("openapi", "3.0.3");
spec.put("openapi", OPENAPI_VERSION);
spec.put("info", info);
spec.put("paths", renderedPaths);
@@ -126,16 +151,21 @@ public final class OpenApiBuilder {
i = close + 1;
}
for (ApiParam ann : cls.getAnnotationsByType(ApiParam.class)) {
for (Parameter ann : cls.getAnnotationsByType(Parameter.class)) {
Map<String, Object> p = new LinkedHashMap<>();
p.put("name", ann.name());
p.put("in", ann.in());
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);
Map<String, Object> schema = new LinkedHashMap<>();
schema.put("type", ann.type());
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);
}
@@ -143,68 +173,107 @@ public final class OpenApiBuilder {
}
private void buildResponses(Map<String, Object> op, Class<?> cls) {
ApiResponse[] anns = cls.getAnnotationsByType(ApiResponse.class);
Map<String, Object> responses = new LinkedHashMap<>();
APIResponse[] anns = cls.getAnnotationsByType(APIResponse.class);
Map<Integer, Map<String, Object>> responseByCode = new LinkedHashMap<>();
if (anns.length == 0) {
responses.put("200", Map.of("description", "OK"));
op.put("responses", responses);
return;
for (APIResponse ann : anns) {
int code = parseStatus(ann.responseCode());
responseByCode.put(code, buildAnnotatedResponse(code, ann, cls));
}
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))
));
for (OpenApiSecurityContributor c : securityContributors()) {
for (var auto : c.autoResponsesFor(cls).entrySet()) {
responseByCode.putIfAbsent(auto.getKey(), Map.of("description", auto.getValue()));
}
responses.put(String.valueOf(ann.status()), r);
}
if (responseByCode.isEmpty()) {
responseByCode.put(200, Map.of("description", "OK"));
}
Map<String, Object> responses = new LinkedHashMap<>();
responseByCode.entrySet().stream()
.sorted(Map.Entry.comparingByKey(Comparator.naturalOrder()))
.forEach(e -> responses.put(String.valueOf(e.getKey()), e.getValue()));
op.put("responses", responses);
}
private static Class<?> resolveSchemaType(ApiResponse ann, Class<?> handlerClass) {
if (ann.schema() != Void.class) return ann.schema();
if (!ann.useReturnType()) return null;
private List<OpenApiSecurityContributor> securityContributors() {
return securityRegistry != null ? securityRegistry.contributors() : List.of();
}
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());
Content content = ann.content();
if (content.contentType() == ContentType.NONE) return out;
Map<String, Object> schema = resolveResponseSchema(content, handlerClass);
if (schema == null || schema.isEmpty()) return out;
out.put("content", Map.of(mediaTypeOf(content.contentType()), Map.of("schema", schema)));
return out;
}
private static int parseStatus(String code) {
try {
Method handle = handlerClass.getMethod("handle", dev.relism.models.Request.class, dev.relism.models.Response.class);
return Integer.parseInt(code.trim());
} catch (Exception 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 == dev.relism.models.Response.class || raw == Void.class || raw == void.class)
if (raw == null || raw == Object.class || raw == Response.class || raw == Void.class || raw == void.class)
return null;
return raw;
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 mediaTypeOf(ContentType type) {
byte[] bytes = type.getBytes();
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) {
return path.startsWith("/") ? path : "/" + path;
}
private static String httpPhrase(int status) {
return switch (status) {
case 200 -> "OK";
case 201 -> "Created";
case 204 -> "No Content";
case 400 -> "Bad Request";
case 401 -> "Unauthorized";
case 403 -> "Forbidden";
case 404 -> "Not Found";
case 409 -> "Conflict";
case 422 -> "Unprocessable Entity";
case 500 -> "Internal Server Error";
default -> "";
};
private static String defaultDescription(int status) {
String reason = HttpStatus.reasonForCode(status);
return reason == null ? "" : reason;
}
private static List<Map<String, List<String>>> buildOperationSecurity(List<OpenApiSecurityContributor> contributors,
Class<?> handlerClass) {
Class<?> handlerClass) {
List<Map<String, List<String>>> security = new ArrayList<>();
for (OpenApiSecurityContributor c : contributors) {
List<String> scopes = c.requiredFor(handlerClass);
@@ -239,6 +308,10 @@ public final class OpenApiBuilder {
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());
@@ -283,6 +356,7 @@ public final class OpenApiBuilder {
names.put(cls, name);
if (resolving.contains(cls)) return name;
resolving.add(cls);
docs.put(name, buildPojoSchema(cls));
resolving.remove(cls);
@@ -298,7 +372,7 @@ public final class OpenApiBuilder {
Map<String, Object> out = new LinkedHashMap<>();
out.put("type", "object");
if (typeSchema != null && !typeSchema.description().isEmpty()) out.put("description", typeSchema.description());
if (typeSchema != null) applySchemaHints(out, typeSchema);
Map<String, Object> properties = new LinkedHashMap<>();
List<String> required = new ArrayList<>();
@@ -338,18 +412,24 @@ public final class OpenApiBuilder {
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> 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> 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 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) {
@@ -378,24 +458,55 @@ public final class OpenApiBuilder {
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");
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(((Enum<?>) c).name());
for (Object c : constants) values.add(String.valueOf(c));
return Map.of("type", "string", "enum", values);
}
if (!SIMPLE.contains(cls) && cls.getName().startsWith("java.")) return Map.of("type", "string");
return null;
}
}
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;
dev.relism.http.HttpMethod method = meta.method();
return new Route() {
@Override public dev.relism.http.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;
}
}
@@ -6,12 +6,10 @@ 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.extension.RouteEvent;
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.
*
@@ -74,13 +72,9 @@ public class OpenApiExtension implements FlashExtension {
ctx.provide(OpenApiBuilder.class, builder);
builder.setSecurityRegistry(secRegistry);
// Collect operation metadata at handler-registration time (no middleware injected).
ctx.addAnnotationProcessor(handlerClass -> {
ApiOperation op = handlerClass.getAnnotation(ApiOperation.class);
Route route = routeOf(handlerClass);
if (op != null && route != null) builder.addOperation(route, op, handlerClass);
return java.util.List.of();
});
// Collect operation metadata from final compiled routes.
// This guarantees full runtime paths (namespaces/prefixes/rewrites) in the spec.
ctx.addRouteListener(event -> addOperationFromEvent(builder, event));
}
@Override
@@ -126,30 +120,21 @@ public class OpenApiExtension implements FlashExtension {
"</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 void addOperationFromEvent(OpenApiBuilder builder, RouteEvent event) {
Class<?> handlerClass = event.handlerClass();
if (handlerClass == null) return; // lambda route: no annotation metadata
ApiOperation op = handlerClass.getAnnotation(ApiOperation.class);
if (op == null) return;
builder.addOperation(routeOf(event), op, handlerClass);
}
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;
}
private static Route routeOf(RouteEvent event) {
return new Route() {
@Override public dev.relism.http.HttpMethod method() { return event.method(); }
@Override public String path() { return event.path(); }
@Override public Class<? extends java.lang.annotation.Annotation> annotationType() { return Route.class; }
};
}
}
@@ -46,4 +46,12 @@ public interface OpenApiSecurityContributor {
* </ul>
*/
List<String> requiredFor(Class<?> handlerClass);
/**
* Optional auto-injected operation responses for handlers secured by this contributor.
* Key = HTTP status code, value = description.
*/
default Map<Integer, String> autoResponsesFor(Class<?> handlerClass) {
return Map.of();
}
}
@@ -0,0 +1,26 @@
package dev.relism.ext.openapi;
import java.lang.annotation.ElementType;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Declares one OpenAPI parameter for a class-based handler operation.
*/
@Repeatable(Parameters.class)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Parameter {
String name();
ParameterIn in() default ParameterIn.QUERY;
String description() default "";
boolean required() default false;
String example() default "";
String[] examples() default {};
SchemaType type() default SchemaType.STRING;
String style() default "";
boolean explode() default false;
boolean allowEmptyValue() default false;
}
@@ -0,0 +1,14 @@
package dev.relism.ext.openapi;
import java.util.Locale;
public enum ParameterIn {
QUERY,
PATH,
HEADER,
COOKIE;
public String wireValue() {
return name().toLowerCase(Locale.ROOT);
}
}
@@ -5,9 +5,9 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/** Container for repeated {@link ApiParam} annotations. */
/** Container for repeated {@link Parameter} annotations. */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface ApiParams {
ApiParam[] value();
public @interface Parameters {
Parameter[] value();
}
@@ -10,10 +10,13 @@ import java.lang.annotation.Target;
@Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD})
public @interface Schema {
String name() default "";
String title() default "";
String description() default "";
String format() default "";
String example() default "";
String[] enumeration() default {};
boolean nullable() default false;
boolean required() default false;
boolean deprecated() default false;
boolean hidden() default false;
}
@@ -10,10 +10,13 @@ import java.lang.annotation.Target;
@Target({ElementType.FIELD, ElementType.METHOD})
public @interface SchemaProperty {
String name() default "";
String title() default "";
String description() default "";
String format() default "";
String example() default "";
String[] enumeration() default {};
boolean nullable() default false;
boolean required() default false;
boolean deprecated() default false;
boolean hidden() default false;
}
@@ -0,0 +1,16 @@
package dev.relism.ext.openapi;
import java.util.Locale;
public enum SchemaType {
STRING,
INTEGER,
NUMBER,
BOOLEAN,
OBJECT,
ARRAY;
public String wireValue() {
return name().toLowerCase(Locale.ROOT);
}
}
@@ -0,0 +1,263 @@
package dev.relism.ext.openapi;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import dev.relism.http.ContentType;
import dev.relism.routing.GET;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class OpenApiBuilderTest {
@GET("/users/{id}")
@ApiOperation(summary = "Get user")
@Parameter(name = "expand", in = ParameterIn.QUERY, required = false, type = SchemaType.STRING, examples = {"roles", "permissions"})
@APIResponse(responseCode = "200", description = "User found", content = @Content(contentType = ContentType.JSON, schema = UserDto.class))
static class GetUserHandler extends dev.relism.models.RequestHandler {
@Override
public Object handle(dev.relism.models.Request request, dev.relism.models.Response response) {
return null;
}
}
@GET("/users")
@ApiOperation(summary = "List users")
@APIResponse(responseCode = "200", content = @Content(contentType = ContentType.JSON, schema = UserDto.class, array = true))
static class ListUsersHandler extends dev.relism.models.RequestHandler {
@Override
public Object handle(dev.relism.models.Request request, dev.relism.models.Response response) {
return null;
}
}
@GET("/ping")
@ApiOperation(summary = "Ping")
@APIResponse(responseCode = "204", description = "No content", content = @Content(contentType = ContentType.NONE))
static class PingHandler extends dev.relism.models.RequestHandler {
@Override
public Object handle(dev.relism.models.Request request, dev.relism.models.Response response) {
return null;
}
}
@GET("/secure")
@ApiOperation(summary = "Secure")
@APIResponse(responseCode = "403", description = "Custom forbidden")
static class SecureHandler extends dev.relism.models.RequestHandler {
@Override
public Object handle(dev.relism.models.Request request, dev.relism.models.Response response) {
return null;
}
}
@GET("/typed")
@ApiOperation(summary = "Typed")
@APIResponse(responseCode = "200", content = @Content)
static class TypedHandler extends dev.relism.models.RequestHandler {
@Override
public UserDto handle(dev.relism.models.Request request, dev.relism.models.Response response) {
return new UserDto();
}
}
@GET("/typed-list")
@ApiOperation(summary = "Typed list")
@APIResponse(responseCode = "200", content = @Content)
static class TypedListHandler extends dev.relism.models.RequestHandler {
@Override
public List<UserDto> handle(dev.relism.models.Request request, dev.relism.models.Response response) {
return List.of();
}
}
@GET("/typed-map")
@ApiOperation(summary = "Typed map")
@APIResponse(responseCode = "200", content = @Content)
static class TypedMapHandler extends dev.relism.models.RequestHandler {
@Override
public Map<String, UserDto> handle(dev.relism.models.Request request, dev.relism.models.Response response) {
return Map.of();
}
}
@Schema(name = "UserDTO", title = "User model", description = "DTO", deprecated = true)
@JsonIgnoreProperties({"ignoredByType"})
static class UserDto {
@SchemaProperty(title = "Identifier", description = "Unique id", required = true, example = "usr-1", enumeration = {"usr-1", "usr-2"})
public String id;
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
public String readOnlyField;
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
public String writeOnlyField;
@JsonIgnore
public String hiddenByIgnore;
public transient String transientField;
public String ignoredByType;
@ArraySchema(uniqueItems = true, minItems = 1)
public List<String> tags;
}
@Test
void builds_single_response_and_parameters_and_schema() {
OpenApiBuilder b = new OpenApiBuilder().title("X").version("1");
b.addOperation(OpenApiBuilder.routeOf(GetUserHandler.class), GetUserHandler.class.getAnnotation(ApiOperation.class), GetUserHandler.class);
Map<String, Object> spec = b.build();
Map<String, Object> paths = cast(spec.get("paths"));
Map<String, Object> userPath = cast(paths.get("/users/{id}"));
Map<String, Object> get = cast(userPath.get("get"));
List<Map<String, Object>> params = cast(get.get("parameters"));
assertEquals(2, params.size());
Map<String, Object> responses = cast(get.get("responses"));
assertTrue(responses.containsKey("200"));
Map<String, Object> resp200 = cast(responses.get("200"));
Map<String, Object> content = cast(resp200.get("content"));
assertTrue(content.containsKey("application/json"));
Map<String, Object> components = cast(spec.get("components"));
Map<String, Object> schemas = cast(components.get("schemas"));
assertTrue(schemas.containsKey("UserDTO"));
Map<String, Object> userSchema = cast(schemas.get("UserDTO"));
assertEquals("User model", userSchema.get("title"));
assertEquals(true, userSchema.get("deprecated"));
Map<String, Object> properties = cast(userSchema.get("properties"));
assertFalse(properties.containsKey("hiddenByIgnore"));
assertFalse(properties.containsKey("ignoredByType"));
assertFalse(properties.containsKey("transientField"));
assertTrue(properties.containsKey("id"));
assertTrue(properties.containsKey("tags"));
}
@Test
void builds_array_response() {
OpenApiBuilder b = new OpenApiBuilder();
b.addOperation(OpenApiBuilder.routeOf(ListUsersHandler.class), ListUsersHandler.class.getAnnotation(ApiOperation.class), ListUsersHandler.class);
Map<String, Object> spec = b.build();
Map<String, Object> get = getOperation(spec, "/users", "get");
Map<String, Object> responses = cast(get.get("responses"));
Map<String, Object> resp200 = cast(responses.get("200"));
Map<String, Object> content = cast(resp200.get("content"));
Map<String, Object> appJson = cast(content.get("application/json"));
Map<String, Object> schema = cast(appJson.get("schema"));
assertEquals("array", schema.get("type"));
}
@Test
void builds_no_content_response_without_content_block() {
OpenApiBuilder b = new OpenApiBuilder();
b.addOperation(OpenApiBuilder.routeOf(PingHandler.class), PingHandler.class.getAnnotation(ApiOperation.class), PingHandler.class);
Map<String, Object> spec = b.build();
Map<String, Object> get = getOperation(spec, "/ping", "get");
Map<String, Object> responses = cast(get.get("responses"));
Map<String, Object> resp204 = cast(responses.get("204"));
assertEquals("No content", resp204.get("description"));
assertFalse(resp204.containsKey("content"));
}
@Test
void auto_security_responses_are_added_and_manual_wins_sorted() {
OpenApiBuilder b = new OpenApiBuilder();
OpenApiSecurityRegistry registry = new OpenApiSecurityRegistry();
registry.add(new OpenApiSecurityContributor() {
@Override public String schemeName() { return "oidc"; }
@Override public Map<String, Object> schemeDefinition() { return Map.of("type", "oauth2"); }
@Override public List<String> requiredFor(Class<?> handlerClass) { return List.of(); }
@Override public Map<Integer, String> autoResponsesFor(Class<?> handlerClass) {
return Map.of(401, "Authentication required", 403, "Auto forbidden");
}
});
b.setSecurityRegistry(registry);
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> resp403 = cast(responses.get("403"));
Map<String, Object> resp401 = cast(responses.get("401"));
assertEquals(List.of("401", "403"), new java.util.ArrayList<>(responses.keySet()));
assertEquals("Custom forbidden", resp403.get("description"));
assertEquals("Authentication required", resp401.get("description"));
List<Map<String, List<String>>> security = cast(get.get("security"));
assertNotNull(security);
assertEquals(1, security.size());
}
@Test
void use_return_type_for_response_schema() {
OpenApiBuilder b = new OpenApiBuilder();
b.addOperation(OpenApiBuilder.routeOf(TypedHandler.class), TypedHandler.class.getAnnotation(ApiOperation.class), TypedHandler.class);
Map<String, Object> spec = b.build();
Map<String, Object> get = getOperation(spec, "/typed", "get");
Map<String, Object> responses = cast(get.get("responses"));
Map<String, Object> resp200 = cast(responses.get("200"));
Map<String, Object> content = cast(resp200.get("content"));
assertTrue(content.containsKey("application/json"));
}
@Test
void infer_array_schema_from_collection_return_type() {
OpenApiBuilder b = new OpenApiBuilder();
b.addOperation(OpenApiBuilder.routeOf(TypedListHandler.class), TypedListHandler.class.getAnnotation(ApiOperation.class), TypedListHandler.class);
Map<String, Object> spec = b.build();
Map<String, Object> get = getOperation(spec, "/typed-list", "get");
Map<String, Object> responses = cast(get.get("responses"));
Map<String, Object> resp200 = cast(responses.get("200"));
Map<String, Object> content = cast(resp200.get("content"));
Map<String, Object> appJson = cast(content.get("application/json"));
Map<String, Object> schema = cast(appJson.get("schema"));
assertEquals("array", schema.get("type"));
Map<String, Object> items = cast(schema.get("items"));
assertEquals("#/components/schemas/UserDTO", items.get("$ref"));
}
@Test
void infer_map_value_schema_from_map_return_type() {
OpenApiBuilder b = new OpenApiBuilder();
b.addOperation(OpenApiBuilder.routeOf(TypedMapHandler.class), TypedMapHandler.class.getAnnotation(ApiOperation.class), TypedMapHandler.class);
Map<String, Object> spec = b.build();
Map<String, Object> get = getOperation(spec, "/typed-map", "get");
Map<String, Object> responses = cast(get.get("responses"));
Map<String, Object> resp200 = cast(responses.get("200"));
Map<String, Object> content = cast(resp200.get("content"));
Map<String, Object> appJson = cast(content.get("application/json"));
Map<String, Object> schema = cast(appJson.get("schema"));
assertEquals("object", schema.get("type"));
Map<String, Object> additionalProperties = cast(schema.get("additionalProperties"));
assertEquals("#/components/schemas/UserDTO", additionalProperties.get("$ref"));
}
private static Map<String, Object> getOperation(Map<String, Object> spec, String path, String method) {
Map<String, Object> paths = cast(spec.get("paths"));
Map<String, Object> pathItem = cast(paths.get(path));
return cast(pathItem.get(method));
}
@SuppressWarnings("unchecked")
private static <T> T cast(Object value) {
return (T) value;
}
}
@@ -0,0 +1,161 @@
package dev.relism.ext.openapi;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.extension.FlashContext;
import dev.relism.extension.FlashRegistrar;
import dev.relism.extension.RouteEvent;
import dev.relism.extension.RouteListener;
import dev.relism.http.ContentType;
import dev.relism.http.HttpMethod;
import dev.relism.models.RequestHandler;
import dev.relism.models.Response;
import dev.relism.routing.GET;
import dev.relism.routing.Middleware;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class OpenApiExtensionTest {
@GET("/health")
@ApiOperation(summary = "Health check")
@APIResponse(responseCode = "200", description = "OK")
static class HealthHandler extends RequestHandler {
@Override
public Object handle(dev.relism.models.Request request, Response response) {
return "ok";
}
}
@Test
void provide_collects_operations_and_routes_serve_json_yaml_swagger() throws Exception {
FlashContext ctx = new FlashContext();
OpenApiExtension ext = new OpenApiExtension("/docs", "My API", "2.0.0", "desc");
ext.provide(ctx);
emitRoute(ctx, HttpMethod.GET, "/health", "/", HealthHandler.class);
TestRegistrar app = new TestRegistrar(ctx);
ext.routes(app, ctx);
assertNotNull(app.route(HttpMethod.GET, "/docs.json"));
assertNotNull(app.route(HttpMethod.GET, "/docs.yaml"));
assertNotNull(app.route(HttpMethod.GET, "/docs/swagger"));
Response jsonRes = new Response(200, ContentType.NONE);
Object jsonBody = app.route(HttpMethod.GET, "/docs.json").handle(null, jsonRes);
assertEquals(new String(ContentType.JSON.getBytes()), new String(jsonRes.getContentType()));
assertTrue(String.valueOf(jsonBody).contains("\"openapi\":\"3.0.3\""));
assertTrue(String.valueOf(jsonBody).contains("\"title\":\"My API\""));
Response yamlRes = new Response(200, ContentType.NONE);
Object yamlBody = app.route(HttpMethod.GET, "/docs.yaml").handle(null, yamlRes);
assertEquals("application/yaml", new String(yamlRes.getContentType()));
assertTrue(String.valueOf(yamlBody).contains("openapi: \"3.0.3\""));
Response swaggerRes = new Response(200, ContentType.NONE);
Object swaggerBody = app.route(HttpMethod.GET, "/docs/swagger").handle(null, swaggerRes);
assertEquals(new String(ContentType.TEXT_HTML.getBytes()), new String(swaggerRes.getContentType()));
assertTrue(String.valueOf(swaggerBody).contains("SwaggerUIBundle"));
assertTrue(String.valueOf(swaggerBody).contains("/docs.json"));
}
@Test
void routes_use_mapper_from_context_when_provided() throws Exception {
FlashContext ctx = new FlashContext();
OpenApiExtension ext = new OpenApiExtension();
ObjectMapper mapper = new ObjectMapper();
ctx.provide(ObjectMapper.class, mapper);
ext.provide(ctx);
TestRegistrar app = new TestRegistrar(ctx);
ext.routes(app, ctx);
Response jsonRes = new Response(200, ContentType.NONE);
Object jsonBody = app.route(HttpMethod.GET, "/openapi.json").handle(null, jsonRes);
assertTrue(String.valueOf(jsonBody).contains("\"openapi\":\"3.0.3\""));
}
@GET("/users")
@ApiOperation(summary = "Scoped users")
static class ScopedUsersHandler extends RequestHandler {
@Override
public Object handle(dev.relism.models.Request request, Response response) {
return List.of();
}
}
@Test
void collects_full_runtime_path_from_route_event() {
FlashContext ctx = new FlashContext();
OpenApiExtension ext = new OpenApiExtension();
ext.provide(ctx);
emitRoute(ctx, HttpMethod.GET, "/api/v1/users", "/api/v1", ScopedUsersHandler.class);
OpenApiBuilder builder = ctx.require(OpenApiBuilder.class);
Map<String, Object> spec = builder.build();
Map<String, Object> paths = cast(spec.get("paths"));
assertTrue(paths.containsKey("/api/v1/users"));
assertFalse(paths.containsKey("/users"));
}
@SuppressWarnings("unchecked")
private static List<RouteListener> listeners(FlashContext ctx) {
try {
java.lang.reflect.Method m = FlashContext.class.getDeclaredMethod("routeListeners");
m.setAccessible(true);
return (List<RouteListener>) m.invoke(ctx);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private static void emitRoute(FlashContext ctx, HttpMethod method, String path, String namespace,
Class<? extends RequestHandler> handlerClass) {
RouteEvent event = new RouteEvent(method, path, namespace, "FlashApp", handlerClass, List.of());
for (RouteListener listener : listeners(ctx)) listener.onRoute(event);
}
@SuppressWarnings("unchecked")
private static Map<String, Object> cast(Object value) {
return (Map<String, Object>) value;
}
private static final class TestRegistrar extends FlashRegistrar<TestRegistrar> {
private final FlashContext ctx;
private final Map<String, RequestHandler> routes = new HashMap<>();
private final List<Middleware> middlewares = new ArrayList<>();
private TestRegistrar(FlashContext ctx) {
this.ctx = ctx;
}
@Override
public FlashContext ctx() {
return ctx;
}
@Override
protected void addRoute(HttpMethod method, String path, RequestHandler handler, List<Middleware> mw) {
routes.put(method.name() + " " + path, handler);
}
@Override
protected void addMiddleware(Middleware mw) {
middlewares.add(mw);
}
RequestHandler route(HttpMethod method, String path) {
return routes.get(method.name() + " " + path);
}
}
}