"}
-Any other Throwable → HTTP 500 {"error": "Internal Server Error"}
-```
-
-To throw a handled HTTP error from any handler:
-
-```java
-throw HttpException.notFound("Blog not found");
-throw HttpException.badRequest("Missing field: title");
-throw HttpException.unauthorized();
-throw HttpException.forbidden();
-```
-
-## Lambda routes
-
-For lambda-style routes, use the `ObjectMapper` directly from the context:
-
-```java
-ObjectMapper mapper = app.ctx().require(ObjectMapper.class);
-
-app.get("/api/status", (req, res) -> {
- res.type(ContentType.JSON);
- return mapper.writeValueAsString(Map.of("status", "ok"));
+app.mount("/api", api -> {
+ api.use(jackson.autoJson());
+ api.get("/health", (req, res) -> Map.of("ok", true));
});
```
+
+If you need to pull it from context, `JacksonMiddleware` is also provided as a service
+after the app boots (same lifecycle model as other extension-provided services).
+
+## Notes
+
+- Install order is irrelevant (Flash two-phase extension lifecycle).
+- `autoJson()` and OpenAPI are intentionally decoupled.
diff --git a/flash-extensions/flash-ext-jackson/pom.xml b/flash-extensions/flash-ext-jackson/pom.xml
index 0d0354f..6223ddc 100644
--- a/flash-extensions/flash-ext-jackson/pom.xml
+++ b/flash-extensions/flash-ext-jackson/pom.xml
@@ -7,11 +7,15 @@
dev.relism
flash-extensions
- 1.0-SNAPSHOT
+ 1.1-indev5
flash-ext-jackson
+
+ 0.8.12
+
+
dev.relism
@@ -21,6 +25,10 @@
com.fasterxml.jackson.core
jackson-databind
+
+ com.fasterxml.jackson.datatype
+ jackson-datatype-jsr310
+
org.projectlombok
lombok
@@ -31,4 +39,44 @@
+
+
+
+ org.jacoco
+ jacoco-maven-plugin
+ ${jacoco.version}
+
+
+ jacoco-prepare-agent
+
+ prepare-agent
+
+
+
+ jacoco-report-and-check
+ verify
+
+ report
+ check
+
+
+
+
+ BUNDLE
+
+
+ LINE
+ COVEREDRATIO
+ 0.80
+
+
+
+
+
+
+
+
+
+
+
diff --git a/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/JacksonExtension.java b/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/JacksonExtension.java
index 51d6e9b..dc9df30 100644
--- a/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/JacksonExtension.java
+++ b/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/JacksonExtension.java
@@ -1,9 +1,11 @@
package dev.relism.ext.jackson;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import dev.relism.extension.FlashContext;
import dev.relism.extension.FlashExtension;
+import dev.relism.routing.Middleware;
/**
* Registers JSON support into the Flash extension layer.
@@ -15,6 +17,9 @@ import dev.relism.extension.FlashExtension;
* The raw {@link ObjectMapper} is also registered under {@code ObjectMapper.class}
* for extensions that need direct mapper access (e.g. OpenAPI schema generation).
*
+ *
{@link JacksonMiddleware} is provided under {@code JacksonMiddleware.class} and
+ * exposes opinionated JSON auto-marshalling middleware via {@link JacksonMiddleware#autoJson()}.
+ *
*
Usage — composition (preferred)
* {@code
* public class MyHandler extends RequestHandler {
@@ -45,21 +50,43 @@ import dev.relism.extension.FlashExtension;
public class JacksonExtension implements FlashExtension {
private final ObjectMapper mapper;
+ private final JacksonMiddleware middleware;
- /** Installs with a default {@link JsonMapper} (no extra modules). */
+ /**
+ * Installs with an opinionated default {@link JsonMapper}:
+ * auto-discovers modules on classpath (e.g. Java Time) and writes dates as ISO strings.
+ */
public JacksonExtension() {
- this(JsonMapper.builder().build());
+ this(JsonMapper.builder()
+ .findAndAddModules()
+ .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
+ .build());
}
/** Installs with a fully configured custom {@link ObjectMapper}. */
public JacksonExtension(ObjectMapper mapper) {
this.mapper = mapper;
+ this.middleware = new JacksonMiddleware(mapper);
+ }
+
+ /**
+ * Opinionated outbound JSON middleware factory.
+ *
+ * Use for app/scope-level registration:
+ *
{@code
+ * JacksonExtension jackson = new JacksonExtension();
+ * app.install(jackson).use(jackson.autoJson());
+ * }
+ */
+ public Middleware autoJson() {
+ return middleware.autoJson();
}
@Override
public void provide(FlashContext ctx) {
Json json = new Json(mapper);
- ctx.provide(Json.class, json);
- ctx.provide(ObjectMapper.class, mapper);
+ ctx.provide(Json.class, json);
+ ctx.provide(ObjectMapper.class, mapper);
+ ctx.provide(JacksonMiddleware.class, middleware);
}
}
diff --git a/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/JacksonMiddleware.java b/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/JacksonMiddleware.java
new file mode 100644
index 0000000..4c877df
--- /dev/null
+++ b/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/JacksonMiddleware.java
@@ -0,0 +1,62 @@
+package dev.relism.ext.jackson;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.relism.http.ContentType;
+import dev.relism.models.Response;
+import dev.relism.routing.Middleware;
+
+/**
+ * Outbound JSON marshalling middleware for class-based and lambda routes.
+ *
+ * {@link #autoJson()} marshals any non-body-native return value to JSON bytes,
+ * writes {@code Content-Type: application/json}, and returns {@code byte[]} so
+ * the Flash write path stays direct.
+ *
+ *
Pass-through return types:
+ *
+ * - {@code null}
+ * - {@link Response}
+ * - {@code byte[]}
+ * - {@link String}
+ * - {@link CharSequence}
+ *
+ */
+public final class JacksonMiddleware {
+
+ private final ObjectMapper mapper;
+
+ JacksonMiddleware(ObjectMapper mapper) {
+ this.mapper = mapper;
+ }
+
+ /**
+ * Automatic JSON marshalling policy.
+ *
+ * For non-pass-through return values, serializes with Jackson directly to
+ * {@code byte[]} and sets response content type to JSON.
+ *
+ * @throws IllegalStateException when serialization fails
+ */
+ public Middleware autoJson() {
+ return next -> (req, res) -> {
+ Object out = next.handle(req, res);
+ if (isPassThrough(out)) return out;
+
+ res.type(ContentType.JSON);
+ try {
+ return mapper.writeValueAsBytes(out);
+ } catch (JsonProcessingException e) {
+ throw new IllegalStateException(
+ "Failed to serialize handler result as JSON: " + out.getClass().getName(), e);
+ }
+ };
+ }
+
+ private static boolean isPassThrough(Object out) {
+ return out == null
+ || out instanceof Response
+ || out instanceof byte[]
+ || out instanceof CharSequence;
+ }
+}
diff --git a/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/ext/jackson/JacksonExtensionTest.java b/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/ext/jackson/JacksonExtensionTest.java
new file mode 100644
index 0000000..39abbc9
--- /dev/null
+++ b/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/ext/jackson/JacksonExtensionTest.java
@@ -0,0 +1,60 @@
+package dev.relism.ext.jackson;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.relism.extension.FlashContext;
+import dev.relism.http.ContentType;
+import dev.relism.models.Request;
+import dev.relism.models.RequestHandler;
+import dev.relism.models.Response;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class JacksonExtensionTest {
+
+ @Test
+ void provide_registers_json_mapper_and_middleware() {
+ FlashContext ctx = new FlashContext();
+ ObjectMapper mapper = new ObjectMapper();
+ JacksonExtension ext = new JacksonExtension(mapper);
+
+ ext.provide(ctx);
+
+ assertNotNull(ctx.require(Json.class));
+ assertNotNull(ctx.require(JacksonMiddleware.class));
+ assertSame(mapper, ctx.require(ObjectMapper.class));
+ }
+
+ @Test
+ void autoJson_factory_delegates_to_middleware_policy() throws Exception {
+ ObjectMapper mapper = new ObjectMapper();
+ JacksonExtension ext = new JacksonExtension(mapper);
+ RequestHandler next = new RequestHandler() {
+ @Override
+ public Object handle(Request request, Response response) {
+ return new Payload("ok");
+ }
+ };
+ RequestHandler wrapped = new RequestHandler() {
+ private final dev.relism.models.SimpleHandler.FunctionalHandler delegate = ext.autoJson().wrap(next);
+
+ @Override
+ public Object handle(Request request, Response response) throws Exception {
+ return delegate.handle(request, response);
+ }
+ };
+
+ Response res = new Response(200, ContentType.TEXT_PLAIN);
+ Object out = wrapped.handle(null, res);
+
+ assertTrue(out instanceof byte[]);
+ assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
+ }
+
+ private record Payload(String status) {}
+}
diff --git a/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/ext/jackson/JacksonMiddlewareTest.java b/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/ext/jackson/JacksonMiddlewareTest.java
new file mode 100644
index 0000000..dc7c456
--- /dev/null
+++ b/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/ext/jackson/JacksonMiddlewareTest.java
@@ -0,0 +1,89 @@
+package dev.relism.ext.jackson;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.relism.http.ContentType;
+import dev.relism.models.Request;
+import dev.relism.models.RequestHandler;
+import dev.relism.models.Response;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class JacksonMiddlewareTest {
+
+ private static final Request REQ = null;
+
+ @Test
+ void autoJson_marshalsPojo_toJsonBytes_and_setsJsonContentType() throws Exception {
+ JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
+ RequestHandler wrapped = wrap(mw, new UserDto("u1", "alice"));
+ Response res = new Response(200, ContentType.TEXT_PLAIN);
+
+ Object out = wrapped.handle(REQ, res);
+
+ assertInstanceOf(byte[].class, out);
+ assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
+ assertEquals("{\"id\":\"u1\",\"name\":\"alice\"}", new String((byte[]) out, StandardCharsets.UTF_8));
+ }
+
+ @Test
+ void autoJson_passThrough_for_response_string_charSequence_bytes_and_null() throws Exception {
+ JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
+
+ Response payloadResponse = new Response(201, ContentType.TEXT_PLAIN).body("ok");
+ RequestHandler wrappedResponse = wrap(mw, payloadResponse);
+ Response res = new Response(200, ContentType.TEXT_PLAIN);
+ assertSame(payloadResponse, wrappedResponse.handle(REQ, res));
+
+ String s = "hello";
+ assertSame(s, wrap(mw, s).handle(REQ, res));
+
+ CharSequence cs = new StringBuilder("hello-cs");
+ assertSame(cs, wrap(mw, cs).handle(REQ, res));
+
+ byte[] bytes = new byte[]{1, 2, 3};
+ assertSame(bytes, wrap(mw, bytes).handle(REQ, res));
+
+ assertSame(null, wrap(mw, null).handle(REQ, res));
+ }
+
+ @Test
+ void autoJson_wraps_serialization_errors_as_illegal_state() {
+ JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
+ RequestHandler wrapped = wrap(mw, new CyclicDto());
+ Response res = new Response(200, ContentType.TEXT_PLAIN);
+
+ IllegalStateException ex = assertThrows(IllegalStateException.class, () -> wrapped.handle(REQ, res));
+ assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
+ assertTrue(ex.getMessage().startsWith("Failed to serialize handler result as JSON:"));
+ }
+
+ private static RequestHandler wrap(JacksonMiddleware mw, Object fixedReturn) {
+ RequestHandler next = new RequestHandler() {
+ @Override
+ public Object handle(Request request, Response response) {
+ return fixedReturn;
+ }
+ };
+ return new RequestHandler() {
+ private final dev.relism.models.SimpleHandler.FunctionalHandler delegate = mw.autoJson().wrap(next);
+
+ @Override
+ public Object handle(Request request, Response response) throws Exception {
+ return delegate.handle(request, response);
+ }
+ };
+ }
+
+ private record UserDto(String id, String name) {}
+
+ private static final class CyclicDto {
+ CyclicDto self = this;
+ }
+}
diff --git a/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/ext/jackson/JsonTest.java b/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/ext/jackson/JsonTest.java
new file mode 100644
index 0000000..350c30e
--- /dev/null
+++ b/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/ext/jackson/JsonTest.java
@@ -0,0 +1,116 @@
+package dev.relism.ext.jackson;
+
+import com.fasterxml.jackson.annotation.JsonView;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.relism.exceptions.HttpException;
+import dev.relism.http.ContentType;
+import dev.relism.http.HttpMethod;
+import dev.relism.models.HeaderMap;
+import dev.relism.models.Request;
+import dev.relism.models.RequestLine;
+import dev.relism.models.Response;
+import dev.relism.routing.routers.fastpathrouter.FastPathViews;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class JsonTest {
+
+ @Test
+ void body_parses_valid_json_and_maps_bad_payload_to_http_400() throws Exception {
+ Json json = new Json(new ObjectMapper());
+
+ Request ok = request("{\"id\":\"u1\",\"name\":\"alice\"}");
+ UserDto dto = json.body(ok, UserDto.class);
+ assertEquals("u1", dto.id);
+ assertEquals("alice", dto.name);
+
+ Request bad = request("not-json");
+ HttpException ex = assertThrows(HttpException.class, () -> json.body(bad, UserDto.class));
+ assertEquals(400, ex.status());
+ assertTrue(ex.getMessage().startsWith("Invalid request body:"));
+ }
+
+ @Test
+ void bodyFrom_parses_stream_and_maps_bad_payload_to_http_400() throws Exception {
+ Json json = new Json(new ObjectMapper());
+
+ Request ok = request("{\"id\":\"u2\",\"name\":\"bob\"}");
+ UserDto dto = json.bodyFrom(ok, UserDto.class);
+ assertEquals("u2", dto.id);
+ assertEquals("bob", dto.name);
+
+ Request bad = request("[");
+ HttpException ex = assertThrows(HttpException.class, () -> json.bodyFrom(bad, UserDto.class));
+ assertEquals(400, ex.status());
+ }
+
+ @Test
+ void write_and_writeView_set_content_type_and_render_expected_payload() throws Exception {
+ Json json = new Json(new ObjectMapper());
+ Response res = new Response(200, ContentType.TEXT_PLAIN);
+
+ String payload = json.write(res, new UserDto("u3", "carol"));
+ assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
+ assertEquals("{\"id\":\"u3\",\"name\":\"carol\"}", payload);
+
+ Response viewRes = new Response(200, ContentType.TEXT_PLAIN);
+ String viewed = json.writeView(viewRes, new ViewDto("u4", "hidden"), PublicView.class);
+ assertEquals("application/json", new String(viewRes.getContentType(), StandardCharsets.UTF_8));
+ assertEquals("{\"id\":\"u4\"}", viewed);
+ }
+
+ @Test
+ void mapper_returns_underlying_object_mapper_instance() {
+ ObjectMapper mapper = new ObjectMapper();
+ Json json = new Json(mapper);
+ assertSame(mapper, json.mapper());
+ }
+
+ private static Request request(String body) {
+ return request(body.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static Request request(byte[] body) {
+ RequestLine line = new RequestLine(
+ HttpMethod.POST,
+ new FastPathViews.StringByteView("/json"),
+ null,
+ new FastPathViews.StringByteView("HTTP/1.1"),
+ new HeaderMap()
+ );
+ return new Request(line, body);
+ }
+
+ private static final class UserDto {
+ public String id;
+ public String name;
+
+ public UserDto() {}
+
+ private UserDto(String id, String name) {
+ this.id = id;
+ this.name = name;
+ }
+ }
+
+ private interface PublicView {}
+ private interface InternalView {}
+
+ private static final class ViewDto {
+ @JsonView(PublicView.class)
+ public String id;
+ @JsonView(InternalView.class)
+ public String secret;
+
+ private ViewDto(String id, String secret) {
+ this.id = id;
+ this.secret = secret;
+ }
+ }
+}
diff --git a/flash-extensions/flash-ext-limiter/pom.xml b/flash-extensions/flash-ext-limiter/pom.xml
index 534cc87..f339e5a 100644
--- a/flash-extensions/flash-ext-limiter/pom.xml
+++ b/flash-extensions/flash-ext-limiter/pom.xml
@@ -7,7 +7,7 @@
dev.relism
flash-extensions
- 1.0-SNAPSHOT
+ 1.1-indev5
flash-ext-limiter
diff --git a/flash-extensions/flash-ext-oidc/pom.xml b/flash-extensions/flash-ext-oidc/pom.xml
index c705d1c..c862e3b 100644
--- a/flash-extensions/flash-ext-oidc/pom.xml
+++ b/flash-extensions/flash-ext-oidc/pom.xml
@@ -7,7 +7,7 @@
dev.relism
flash-extensions
- 1.0-SNAPSHOT
+ 1.1-indev5
flash-ext-oidc
diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcExtension.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcExtension.java
index 940fbd0..a019bf1 100644
--- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcExtension.java
+++ b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcExtension.java
@@ -295,7 +295,41 @@ public class OidcExtension implements FlashExtension {
public java.util.List requiredFor(Class> handlerClass) {
return OidcAuthPolicy.openApiScopesFor(handlerClass);
}
+
+ @Override
+ public java.util.Map autoResponsesFor(Class> handlerClass) {
+ OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
+ if (policy == null || policy.optionalAuth()) return java.util.Map.of();
+
+ java.util.LinkedHashMap out = new java.util.LinkedHashMap<>();
+ out.put(401, "Authentication required");
+
+ String[] roles = policy.requiredRoles();
+ String[] scopes = policy.requiredScopes();
+ if (roles.length == 0 && scopes.length == 0) return out;
+
+ String roleMessage = roles.length == 0 ? null : roleRequiredMessage(roles);
+ String scopeMessage = scopes.length == 0 ? null : scopeRequiredMessage(scopes);
+ if (roleMessage != null && scopeMessage != null) {
+ out.put(403, roleMessage + "; " + scopeMessage);
+ } else if (roleMessage != null) {
+ out.put(403, roleMessage);
+ } else {
+ out.put(403, scopeMessage);
+ }
+ return out;
+ }
}));
}
+
+ private static String roleRequiredMessage(String[] roles) {
+ if (roles.length == 1) return "\"" + roles[0] + "\" role required";
+ return "Roles \"" + String.join(", ", roles) + "\" are required";
+ }
+
+ private static String scopeRequiredMessage(String[] scopes) {
+ if (scopes.length == 1) return "\"" + scopes[0] + "\" scope required";
+ return "Scopes \"" + String.join(", ", scopes) + "\" are required";
+ }
}
}
diff --git a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcOpenApiInteropTest.java b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcOpenApiInteropTest.java
new file mode 100644
index 0000000..1035d51
--- /dev/null
+++ b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcOpenApiInteropTest.java
@@ -0,0 +1,101 @@
+package dev.relism.ext.oidc;
+
+import dev.relism.ext.openapi.OpenApiSecurityContributor;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Method;
+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.assertTrue;
+
+class OidcOpenApiInteropTest {
+
+ @Authenticated
+ static class AuthOnly {}
+
+ @Authenticated(optional = true)
+ static class AuthOptional {}
+
+ @RolesAllowed("admin")
+ static class OneRole {}
+
+ @RolesAllowed({"admin", "operator"})
+ static class MultiRole {}
+
+ @ScopesAllowed("orders:write")
+ static class OneScope {}
+
+ @ScopesAllowed({"orders:write", "payments:write"})
+ static class MultiScope {}
+
+ @RolesAllowed("admin")
+ @ScopesAllowed("orders:write")
+ static class RoleAndScope {}
+
+ @Test
+ void autoResponses_authOnly() throws Exception {
+ Map responses = contributor().autoResponsesFor(AuthOnly.class);
+ assertEquals("Authentication required", responses.get(401));
+ assertFalse(responses.containsKey(403));
+ }
+
+ @Test
+ void autoResponses_optionalAuth_addsNothing() throws Exception {
+ Map responses = contributor().autoResponsesFor(AuthOptional.class);
+ assertTrue(responses.isEmpty());
+ }
+
+ @Test
+ void autoResponses_oneRole_formatsSingular() throws Exception {
+ Map responses = contributor().autoResponsesFor(OneRole.class);
+ assertEquals("Authentication required", responses.get(401));
+ assertEquals("\"admin\" role required", responses.get(403));
+ }
+
+ @Test
+ void autoResponses_multiRoles_formatsPlural() throws Exception {
+ Map responses = contributor().autoResponsesFor(MultiRole.class);
+ assertEquals("Roles \"admin, operator\" are required", responses.get(403));
+ }
+
+ @Test
+ void autoResponses_oneScope_formatsSingular() throws Exception {
+ Map responses = contributor().autoResponsesFor(OneScope.class);
+ assertEquals("\"orders:write\" scope required", responses.get(403));
+ }
+
+ @Test
+ void autoResponses_multiScopes_formatsPlural() throws Exception {
+ Map responses = contributor().autoResponsesFor(MultiScope.class);
+ assertEquals("Scopes \"orders:write, payments:write\" are required", responses.get(403));
+ }
+
+ @Test
+ void autoResponses_roleAndScope_combinesMessages() throws Exception {
+ Map responses = contributor().autoResponsesFor(RoleAndScope.class);
+ assertEquals("\"admin\" role required; \"orders:write\" scope required", responses.get(403));
+ }
+
+ private static OpenApiSecurityContributor contributor() throws Exception {
+ Class> clazz = Class.forName("dev.relism.ext.oidc.OidcExtension$OpenApiIntegration");
+ Constructor> ctor = clazz.getDeclaredConstructor();
+ ctor.setAccessible(true);
+ Object instance = ctor.newInstance();
+
+ Method m = clazz.getDeclaredMethod("register", dev.relism.extension.FlashContext.class, OidcConfig.class, OidcProviderMetadata.class);
+ m.setAccessible(true);
+
+ dev.relism.extension.FlashContext ctx = new dev.relism.extension.FlashContext();
+ dev.relism.ext.openapi.OpenApiSecurityRegistry registry = new dev.relism.ext.openapi.OpenApiSecurityRegistry();
+ ctx.provide(dev.relism.ext.openapi.OpenApiSecurityRegistry.class, registry);
+
+ OidcConfig config = OidcConfig.builder("https://issuer", "c", "s", "/cb").build();
+ OidcProviderMetadata meta = new OidcProviderMetadata("a", "t", "u", "j", "e");
+ m.invoke(instance, ctx, config, meta);
+
+ return registry.contributors().getFirst();
+ }
+}
diff --git a/flash-extensions/flash-ext-openapi/README.md b/flash-extensions/flash-ext-openapi/README.md
index 25f95c3..fe096ae 100644
--- a/flash-extensions/flash-ext-openapi/README.md
+++ b/flash-extensions/flash-ext-openapi/README.md
@@ -1,161 +1,126 @@
# flash-ext-openapi
-OpenAPI 3.0 spec generation and Swagger UI for the Flash HTTP server.
+OpenAPI 3.0.3 generation + Swagger UI for Flash.
## What it provides
| Route | Description |
|---|---|
-| `GET /openapi.json` | OpenAPI 3.0.3 spec as JSON |
-| `GET /openapi.yaml` | OpenAPI 3.0.3 spec as YAML |
-| `GET /openapi/swagger` | Swagger UI (loaded from unpkg CDN) |
+| `GET /openapi.json` | OpenAPI spec JSON |
+| `GET /openapi.yaml` | OpenAPI spec YAML |
+| `GET /openapi/swagger` | Swagger UI |
-The base path is configurable. Operations are collected automatically at handler-registration time
-from class-based handlers annotated with `@ApiOperation`.
-
-## Dependencies
-
-Requires `flash-ext-jackson` (shares its `ObjectMapper` from context).
-If `flash-ext-oidc` is also installed, OIDC security schemes are injected automatically.
-Install order is irrelevant — the two-phase extension model handles dependency ordering.
-
-```xml
-
- dev.relism
- flash-ext-openapi
- 1.0-SNAPSHOT
-
-```
-
-## Installation
+## Install
```java
FlashApp.create(8080)
.install(new JacksonExtension())
- .install(new OpenApiExtension("/openapi", "My API", "2.0.0", "Optional description"))
- .register(new MyHandler());
+ .install(new OpenApiExtension("/openapi", "My API", "1.0.0"))
+ .scan("com.acme.handlers")
+ .startAndBlock();
```
-### Constructors
+## Operation annotation
```java
-new OpenApiExtension() // base path: /openapi, title: API, version: 1.0.0
-new OpenApiExtension("/docs") // custom base path
-new OpenApiExtension("/docs", "My API", "2.0.0") // title + version
-new OpenApiExtension("/docs", "My API", "2.0.0", "desc") // full
-```
-
-## Annotating handlers
-
-All annotations target the **handler class** (`@Target(ElementType.TYPE)`).
-
-### @ApiOperation
-
-```java
-@GET("/api/blogs")
-@ApiOperation(
- summary = "List all blogs",
- description = "Returns a paginated list of published blog posts.",
- tags = {"blogs"},
- operationId = "listBlogs",
- deprecated = false
+@GET("/users/{id}")
+@ApiOperation(summary = "Get user", description = "Returns one user", tags = {"users"})
+@Parameter(name = "expand", in = ParameterIn.QUERY, type = SchemaType.STRING, examples = {"roles", "permissions"})
+@APIResponse(
+ responseCode = "200",
+ description = "User found",
+ content = @Content(contentType = ContentType.JSON, schema = UserDto.class)
)
-public class ListBlogs extends JacksonHandler { ... }
+public final class GetUser extends RequestHandler { ... }
```
-| Field | Default | Description |
-|---|---|---|
-| `summary` | `""` | Short one-liner shown in the operation title |
-| `description` | `""` | Longer Markdown description |
-| `tags` | `{}` | Groups operations in the Swagger UI sidebar |
-| `operationId` | `""` | Unique machine-readable ID |
-| `deprecated` | `false` | Marks the operation with a strikethrough |
+## Response patterns
-### @ApiResponse
-
-Repeatable — annotate as many status codes as the handler can return.
+### Single object
```java
-@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 { ... }
+@APIResponse(
+ responseCode = "200",
+ description = "User found",
+ content = @Content(contentType = ContentType.JSON, schema = UserDto.class)
+)
```
-`schema` references `#/components/schemas/` — you are responsible for populating
-`components.schemas` if you need full model documentation (not yet auto-generated).
-
-`@ApiResponse` is repeatable. The container `@ApiResponses({ @ApiResponse(...), ... })` is also available.
-
-### @ApiParam
-
-Repeatable — declare query, path, header, or cookie parameters explicitly.
+### Array
```java
-@ApiParam(name = "limit", in = "query", type = "integer", description = "Max results (default 20)")
-@ApiParam(name = "offset", in = "query", type = "integer", description = "Pagination offset")
-@ApiParam(name = "slug", in = "path", type = "string", required = true)
-@ApiParam(name = "X-Trace-Id", in = "header", type = "string")
-public class GetBlog extends JacksonHandler { ... }
+@APIResponse(
+ responseCode = "200",
+ description = "Users listed",
+ content = @Content(contentType = ContentType.JSON, schema = UserDto.class, array = true)
+)
```
-> Path parameters in the route (e.g. `@GET("/blogs/{id}")` or `@Route(path = "/blogs/{id}")`) are extracted automatically
-> as required path parameters — you only need `@ApiParam` for query / header / cookie params.
-
-`@ApiParam` is repeatable. If you prefer grouping them, `@ApiParams({ @ApiParam(...), @ApiParam(...) })` is
-the container annotation.
-
-| Field | Default | Description |
-|---|---|---|
-| `name` | — | Parameter name |
-| `in` | `"query"` | Location: `"query"`, `"path"`, `"header"`, `"cookie"` |
-| `type` | `"string"` | OpenAPI primitive: `"string"`, `"integer"`, `"number"`, `"boolean"` |
-| `description` | `""` | Human-readable description |
-| `required` | `false` | Whether the parameter is mandatory |
-| `example` | `""` | Inline example value shown in Swagger UI |
-
-## Security integration
-
-`flash-ext-openapi` defines the `OpenApiSecurityContributor` / `OpenApiSecurityRegistry` contracts.
-Security extensions (e.g. `flash-ext-oidc`) register a contributor at install time; the spec
-builder picks it up automatically — no coupling between extensions.
-
-### How it works
-
-1. `OpenApiExtension` creates an `OpenApiSecurityRegistry` and exposes it in the `FlashContext`.
-2. `flash-ext-oidc` calls `ctx.find(OpenApiSecurityRegistry.class)` and registers its contributor.
-3. At spec build time, `OpenApiBuilder` iterates contributors and injects `security` entries on each
- operation whose handler class carries `@Authenticated` or `@RolesAllowed`.
-
-### Implementing a custom contributor
+### No content
```java
-public class MyAuthContributor implements OpenApiSecurityContributor {
+@APIResponse(
+ responseCode = "204",
+ description = "Deleted",
+ content = @Content(contentType = ContentType.NONE)
+)
+```
- @Override
- public String schemeName() { return "myScheme"; }
+### Inferred from handler return type
- @Override
- public Map schemeDefinition() {
- return Map.of("type", "apiKey", "in", "header", "name", "X-API-Key");
- }
+```java
+@APIResponse(
+ responseCode = "200",
+ content = @Content
+)
+```
- @Override
- public List requiredFor(Class> handlerClass) {
- if (handlerClass.isAnnotationPresent(MyAuth.class)) return List.of();
- return null; // not secured by this contributor
- }
+If `content.schema` is omitted, schema is inferred from the handler `handle(...)` return type.
+Explicit `content.schema` always wins over inference.
+
+Inference defaults:
+
+- `UserDto` -> object schema for `UserDto`
+- `List` / `Set` / `UserDto[]` -> `array` with `items: UserDto`
+- `Map` -> `object` with `additionalProperties: UserDto`
+
+## DTO schema metadata
+
+```java
+@Schema(name = "User", title = "User DTO", description = "Public user", deprecated = false)
+public class UserDto {
+
+ @SchemaProperty(title = "ID", required = true, example = "USR-100", enumeration = {"USR-100", "USR-101"})
+ public String id;
+
+ @SchemaProperty(hidden = true)
+ public String internalDebug;
}
-
-// Register during extension install:
-ctx.find(OpenApiSecurityRegistry.class)
- .ifPresent(r -> r.add(new MyAuthContributor()));
```
-Return values from `requiredFor`:
+Supported field-level exclusion:
-| Return | Meaning |
-|---|---|
-| `null` | Handler is not secured by this contributor — skip |
-| `List.of()` | Requires authentication, no specific scopes |
-| `List.of("admin", "user")` | Requires one of these scopes (OpenAPI OR semantics) |
+- `@Schema(hidden = true)` / `@SchemaProperty(hidden = true)`
+- `@JsonIgnore`
+- `@JsonIgnoreProperties(...)`
+- `transient` / `static`
+
+## OIDC interop
+
+When `flash-ext-oidc` is installed, OpenAPI integrates automatically:
+
+- security scheme under `components.securitySchemes`
+- per-operation `security`
+- auto responses (class-based handlers):
+ - `401 Authentication required`
+ - `403` role/scope required messages when applicable
+
+Manual `@APIResponse` for the same status code always wins.
+
+## Notes
+
+- Operations are collected from final boot-time routes for class-based handlers with `@ApiOperation`.
+- Documented paths always match runtime paths (including scope namespaces/prefixes/rewrites).
+- Route path params are auto-discovered from `/{id}`.
+- Parameter annotations are mainly for query/header/cookie enrichment.
+- Output responses are sorted by numeric status code.
diff --git a/flash-extensions/flash-ext-openapi/pom.xml b/flash-extensions/flash-ext-openapi/pom.xml
index aa85872..1e4dbf1 100644
--- a/flash-extensions/flash-ext-openapi/pom.xml
+++ b/flash-extensions/flash-ext-openapi/pom.xml
@@ -7,11 +7,15 @@
dev.relism
flash-extensions
- 1.0-SNAPSHOT
+ 1.1-indev5
flash-ext-openapi
+
+ 0.8.12
+
+
dev.relism
@@ -31,4 +35,44 @@
+
+
+
+ org.jacoco
+ jacoco-maven-plugin
+ ${jacoco.version}
+
+
+ jacoco-prepare-agent
+
+ prepare-agent
+
+
+
+ jacoco-report-and-check
+ verify
+
+ report
+ check
+
+
+
+
+ BUNDLE
+
+
+ LINE
+ COVEREDRATIO
+ 0.80
+
+
+
+
+
+
+
+
+
+
+
diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiParam.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiParam.java
deleted file mode 100644
index e6618f9..0000000
--- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiParam.java
+++ /dev/null
@@ -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.
- *
- * {@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 { ... }
- * }
- */
-@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 "";
-}
diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiResponse.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiResponse.java
index 773506b..0ab1cc7 100644
--- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiResponse.java
+++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiResponse.java
@@ -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.
- *
- * {@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 { ... }
- * }
+ * 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;
}
diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiResponses.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiResponses.java
index 6182e1b..3920120 100644
--- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiResponses.java
+++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiResponses.java
@@ -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();
}
diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/Content.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/Content.java
new file mode 100644
index 0000000..de4ee23
--- /dev/null
+++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/Content.java
@@ -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;
+}
diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiBuilder.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiBuilder.java
index 4b7bd26..76f278b 100644
--- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiBuilder.java
+++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiBuilder.java
@@ -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>> 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 cachedSpec;
@@ -87,7 +112,7 @@ public final class OpenApiBuilder {
}
Map 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 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 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 op, Class> cls) {
- ApiResponse[] anns = cls.getAnnotationsByType(ApiResponse.class);
- Map responses = new LinkedHashMap<>();
+ APIResponse[] anns = cls.getAnnotationsByType(APIResponse.class);
+ Map> 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 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 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 securityContributors() {
+ return securityRegistry != null ? securityRegistry.contributors() : List.of();
+ }
+
+ private Map buildAnnotatedResponse(int code, APIResponse ann, Class> handlerClass) {
+ Map 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 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 resolveResponseSchema(Content content, Class> handlerClass) {
+ if (content.schema() != Void.class) {
+ Map 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 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 asArraySchema(Map 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 toExamples(String[] examples) {
+ Map 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