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:
co-authored by
Claude Opus 5
parent
2f06ca7c1d
commit
60dd4eab6a
+5
-4
@@ -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.
|
||||
*
|
||||
* <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)
|
||||
@Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD})
|
||||
public @interface Content {
|
||||
ContentType contentType() default ContentType.JSON;
|
||||
Class<?> schema() default Void.class;
|
||||
boolean array() default false;
|
||||
|
||||
|
||||
+16
-6
@@ -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<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<>();
|
||||
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<String, Object> inferredResponse(Class<?> handlerClass) {
|
||||
Map<String, Object> response = new LinkedHashMap<>(Map.of("description", reasonFor(200)));
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<String, Object> 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<String, Object> operation, Class<?> handlerClass,
|
||||
|
||||
+23
-3
@@ -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<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
|
||||
void a_typed_handler_documents_its_body_without_saying_the_type_twice() {
|
||||
OpenApiBuilder b = new OpenApiBuilder();
|
||||
|
||||
Reference in New Issue
Block a user