From 60dd4eab6a2e0bb8ab58d9344ce42bdf07566855 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 23 Sep 2026 14:41:56 +0000 Subject: [PATCH] fix(ext-openapi): an answer's media type is the handler's, not the body's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @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 --- .../json/ConstraintsInTheDocumentTest.java | 2 +- flash-extensions/flash-ext-openapi/README.md | 16 ++++++++---- .../dev/relism/flash/ext/openapi/Content.java | 9 ++++--- .../flash/ext/openapi/OpenApiBuilder.java | 22 +++++++++++----- .../flash/ext/openapi/OpenApiBuilderTest.java | 26 ++++++++++++++++--- .../dev/relism/flash/routing/Produces.java | 26 +++++++++++++++++++ 6 files changed, 82 insertions(+), 19 deletions(-) create mode 100644 flash/src/main/java/dev/relism/flash/routing/Produces.java diff --git a/flash-extensions/flash-ext-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/ConstraintsInTheDocumentTest.java b/flash-extensions/flash-ext-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/ConstraintsInTheDocumentTest.java index dbc746b..7f390e3 100644 --- a/flash-extensions/flash-ext-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/ConstraintsInTheDocumentTest.java +++ b/flash-extensions/flash-ext-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/ConstraintsInTheDocumentTest.java @@ -33,7 +33,7 @@ class ConstraintsInTheDocumentTest { @GET("/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 { @Override public Object handle(Request request, Response response) { return new Account("alice", "a@b.com", 30); diff --git a/flash-extensions/flash-ext-openapi/README.md b/flash-extensions/flash-ext-openapi/README.md index 1a1e797..6b2c166 100644 --- a/flash-extensions/flash-ext-openapi/README.md +++ b/flash-extensions/flash-ext-openapi/README.md @@ -26,6 +26,8 @@ Every class-based route is documented, annotated or not. Read off the code: - **path parameters**, from `/{id}` in the route - **the request body**, from the handler's own body type (see below) - **the response schema**, from what `handle` returns — an object, a `List`, a `Map` +- **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}` - **security and rate limiting**, from the extensions that enforce them @@ -53,8 +55,8 @@ requestBody: schema: { $ref: '#/components/schemas/NewUser' } ``` -The media type comes from `@Consumes` on the base class, so an XML handler documents itself as -XML without a word from the route. +The media type of the body comes from `@Consumes` on the base class, so an XML handler documents +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: @@ -83,13 +85,17 @@ The success response is inferred. Declare one only to say more: @APIResponse(responseCode = "200", description = "User found", content = @Content(schema = UserDto.class, example = "{\"id\":\"usr-1\"}")) @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. -- 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. -- `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 guarded route, the 429 of every limited one — become `components.responses` entries referenced by diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Content.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Content.java index b86a38c..a751b17 100644 --- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Content.java +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Content.java @@ -1,19 +1,20 @@ package dev.relism.flash.ext.openapi; -import dev.relism.flash.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. + * What a response carries: the schema, and an example of it. + * + *

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) @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/flash/ext/openapi/OpenApiBuilder.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java index e305878..92b89d1 100644 --- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java @@ -5,6 +5,7 @@ import dev.relism.flash.http.HttpMethod; import dev.relism.flash.http.HttpStatus; import dev.relism.flash.models.BodyHandler; import dev.relism.flash.routing.Consumes; +import dev.relism.flash.routing.Produces; import dev.relism.flash.routing.Route; import java.lang.annotation.Annotation; @@ -230,15 +231,13 @@ public final class OpenApiBuilder { response.put("description", declared.description().isEmpty() ? reasonFor(status) : declared.description()); Content content = declared.content(); - if (content.contentType() == ContentType.NONE) return response; - Map schema = schemaFor(content, status, handlerClass); - if (schema == null) return response; + if (schema == null) return response; // nothing to describe: a response with no body Map media = new LinkedHashMap<>(); media.put("schema", schema); 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; } @@ -259,10 +258,21 @@ public final class OpenApiBuilder { private Map inferredResponse(Class handlerClass) { Map response = new LinkedHashMap<>(Map.of("description", reasonFor(200))); Map 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; } + /** + * 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. */ private Map returnSchema(Class 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. if (status >= 400 && !response.containsKey("content")) { 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 operation, Class handlerClass, diff --git a/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java b/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java index 40baa7b..8e41ee3 100644 --- a/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java +++ b/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java @@ -23,7 +23,7 @@ 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)) + @APIResponse(responseCode = "200", description = "User found", content = @Content(schema = UserDto.class)) static class GetUserHandler extends RequestHandler { @Override public Object handle(Request request, Response response) { @@ -33,7 +33,7 @@ class OpenApiBuilderTest { @GET("/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 { @Override public Object handle(Request request, Response response) { @@ -43,7 +43,7 @@ class OpenApiBuilderTest { @GET("/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 { @Override public Object handle(Request request, Response response) { @@ -169,6 +169,26 @@ class OpenApiBuilderTest { @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 get = getOperation(b.build(), "/xml", "get"); + Map responses = cast(get.get("responses")); + Map ok = cast(responses.get("200")); + Map content = cast(ok.get("content")); + + assertTrue(content.containsKey("application/xml"), content.keySet().toString()); + } + @Test void a_typed_handler_documents_its_body_without_saying_the_type_twice() { OpenApiBuilder b = new OpenApiBuilder(); diff --git a/flash/src/main/java/dev/relism/flash/routing/Produces.java b/flash/src/main/java/dev/relism/flash/routing/Produces.java new file mode 100644 index 0000000..b19d0c4 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/routing/Produces.java @@ -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. + * + *

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. + * + *

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(); +} -- 2.54.0