Skip to content

Commit 2754237

Browse files
authored
Properly read multiple nested/composed @APIResponse annotations (#2419)
* Properly read multiple nested/composed `@APIResponse` annotations Signed-off-by: Michael Edgar <[email protected]> * Remove unused APIResponsesIO method body, improve response comments Signed-off-by: Michael Edgar <[email protected]> --------- Signed-off-by: Michael Edgar <[email protected]>
1 parent 5bbf7ae commit 2754237

File tree

6 files changed

+165
-61
lines changed

6 files changed

+165
-61
lines changed

core/src/main/java/io/smallrye/openapi/runtime/io/responses/APIResponseIO.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import org.eclipse.microprofile.openapi.models.responses.APIResponse;
1111
import org.eclipse.microprofile.openapi.models.responses.APIResponses;
1212
import org.jboss.jandex.AnnotationInstance;
13+
import org.jboss.jandex.AnnotationTarget;
1314
import org.jboss.jandex.DotName;
1415
import org.jboss.jandex.Type;
1516

@@ -40,6 +41,11 @@ public APIResponseIO(IOContext<V, A, O, AB, OB> context) {
4041
super(context, Names.API_RESPONSE, DotName.createSimple(APIResponse.class));
4142
}
4243

44+
@Override
45+
public Map<String, APIResponse> readMap(AnnotationTarget target) {
46+
return readMap(target, this::responseCode);
47+
}
48+
4349
@Override
4450
public APIResponse read(AnnotationInstance annotation) {
4551
IoLogging.logger.singleAnnotation("@APIResponse");

core/src/main/java/io/smallrye/openapi/runtime/io/responses/APIResponsesIO.java

Lines changed: 14 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
package io.smallrye.openapi.runtime.io.responses;
22

3-
import java.util.Arrays;
4-
import java.util.Collections;
53
import java.util.Map;
64
import java.util.Optional;
75

@@ -23,36 +21,26 @@ public APIResponsesIO(IOContext<V, A, O, AB, OB> context) {
2321
super(context, Names.API_RESPONSES, Names.create(APIResponses.class));
2422
}
2523

26-
public Map<String, APIResponse> readSingle(AnnotationTarget target) {
27-
return Optional.ofNullable(apiResponseIO().getAnnotation(target))
28-
.map(Collections::singleton)
29-
.map(annotations -> apiResponseIO().readMap(annotations, apiResponseIO()::responseCode))
24+
@Override
25+
public APIResponses read(AnnotationTarget target) {
26+
Map<String, APIResponse> responseMap = apiResponseIO().readMap(target);
27+
Map<String, Object> extensions = Optional.ofNullable(getAnnotation(target))
28+
.map(extensionIO()::readExtensible)
3029
.orElse(null);
31-
}
3230

33-
public Map<String, APIResponse> readAll(AnnotationTarget target) {
34-
return apiResponseIO().readMap(target, apiResponseIO()::responseCode);
31+
if (!responseMap.isEmpty() || extensions != null) {
32+
APIResponses responses = OASFactory.createAPIResponses();
33+
responseMap.forEach(responses::addAPIResponse);
34+
responses.setExtensions(extensions);
35+
return responses;
36+
}
37+
38+
return null;
3539
}
3640

3741
@Override
3842
public APIResponses read(AnnotationInstance annotation) {
39-
AnnotationTarget target = annotation.target();
40-
41-
return Optional.ofNullable(annotation)
42-
.map(AnnotationInstance::value)
43-
/*
44-
* Begin - copy target to clones of nested annotations to support @Extension on
45-
* method being applied to @APIReponse. Remove when no longer supporting TCK
46-
* 3.1.1 and earlier.
47-
*/
48-
.map(AnnotationValue::asNestedArray)
49-
.map(annotations -> Arrays.stream(annotations)
50-
.map(a -> AnnotationInstance.create(a.name(), target, a.values()))
51-
.toArray(AnnotationInstance[]::new))
52-
// End
53-
.map(this::read)
54-
.map(responses -> responses.extensions(extensionIO().readExtensible(annotation)))
55-
.orElse(null);
43+
throw new UnsupportedOperationException();
5644
}
5745

5846
@Override

core/src/main/java/io/smallrye/openapi/runtime/scanner/spi/AnnotationScanner.java

Lines changed: 46 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -376,25 +376,25 @@ default void processResponse(final AnnotationScannerContext context, final Class
376376

377377
setJsonViewContext(context, context.annotations().getAnnotationValue(method, JacksonConstants.JSON_VIEW));
378378

379-
Optional<APIResponses> classResponses = Optional.ofNullable(context.io().apiResponsesIO().read(resourceClass));
380-
Map<String, APIResponse> classResponse = context.io().apiResponsesIO().readSingle(resourceClass);
381-
addResponses(operation, classResponses, classResponse, false);
379+
// Add responses from the class
380+
addResponses(operation, context.io().apiResponsesIO().read(resourceClass), false);
382381

383382
// Method annotations override class annotations
384-
Optional<APIResponses> methodResponses = Optional.ofNullable(context.io().apiResponsesIO().read(method));
385-
Map<String, APIResponse> methodResponse = context.io().apiResponsesIO().readSingle(method);
386-
addResponses(operation, methodResponses, methodResponse, true);
383+
addResponses(operation, context.io().apiResponsesIO().read(method), true);
387384

388385
context.io().apiResponsesIO().readResponseSchema(method)
389386
.ifPresent(responseSchema -> addApiReponseSchemaFromAnnotation(responseSchema, method, operation));
390387

391388
/*
392-
* If there is no response from annotations, try to create one from the method return value.
393-
* Do not generate a response if the app has used an empty @ApiResponses annotation. This
394-
* provides a way for the application to indicate that responses will be supplied some other
389+
* If an empty @APIResponses annotation is not present on the method,
390+
* attempt to generate additional response information based on the
391+
* HTTP method and the method's return type.
392+
*
393+
* The presence of an empty @APIResponses provides a way for the
394+
* application to indicate that responses will be supplied some other
395395
* way (i.e. static file).
396396
*/
397-
if (methodResponses.isPresent() || context.io().apiResponsesIO().getAnnotation(method) == null) {
397+
if (!hasEmptyAPIResponsesAnnotation(context, method)) {
398398
createResponseFromRestMethod(context, method, operation);
399399
}
400400

@@ -418,34 +418,50 @@ default void processResponse(final AnnotationScannerContext context, final Class
418418
clearJsonViewContext(context);
419419
}
420420

421-
default void addResponses(Operation operation, Optional<APIResponses> responses, Map<String, APIResponse> singleResponse,
422-
boolean includeExtensions) {
423-
responses.ifPresent(resp -> {
424-
resp.getAPIResponses().forEach(ModelUtil.responses(operation)::addAPIResponse);
425-
if (includeExtensions && resp.getExtensions() != null) {
426-
resp.getExtensions().forEach(ModelUtil.responses(operation)::addExtension);
421+
private boolean hasEmptyAPIResponsesAnnotation(AnnotationScannerContext context, MethodInfo method) {
422+
AnnotationInstance responsesAnno = context.io().apiResponsesIO().getAnnotation(method);
423+
424+
if (responsesAnno == null) {
425+
return false;
426+
}
427+
428+
AnnotationInstance[] responsesAnnoValue = context.annotations().value(responsesAnno);
429+
return (responsesAnnoValue == null || responsesAnnoValue.length == 0);
430+
}
431+
432+
private void addResponses(Operation operation, APIResponses responses, boolean includeExtensions) {
433+
if (responses != null) {
434+
APIResponses operationResponses = ModelUtil.responses(operation);
435+
responses.getAPIResponses().forEach(operationResponses::addAPIResponse);
436+
437+
if (includeExtensions) {
438+
operationResponses.setExtensions(responses.getExtensions());
427439
}
428-
});
429-
if (singleResponse != null) {
430-
singleResponse.forEach(ModelUtil.responses(operation)::addAPIResponse);
431440
}
432441
}
433442

434443
/**
435-
* Called when a scanner (jax-rs, spring) method's APIResponse annotations have all been processed but
436-
* no response was actually created for the operation.This method will create a response
437-
* from the method information and add it to the given operation. It will try to do this
438-
* by examining the method's return value and the type of operation (GET, PUT, POST, DELETE).
444+
* Called when a scanner (jax-rs, spring) method's APIResponse annotations
445+
* have all been processed but no response content was created for the
446+
* operation.
439447
*
440-
* If there is a return value of some kind (a non-void return type) then the response code
441-
* is assumed to be 200.
448+
* This method will create a response from the method information
449+
* and add it to the given operation. It will try to do this by examining
450+
* the method's return value and the type of operation (GET, PUT, POST,
451+
* DELETE).
442452
*
443-
* If there not a return value (void return type) then either a 201 or 204 is returned,
444-
* depending on the type of request.
453+
* If there is a return value of some kind (a non-void return type) then the
454+
* response code is assumed to be 200.
445455
*
446-
* @param context the scanning context
447-
* @param method the current method
448-
* @param operation the current operation
456+
* If there not a return value (void return type) then either a 201 or 204
457+
* is returned, depending on the type of request.
458+
*
459+
* @param context
460+
* the scanning context
461+
* @param method
462+
* the current method
463+
* @param operation
464+
* the current operation
449465
*/
450466
default void createResponseFromRestMethod(final AnnotationScannerContext context,
451467
final MethodInfo method,

extension-jaxrs/src/main/java/io/smallrye/openapi/jaxrs/JaxRsAnnotationScanner.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ private Map<DotName, Map<String, APIResponse>> processExceptionMappers() {
323323
MethodInfo mapperMethod = mapperAnnotation.target().asMethod();
324324
if (mapperMethod.parametersCount() == 1) {
325325
DotName exceptionName = mapperMethod.parameterType(0).name();
326-
exceptionMappers.put(exceptionName, context.io().apiResponsesIO().readAll(mapperMethod));
326+
exceptionMappers.put(exceptionName, context.io().apiResponseIO().readMap(mapperMethod));
327327
}
328328
}
329329

@@ -349,14 +349,14 @@ private Map<DotName, Map<String, APIResponse>> exceptionResponseAnnotations(Clas
349349
.of(classInfo.method(JaxRsConstants.TO_RESPONSE_METHOD_NAME, exceptionType))
350350
.filter(Objects::nonNull)
351351
.flatMap(m -> context.io()
352-
.apiResponsesIO()
353-
.readAll(m)
352+
.apiResponseIO()
353+
.readMap(m)
354354
.entrySet()
355355
.stream());
356356

357357
Stream<Entry<String, APIResponse>> classAnnotations = context.io()
358-
.apiResponsesIO()
359-
.readAll(classInfo)
358+
.apiResponseIO()
359+
.readMap(classInfo)
360360
.entrySet()
361361
.stream();
362362

extension-jaxrs/src/test/java/io/smallrye/openapi/runtime/scanner/ApiResponseTests.java

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package io.smallrye.openapi.runtime.scanner;
22

33
import java.io.IOException;
4+
import java.lang.annotation.ElementType;
5+
import java.lang.annotation.Retention;
6+
import java.lang.annotation.RetentionPolicy;
7+
import java.lang.annotation.Target;
48
import java.util.Collection;
59
import java.util.HashMap;
610
import java.util.Iterator;
@@ -11,6 +15,7 @@
1115
import org.eclipse.microprofile.openapi.annotations.media.Schema;
1216
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
1317
import org.eclipse.microprofile.openapi.annotations.responses.APIResponseSchema;
18+
import org.eclipse.microprofile.openapi.annotations.responses.APIResponses;
1419
import org.json.JSONException;
1520
import org.junit.jupiter.api.Test;
1621
import org.junit.jupiter.params.ParameterizedTest;
@@ -454,4 +459,48 @@ public jakarta.ws.rs.core.Response handle() {
454459
(java.io.InputStream) null,
455460
DataApi.class));
456461
}
462+
463+
@Target(ElementType.METHOD)
464+
@Retention(RetentionPolicy.RUNTIME)
465+
@APIResponses({ // NOSONAR - both @APIResponses + @APIResponse wanted directly
466+
@APIResponse(responseCode = "400", description = "Really Bad Request"),
467+
@APIResponse(responseCode = "406", description = "Not Acceptable")
468+
})
469+
@APIResponse(responseCode = "404", description = "Not found")
470+
@interface WithClientErrors {
471+
}
472+
473+
@Target(ElementType.METHOD)
474+
@Retention(RetentionPolicy.RUNTIME)
475+
@APIResponse(responseCode = "500", description = "Internal server error")
476+
@APIResponses({ // NOSONAR - both @APIResponses + @APIResponse wanted directly
477+
@APIResponse(responseCode = "502", description = "Bad Gateway"),
478+
@APIResponse(responseCode = "504", description = "Gateway Timeout")
479+
})
480+
@interface WithServerErrors {
481+
}
482+
483+
@Test
484+
void testAnnotationComposition() throws IOException, JSONException {
485+
486+
@jakarta.ws.rs.Path("hello")
487+
class HelloApi {
488+
489+
@jakarta.ws.rs.GET
490+
@jakarta.ws.rs.Produces(jakarta.ws.rs.core.MediaType.TEXT_PLAIN)
491+
@APIResponses({ // NOSONAR - both @APIResponses + @APIResponse wanted directly on method
492+
@APIResponse(responseCode = "200", description = "OK"),
493+
@APIResponse(responseCode = "301", description = "See Other")
494+
})
495+
@WithClientErrors
496+
@WithServerErrors
497+
@APIResponse(responseCode = "403", description = "Not Authorized")
498+
public String sayHello() {
499+
return "Goodbye";
500+
}
501+
}
502+
503+
assertJsonEquals("responses.multi-location-composition.json", HelloApi.class, WithClientErrors.class,
504+
WithServerErrors.class);
505+
}
457506
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"openapi" : "3.1.0",
3+
"paths" : {
4+
"/hello" : {
5+
"get" : {
6+
"responses" : {
7+
"403" : {
8+
"description" : "Not Authorized"
9+
},
10+
"404" : {
11+
"description" : "Not found"
12+
},
13+
"500" : {
14+
"description" : "Internal server error"
15+
},
16+
"200" : {
17+
"description" : "OK",
18+
"content" : {
19+
"text/plain" : {
20+
"schema" : {
21+
"type" : "string"
22+
}
23+
}
24+
}
25+
},
26+
"301" : {
27+
"description" : "See Other"
28+
},
29+
"400" : {
30+
"description" : "Really Bad Request"
31+
},
32+
"406" : {
33+
"description" : "Not Acceptable"
34+
},
35+
"502" : {
36+
"description" : "Bad Gateway"
37+
},
38+
"504" : {
39+
"description" : "Gateway Timeout"
40+
}
41+
}
42+
}
43+
}
44+
}
45+
}

0 commit comments

Comments
 (0)