fix(ext-openapi): an answer's media type is the handler's, not the body's

@Consumes says what a route reads. It was also deciding what the document said
a route answers, through a JSON default nothing could override: a handler that
takes a JSON body is not thereby a handler that answers JSON.

@Produces now says that, beside @Consumes and as descriptive as it is — on the
handler, or once on a base class. Every response takes its media type from it,
JSON when nothing declares one, and the error object stays JSON because that is
what Flash answers a failure with whatever the route produces.

Content loses contentType with it. One handler answers in one format and a
status code does not change that, so the media type was in the wrong place; a
response with no schema and no return type to infer one from is a response
with no body, which is what a 204 was using it to say.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-09-23 14:41:56 +00:00
co-authored by Claude Opus 5
parent 2f06ca7c1d
commit 60dd4eab6a
6 changed files with 82 additions and 19 deletions
@@ -33,7 +33,7 @@ class ConstraintsInTheDocumentTest {
@GET("/accounts") @GET("/accounts")
@ApiOperation(summary = "List accounts") @ApiOperation(summary = "List accounts")
@APIResponse(responseCode = "200", content = @Content(contentType = ContentType.JSON, schema = Account.class)) @APIResponse(responseCode = "200", content = @Content(schema = Account.class))
public static class ListAccounts extends RequestHandler { public static class ListAccounts extends RequestHandler {
@Override public Object handle(Request request, Response response) { @Override public Object handle(Request request, Response response) {
return new Account("alice", "a@b.com", 30); return new Account("alice", "a@b.com", 30);
+11 -5
View File
@@ -26,6 +26,8 @@ Every class-based route is documented, annotated or not. Read off the code:
- **path parameters**, from `/{id}` in the route - **path parameters**, from `/{id}` in the route
- **the request body**, from the handler's own body type (see below) - **the request body**, from the handler's own body type (see below)
- **the response schema**, from what `handle` returns — an object, a `List<T>`, a `Map<String, T>` - **the response schema**, from what `handle` returns — an object, a `List<T>`, a `Map<String, T>`
- **the media types**: `@Consumes` for what it reads, `@Produces` for what it answers, which are
two different questions — taking a JSON body does not make the answer JSON
- **error responses**, in the one shape Flash answers failures with: `{"error": "...", "status": 404}` - **error responses**, in the one shape Flash answers failures with: `{"error": "...", "status": 404}`
- **security and rate limiting**, from the extensions that enforce them - **security and rate limiting**, from the extensions that enforce them
@@ -53,8 +55,8 @@ requestBody:
schema: { $ref: '#/components/schemas/NewUser' } schema: { $ref: '#/components/schemas/NewUser' }
``` ```
The media type comes from `@Consumes` on the base class, so an XML handler documents itself as The media type of the body comes from `@Consumes` on the base class, so an XML handler documents
XML without a word from the route. itself as XML without a word from the route.
For a handler that reads the body by hand, or to describe it as something else, declare it: For a handler that reads the body by hand, or to describe it as something else, declare it:
@@ -83,13 +85,17 @@ The success response is inferred. Declare one only to say more:
@APIResponse(responseCode = "200", description = "User found", @APIResponse(responseCode = "200", description = "User found",
content = @Content(schema = UserDto.class, example = "{\"id\":\"usr-1\"}")) content = @Content(schema = UserDto.class, example = "{\"id\":\"usr-1\"}"))
@APIResponse(responseCode = "409", description = "That email is taken") @APIResponse(responseCode = "409", description = "That email is taken")
@APIResponse(responseCode = "204", content = @Content(contentType = ContentType.NONE)) @APIResponse(responseCode = "204", description = "Deleted")
``` ```
- `content.schema` omitted on a 2xx: the handler's return type. - `content.schema` omitted on a 2xx: the handler's return type.
- Any 4xx or 5xx without an explicit schema: Flash's error object, referenced from `components`. - Any 4xx or 5xx without an explicit schema: Flash's error object, referenced from `components`
in JSON, which is what Flash answers a failure with whatever the route produces.
- `content.array = true` wraps whichever schema was chosen. - `content.array = true` wraps whichever schema was chosen.
- `contentType = NONE` documents a response with no body. - No schema and nothing to infer one from: a response with no body, which is what a 204 is.
The media type of every answer is the handler's `@Produces`, JSON when nothing says otherwise. It
is not on `@Content`: one handler answers in one format, and a status code does not change that.
**A response several operations share is written once.** Identical answers — the 401 of every **A response several operations share is written once.** Identical answers — the 401 of every
guarded route, the 429 of every limited one — become `components.responses` entries referenced by guarded route, the 429 of every limited one — become `components.responses` entries referenced by
@@ -1,19 +1,20 @@
package dev.relism.flash.ext.openapi; package dev.relism.flash.ext.openapi;
import dev.relism.flash.http.ContentType;
import java.lang.annotation.ElementType; import java.lang.annotation.ElementType;
import java.lang.annotation.Retention; import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy; import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target; import java.lang.annotation.Target;
/** /**
* OpenAPI response content descriptor. * What a response carries: the schema, and an example of it.
*
* <p>The media type is not here — it is the handler's, declared with
* {@link dev.relism.flash.routing.Produces @Produces} and JSON when nothing says otherwise. A
* response with no schema, and no return type to infer one from, is documented without a body.
*/ */
@Retention(RetentionPolicy.RUNTIME) @Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD}) @Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD})
public @interface Content { public @interface Content {
ContentType contentType() default ContentType.JSON;
Class<?> schema() default Void.class; Class<?> schema() default Void.class;
boolean array() default false; boolean array() default false;
@@ -5,6 +5,7 @@ import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.http.HttpStatus; import dev.relism.flash.http.HttpStatus;
import dev.relism.flash.models.BodyHandler; import dev.relism.flash.models.BodyHandler;
import dev.relism.flash.routing.Consumes; import dev.relism.flash.routing.Consumes;
import dev.relism.flash.routing.Produces;
import dev.relism.flash.routing.Route; import dev.relism.flash.routing.Route;
import java.lang.annotation.Annotation; import java.lang.annotation.Annotation;
@@ -230,15 +231,13 @@ public final class OpenApiBuilder {
response.put("description", declared.description().isEmpty() ? reasonFor(status) : declared.description()); response.put("description", declared.description().isEmpty() ? reasonFor(status) : declared.description());
Content content = declared.content(); Content content = declared.content();
if (content.contentType() == ContentType.NONE) return response;
Map<String, Object> schema = schemaFor(content, status, handlerClass); Map<String, Object> schema = schemaFor(content, status, handlerClass);
if (schema == null) return response; if (schema == null) return response; // nothing to describe: a response with no body
Map<String, Object> media = new LinkedHashMap<>(); Map<String, Object> media = new LinkedHashMap<>();
media.put("schema", schema); media.put("schema", schema);
if (!content.example().isEmpty()) media.put("example", content.example()); if (!content.example().isEmpty()) media.put("example", content.example());
response.put("content", Map.of(mediaTypeOf(content.contentType()), media)); response.put("content", Map.of(mediaTypeOf(producedBy(handlerClass)), media));
return response; return response;
} }
@@ -259,10 +258,21 @@ public final class OpenApiBuilder {
private Map<String, Object> inferredResponse(Class<?> handlerClass) { private Map<String, Object> inferredResponse(Class<?> handlerClass) {
Map<String, Object> response = new LinkedHashMap<>(Map.of("description", reasonFor(200))); Map<String, Object> response = new LinkedHashMap<>(Map.of("description", reasonFor(200)));
Map<String, Object> schema = returnSchema(handlerClass); Map<String, Object> schema = returnSchema(handlerClass);
if (schema != null) response.put("content", Map.of(mediaTypeOf(ContentType.JSON), Map.of("schema", schema))); if (schema != null) {
response.put("content", Map.of(mediaTypeOf(producedBy(handlerClass)), Map.of("schema", schema)));
}
return response; return response;
} }
/**
* What this handler answers in. {@code @Consumes} says what it reads, which is a different
* question: taking a JSON body does not make the answer JSON.
*/
private static ContentType producedBy(Class<?> handlerClass) {
Produces produces = handlerClass.getAnnotation(Produces.class);
return produces == null ? ContentType.JSON : produces.value();
}
/** The schema of whatever {@code handle} gives back, or null when it says nothing useful. */ /** The schema of whatever {@code handle} gives back, or null when it says nothing useful. */
private Map<String, Object> returnSchema(Class<?> handlerClass) { private Map<String, Object> returnSchema(Class<?> handlerClass) {
Type returned = returnTypeOf(handlerClass); Type returned = returnTypeOf(handlerClass);
@@ -336,7 +346,7 @@ public final class OpenApiBuilder {
// A status a contributor added is a failure Flash answers in its own shape. // A status a contributor added is a failure Flash answers in its own shape.
if (status >= 400 && !response.containsKey("content")) { if (status >= 400 && !response.containsKey("content")) {
response.put("content", Map.of(mediaTypeOf(ContentType.JSON), Map.of("schema", errorSchema()))); response.put("content", Map.of(mediaTypeOf(ContentType.JSON), Map.of("schema", errorSchema())));
} } // Flash answers a failure in JSON whatever the route produces
} }
private void applyContributorSecurity(Map<String, Object> operation, Class<?> handlerClass, private void applyContributorSecurity(Map<String, Object> operation, Class<?> handlerClass,
@@ -23,7 +23,7 @@ class OpenApiBuilderTest {
@GET("/users/{id}") @GET("/users/{id}")
@ApiOperation(summary = "Get user") @ApiOperation(summary = "Get user")
@Parameter(name = "expand", in = ParameterIn.QUERY, required = false, type = SchemaType.STRING, examples = {"roles", "permissions"}) @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)) @APIResponse(responseCode = "200", description = "User found", content = @Content(schema = UserDto.class))
static class GetUserHandler extends RequestHandler { static class GetUserHandler extends RequestHandler {
@Override @Override
public Object handle(Request request, Response response) { public Object handle(Request request, Response response) {
@@ -33,7 +33,7 @@ class OpenApiBuilderTest {
@GET("/users") @GET("/users")
@ApiOperation(summary = "List users") @ApiOperation(summary = "List users")
@APIResponse(responseCode = "200", content = @Content(contentType = ContentType.JSON, schema = UserDto.class, array = true)) @APIResponse(responseCode = "200", content = @Content(schema = UserDto.class, array = true))
static class ListUsersHandler extends RequestHandler { static class ListUsersHandler extends RequestHandler {
@Override @Override
public Object handle(Request request, Response response) { public Object handle(Request request, Response response) {
@@ -43,7 +43,7 @@ class OpenApiBuilderTest {
@GET("/ping") @GET("/ping")
@ApiOperation(summary = "Ping") @ApiOperation(summary = "Ping")
@APIResponse(responseCode = "204", description = "No content", content = @Content(contentType = ContentType.NONE)) @APIResponse(responseCode = "204", description = "No content")
static class PingHandler extends RequestHandler { static class PingHandler extends RequestHandler {
@Override @Override
public Object handle(Request request, Response response) { public Object handle(Request request, Response response) {
@@ -169,6 +169,26 @@ class OpenApiBuilderTest {
@Override public Object handle(Request request, Response response) { return null; } @Override public Object handle(Request request, Response response) { return null; }
} }
@GET("/xml")
@dev.relism.flash.routing.Produces(ContentType.XML)
@ApiOperation(summary = "Answers XML")
static class XmlAnswerHandler extends RequestHandler {
@Override public UserDto handle(Request request, Response response) { return null; }
}
@Test
void the_media_type_of_an_answer_is_the_handler_s_own() {
OpenApiBuilder b = new OpenApiBuilder();
b.addOperation(OpenApiBuilder.routeOf(XmlAnswerHandler.class), XmlAnswerHandler.class.getAnnotation(ApiOperation.class), XmlAnswerHandler.class);
Map<String, Object> get = getOperation(b.build(), "/xml", "get");
Map<String, Object> responses = cast(get.get("responses"));
Map<String, Object> ok = cast(responses.get("200"));
Map<String, Object> content = cast(ok.get("content"));
assertTrue(content.containsKey("application/xml"), content.keySet().toString());
}
@Test @Test
void a_typed_handler_documents_its_body_without_saying_the_type_twice() { void a_typed_handler_documents_its_body_without_saying_the_type_twice() {
OpenApiBuilder b = new OpenApiBuilder(); OpenApiBuilder b = new OpenApiBuilder();
@@ -0,0 +1,26 @@
package dev.relism.flash.routing;
import dev.relism.flash.http.ContentType;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* The media type a handler answers in.
*
* <p>Independent of {@link Consumes}: what a request carries says nothing about what the answer
* is written as, and a handler that takes JSON may well answer something else. Declare it on the
* handler, or once on a base class every handler of that kind extends.
*
* <p>Descriptive, like {@link Consumes}: tooling reads it, the router does not. What actually
* serializes a response is the middleware the route runs under.
*/
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Produces {
ContentType value();
}