From 0d067d03b6ef78a78efe3e4f957f977254c72030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Rychel?= <121518786+micryc@users.noreply.github.com> Date: Thu, 1 Feb 2024 14:03:01 +0100 Subject: [PATCH] Add Header Object missing attributes (#4608) * Add explode attribute to header annotation * Add hidden attribute * Add example,examples attributes * Add array attribute * Add explode,hidden,example and examples attributes tests * Add Header with ArraySchema attribute test * Delete sout in test * Fix schema implementation parsing process * Delete unused imports and spaces * Fix schema implementation test * Fix APIs backward compatibility * Add space after coma * Refs #4196 - reintroduce original getHeader(s) methods signatures --------- Co-authored-by: frantuma --- .../v3/oas/annotations/headers/Header.java | 39 +++ .../v3/core/util/AnnotationsUtils.java | 86 ++++- .../AnnotationsUtilsHeadersTest.java | 2 +- .../io/swagger/v3/jaxrs2/OperationParser.java | 2 +- .../AnnotatedOperationMethodTest.java | 298 +++++++++++++++++- 5 files changed, 418 insertions(+), 9 deletions(-) diff --git a/modules/swagger-annotations/src/main/java/io/swagger/v3/oas/annotations/headers/Header.java b/modules/swagger-annotations/src/main/java/io/swagger/v3/oas/annotations/headers/Header.java index f4562ad2f8..b9bc2031c3 100644 --- a/modules/swagger-annotations/src/main/java/io/swagger/v3/oas/annotations/headers/Header.java +++ b/modules/swagger-annotations/src/main/java/io/swagger/v3/oas/annotations/headers/Header.java @@ -1,5 +1,8 @@ package io.swagger.v3.oas.annotations.headers; +import io.swagger.v3.oas.annotations.enums.Explode; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.ExampleObject; import io.swagger.v3.oas.annotations.media.Schema; import java.lang.annotation.Inherited; @@ -64,4 +67,40 @@ **/ String ref() default ""; + + /** + * When this is true, parameter values of type array or object generate separate parameters for each value of the array or key-value pair of the map. For other types of parameters this property has no effect. When style is form, the default value is true. For all other styles, the default value is false. Ignored if the properties content or array are specified. + * + * @return whether or not to expand individual array members + **/ + Explode explode() default Explode.DEFAULT; + + /** + * Allows this header to be marked as hidden + * + * @return whether or not this header is hidden + */ + boolean hidden() default false; + + /** + * Provides an example of the schema. When associated with a specific media type, the example string shall be parsed by the consumer to be treated as an object or an array. Ignored if the properties examples, content or array are specified. + * + * @return an example of the header + **/ + String example() default ""; + + /** + * An array of examples of the schema used to show the use of the associated schema. + * + * @return array of examples of the header + **/ + ExampleObject[] examples() default {}; + + /** + * The schema of the array that defines this header. Ignored if the property content is specified. + * + * @return the schema of the array + */ + ArraySchema array() default @ArraySchema(); + } diff --git a/modules/swagger-core/src/main/java/io/swagger/v3/core/util/AnnotationsUtils.java b/modules/swagger-core/src/main/java/io/swagger/v3/core/util/AnnotationsUtils.java index 9e9af0ab83..bd19af4b6b 100644 --- a/modules/swagger-core/src/main/java/io/swagger/v3/core/util/AnnotationsUtils.java +++ b/modules/swagger-core/src/main/java/io/swagger/v3/core/util/AnnotationsUtils.java @@ -10,6 +10,7 @@ import io.swagger.v3.core.converter.ModelConverters; import io.swagger.v3.core.converter.ResolvedSchema; import io.swagger.v3.oas.annotations.StringToClassMapItem; +import io.swagger.v3.oas.annotations.enums.Explode; import io.swagger.v3.oas.annotations.extensions.Extension; import io.swagger.v3.oas.annotations.extensions.ExtensionProperty; import io.swagger.v3.oas.annotations.links.LinkParameter; @@ -41,7 +42,6 @@ import org.apache.commons.lang3.math.NumberUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; - import java.io.IOException; import java.lang.annotation.Annotation; import java.lang.reflect.Field; @@ -1288,17 +1288,24 @@ public static Map getLinkParameters(LinkParameter[] linkParamete } public static Optional> getHeaders(io.swagger.v3.oas.annotations.headers.Header[] annotationHeaders, JsonView jsonViewAnnotation) { - return getHeaders(annotationHeaders, jsonViewAnnotation, false); + return getHeaders(annotationHeaders, null, jsonViewAnnotation); + } + public static Optional> getHeaders(io.swagger.v3.oas.annotations.headers.Header[] annotationHeaders, Components components, JsonView jsonViewAnnotation) { + return getHeaders(annotationHeaders, components, jsonViewAnnotation, false); } public static Optional> getHeaders(io.swagger.v3.oas.annotations.headers.Header[] annotationHeaders, JsonView jsonViewAnnotation, boolean openapi31) { + return getHeaders(annotationHeaders, null, jsonViewAnnotation, openapi31); + } + + public static Optional> getHeaders(io.swagger.v3.oas.annotations.headers.Header[] annotationHeaders, Components components, JsonView jsonViewAnnotation, boolean openapi31) { if (annotationHeaders == null) { return Optional.empty(); } Map headers = new HashMap<>(); for (io.swagger.v3.oas.annotations.headers.Header header : annotationHeaders) { - getHeader(header, jsonViewAnnotation).ifPresent(headerResult -> headers.put(header.name(), headerResult)); + getHeader(header, components, jsonViewAnnotation, openapi31).ifPresent(headerResult -> headers.put(header.name(), headerResult)); } if (headers.size() == 0) { @@ -1308,12 +1315,18 @@ public static Optional> getHeaders(io.swagger.v3.oas.annotat } public static Optional
getHeader(io.swagger.v3.oas.annotations.headers.Header header, JsonView jsonViewAnnotation) { - return getHeader(header, jsonViewAnnotation, false); + return getHeader(header, null, jsonViewAnnotation); + } + public static Optional
getHeader(io.swagger.v3.oas.annotations.headers.Header header, Components components, JsonView jsonViewAnnotation) { + return getHeader(header, components, jsonViewAnnotation, false); } public static Optional
getHeader(io.swagger.v3.oas.annotations.headers.Header header, JsonView jsonViewAnnotation, boolean openapi31) { + return getHeader(header, null, jsonViewAnnotation, openapi31); + } + public static Optional
getHeader(io.swagger.v3.oas.annotations.headers.Header header, Components components, JsonView jsonViewAnnotation, boolean openapi31) { - if (header == null) { + if (header == null || header.hidden()) { return Optional.empty(); } @@ -1327,6 +1340,13 @@ public static Optional
getHeader(io.swagger.v3.oas.annotations.headers.H headerObject.set$ref(header.ref()); isEmpty = false; } + if (StringUtils.isNotBlank(header.example())) { + try { + headerObject.setExample(Json.mapper().readTree(header.example())); + } catch (IOException e) { + headerObject.setExample(header.example()); + } + } if (header.deprecated()) { headerObject.setDeprecated(header.deprecated()); } @@ -1334,15 +1354,45 @@ public static Optional
getHeader(io.swagger.v3.oas.annotations.headers.H headerObject.setRequired(header.required()); isEmpty = false; } + Map exampleMap = new LinkedHashMap<>(); + if (header.examples().length == 1 && StringUtils.isBlank(header.examples()[0].name())) { + Optional exampleOptional = AnnotationsUtils.getExample(header.examples()[0], true); + exampleOptional.ifPresent(headerObject::setExample); + } else { + for (ExampleObject exampleObject : header.examples()) { + AnnotationsUtils.getExample(exampleObject).ifPresent(example -> exampleMap.put(exampleObject.name(), example)); + } + } + if (!exampleMap.isEmpty()) { + headerObject.setExamples(exampleMap); + } headerObject.setStyle(Header.StyleEnum.SIMPLE); if (header.schema() != null) { if (header.schema().implementation().equals(Void.class)) { AnnotationsUtils.getSchemaFromAnnotation(header.schema(), jsonViewAnnotation, openapi31).ifPresent( headerObject::setSchema); + }else { + AnnotatedType annotatedType = new AnnotatedType() + .type(getSchemaType(header.schema())) + .resolveAsRef(true) + .skipOverride(true) + .jsonViewAnnotation(jsonViewAnnotation); + + final ResolvedSchema resolvedSchema = ModelConverters.getInstance(openapi31).resolveAsResolvedSchema(annotatedType); + + if (resolvedSchema.schema != null) { + headerObject.setSchema(resolvedSchema.schema); + } + resolvedSchema.referencedSchemas.forEach(components::addSchemas); } } + if (hasArrayAnnotation(header.array())){ + AnnotationsUtils.getArraySchema(header.array(), components, jsonViewAnnotation, openapi31, null, true).ifPresent( + headerObject::setSchema); + } + setHeaderExplode(headerObject, header); if (isEmpty) { return Optional.empty(); } @@ -1350,6 +1400,30 @@ public static Optional
getHeader(io.swagger.v3.oas.annotations.headers.H return Optional.of(headerObject); } + public static void setHeaderExplode (Header header, io.swagger.v3.oas.annotations.headers.Header h) { + if (isHeaderExplodable(h, header)) { + if (Explode.TRUE.equals(h.explode())) { + header.setExplode(Boolean.TRUE); + } else if (Explode.FALSE.equals(h.explode())) { + header.setExplode(Boolean.FALSE); + } + } + } + + private static boolean isHeaderExplodable(io.swagger.v3.oas.annotations.headers.Header h, Header header) { + io.swagger.v3.oas.annotations.media.Schema schema = h.schema(); + boolean explode = true; + if (schema != null) { + Class implementation = schema.implementation(); + if (implementation == Void.class) { + if (!schema.type().equals("object") && !schema.type().equals("array")) { + explode = false; + } + } + } + return explode; + } + public static void addEncodingToMediaType(MediaType mediaType, io.swagger.v3.oas.annotations.media.Encoding encoding, JsonView jsonViewAnnotation) { addEncodingToMediaType(mediaType, encoding, jsonViewAnnotation, false); } @@ -1376,7 +1450,7 @@ public static void addEncodingToMediaType(MediaType mediaType, io.swagger.v3.oas } if (encoding.headers() != null) { - getHeaders(encoding.headers(), jsonViewAnnotation, openapi31).ifPresent(encodingObject::headers); + getHeaders(encoding.headers(), null, jsonViewAnnotation, openapi31).ifPresent(encodingObject::headers); } if (encoding.extensions() != null && encoding.extensions().length > 0) { Map extensions = AnnotationsUtils.getExtensions(openapi31, encoding.extensions()); diff --git a/modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/AnnotationsUtilsHeadersTest.java b/modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/AnnotationsUtilsHeadersTest.java index a080ddb408..26065877d4 100644 --- a/modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/AnnotationsUtilsHeadersTest.java +++ b/modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/AnnotationsUtilsHeadersTest.java @@ -46,7 +46,7 @@ public void extensionsTest(String methodName, .flatMap(response -> Arrays.stream(response.headers())).toArray(Header[]::new); final Optional> optionalMap = - AnnotationsUtils.getHeaders(headers, null); + AnnotationsUtils.getHeaders(headers, null, null); Assert.assertEquals(optionalMap, expected); } diff --git a/modules/swagger-jaxrs2/src/main/java/io/swagger/v3/jaxrs2/OperationParser.java b/modules/swagger-jaxrs2/src/main/java/io/swagger/v3/jaxrs2/OperationParser.java index b3f803e62f..599d1cd49d 100644 --- a/modules/swagger-jaxrs2/src/main/java/io/swagger/v3/jaxrs2/OperationParser.java +++ b/modules/swagger-jaxrs2/src/main/java/io/swagger/v3/jaxrs2/OperationParser.java @@ -93,7 +93,7 @@ public static Optional getApiResponses(final io.swagger.v3.oas.ann AnnotationsUtils.getContent(response.content(), classProduces == null ? new String[0] : classProduces.value(), methodProduces == null ? new String[0] : methodProduces.value(), null, components, jsonViewAnnotation, openapi31).ifPresent(apiResponseObject::content); - AnnotationsUtils.getHeaders(response.headers(), jsonViewAnnotation).ifPresent(apiResponseObject::headers); + AnnotationsUtils.getHeaders(response.headers(), components, jsonViewAnnotation).ifPresent(apiResponseObject::headers); if (StringUtils.isNotBlank(apiResponseObject.getDescription()) || apiResponseObject.getContent() != null || apiResponseObject.getHeaders() != null) { Map links = AnnotationsUtils.getLinks(response.links()); diff --git a/modules/swagger-jaxrs2/src/test/java/io/swagger/v3/jaxrs2/annotations/operations/AnnotatedOperationMethodTest.java b/modules/swagger-jaxrs2/src/test/java/io/swagger/v3/jaxrs2/annotations/operations/AnnotatedOperationMethodTest.java index 6d9f4aede4..b2ed91a39d 100644 --- a/modules/swagger-jaxrs2/src/test/java/io/swagger/v3/jaxrs2/annotations/operations/AnnotatedOperationMethodTest.java +++ b/modules/swagger-jaxrs2/src/test/java/io/swagger/v3/jaxrs2/annotations/operations/AnnotatedOperationMethodTest.java @@ -10,15 +10,16 @@ import io.swagger.v3.jaxrs2.resources.UserResource; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.Explode; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.headers.Header; +import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import org.testng.annotations.Test; - import javax.ws.rs.GET; import javax.ws.rs.Path; import java.io.IOException; @@ -171,6 +172,10 @@ static class SampleResponseSchema { public String id; } + static class SampleHeaderSchema { + public String id; + } + static class GenericError { public int code; public String message; @@ -346,6 +351,79 @@ static class GetOperationWithResponseMultipleHeaders { responseCode = "200", description = "voila!", headers = {@Header( + explode = Explode.TRUE, + name = "Rate-Limit-Limit", + description = "The number of allowed requests in the current period", + schema = @Schema(type = "integer")), + @Header( + name = "X-Rate-Limit-Desc", + description = "The description of rate limit", + schema = @Schema(type = "string"))}) + }) + @GET + @Path("/path") + public void simpleGet() { + } + } + + @Test + public void testOperationWithResponseArraySchema() { + String openApiYAML = readIntoYaml(GetOperationResponseHeaderWithArraySchema.class); + int start = openApiYAML.indexOf("get:"); + String extractedYAML = openApiYAML.substring(start); + String expectedYAML = "get:\n" + + " summary: Simple get operation\n" + + " description: Defines a simple get operation with no inputs and a complex output\n" + + " operationId: getWithPayloadResponse\n" + + " responses:\n" + + " \"200\":\n" + + " description: voila!\n" + + " headers:\n" + + " Rate-Limit-Limit:\n" + + " description: The number of allowed requests in the current period\n" + + " style: simple\n" + + " schema:\n" + + " maxItems: 10\n" + + " minItems: 1\n" + + " type: array\n" + + " items:\n" + + " type: integer\n" + + " deprecated: true\n"; + assertEquals(expectedYAML, extractedYAML); + } + + static class GetOperationResponseHeaderWithArraySchema { + @Operation( + summary = "Simple get operation", + description = "Defines a simple get operation with no inputs and a complex output", + operationId = "getWithPayloadResponse", + deprecated = true, + responses = { + @ApiResponse( + responseCode = "200", + description = "voila!", + headers = {@Header( + name = "Rate-Limit-Limit", + description = "The number of allowed requests in the current period", + array = @ArraySchema(maxItems = 10, minItems = 1,schema = @Schema(type = "integer")))})}) + @GET + @Path("/path") + public void simpleGet() { + } + } + + static class GetOperationResponseWithoutHiddenHeader { + @Operation( + summary = "Simple get operation", + description = "Defines a simple get operation with no inputs and a complex output", + operationId = "getWithPayloadResponse", + deprecated = true, + responses = { + @ApiResponse( + responseCode = "200", + description = "voila!", + headers = {@Header( + hidden = true, name = "Rate-Limit-Limit", description = "The number of allowed requests in the current period", schema = @Schema(type = "integer")), @@ -360,6 +438,72 @@ public void simpleGet() { } } + static class GetOperationWithResponseMultipleHeadersAndExamples { + @Operation( + summary = "Simple get operation", + description = "Defines a simple get operation with no inputs and a complex output", + operationId = "getWithPayloadResponse", + deprecated = true, + responses = { + @ApiResponse( + responseCode = "200", + description = "voila!", + headers = {@Header( + examples = { + @ExampleObject( + name = "ex 1", + description = "example description", + value = "example value" + ), + @ExampleObject( + name = "ex 2", + description = "example description 2", + value = "example value 2" + ) + }, + name = "Rate-Limit-Limit", + description = "The number of allowed requests in the current period", + schema = @Schema(type = "object")), + @Header( + name = "X-Rate-Limit-Desc", + description = "The description of rate limit", + array = @ArraySchema(schema = @Schema()), + example = "example1")}) + + }) + @GET + @Path("/path") + public void simpleGet() { + } + } + + static class GetOperationResponseWithHeaderExplodeAttribute { + @Operation( + summary = "Simple get operation", + description = "Defines a simple get operation with no inputs and a complex output", + operationId = "getWithPayloadResponse", + deprecated = true, + responses = { + @ApiResponse( + responseCode = "200", + description = "voila!", + headers = {@Header( + name = "Rate-Limit-Limit", + description = "The number of allowed requests in the current period", + explode = Explode.TRUE, + schema = @Schema(type = "object")), + @Header( + name = "X-Rate-Limit-Desc", + description = "The description of rate limit", + explode = Explode.FALSE, + schema = @Schema(type = "array"))}) + }) + @GET + @Path("/path") + public void simpleGet() { + } + } + @Test public void testOperationWithResponseMultipleHeaders() { String openApiYAML = readIntoYaml(GetOperationWithResponseMultipleHeaders.class); @@ -387,6 +531,158 @@ public void testOperationWithResponseMultipleHeaders() { assertEquals(expectedYAML, extractedYAML); } + static class GetOperationWithResponseMultipleHeadersWithImplementationSchema { + @Operation( + summary = "Simple get operation", + description = "Defines a simple get operation with no inputs and a complex output", + operationId = "getWithPayloadResponse", + deprecated = true, + responses = { + @ApiResponse( + responseCode = "200", + description = "voila!", + headers = {@Header( + explode = Explode.TRUE, + name = "Rate-Limit-Limit", + description = "The number of allowed requests in the current period", + array = @ArraySchema(maxItems = 10, minItems = 1,schema = @Schema(implementation = SampleHeaderSchema.class))), + @Header( + explode = Explode.TRUE, + name = "X-Rate-Limit-Desc", + description = "The description of rate limit", + schema = @Schema(implementation = SampleHeaderSchema.class))})}) + + + + @GET + @Path("/path") + public void simpleGet() { + } + } + + @Test + public void testOperationWithResponseMultipleHeadersImplementationSchema() { + String openApiYAML = readIntoYaml(GetOperationWithResponseMultipleHeadersWithImplementationSchema.class); + int start = openApiYAML.indexOf("get:"); + String extractedYAML = openApiYAML.substring(start); + String expectedYAML = "get:\n" + + " summary: Simple get operation\n" + + " description: Defines a simple get operation with no inputs and a complex output\n" + + " operationId: getWithPayloadResponse\n" + + " responses:\n" + + " \"200\":\n" + + " description: voila!\n" + + " headers:\n" + + " X-Rate-Limit-Desc:\n" + + " description: The description of rate limit\n" + + " style: simple\n" + + " explode: true\n" + + " schema:\n" + + " $ref: '#/components/schemas/SampleHeaderSchema'\n" + + " Rate-Limit-Limit:\n" + + " description: The number of allowed requests in the current period\n" + + " style: simple\n" + + " schema:\n" + + " maxItems: 10\n" + + " minItems: 1\n" + + " type: array\n" + + " items:\n" + + " $ref: '#/components/schemas/SampleHeaderSchema'\n" + + " deprecated: true\n" + + "components:\n" + + " schemas:\n" + + " SampleHeaderSchema:\n" + + " type: object\n" + + " properties:\n" + + " id:\n" + + " type: string\n"; + assertEquals(expectedYAML, extractedYAML); + } + + @Test + public void testOperationWithResponseMultipleHeadersAndExplodeAttribute() { + String openApiYAML = readIntoYaml(GetOperationResponseWithHeaderExplodeAttribute.class); + int start = openApiYAML.indexOf("get:"); + String extractedYAML = openApiYAML.substring(start); + String expectedYAML = "get:\n" + + " summary: Simple get operation\n" + + " description: Defines a simple get operation with no inputs and a complex output\n" + + " operationId: getWithPayloadResponse\n" + + " responses:\n" + + " \"200\":\n" + + " description: voila!\n" + + " headers:\n" + + " X-Rate-Limit-Desc:\n" + + " description: The description of rate limit\n" + + " style: simple\n" + + " explode: false\n" + + " schema:\n" + + " type: array\n" + + " Rate-Limit-Limit:\n" + + " description: The number of allowed requests in the current period\n" + + " style: simple\n" + + " explode: true\n" + + " schema:\n" + + " type: object\n" + + " deprecated: true\n"; + assertEquals(expectedYAML, extractedYAML); + } + + @Test + public void testOperationResponseWithoutHiddenHeader() { + String openApiYAML = readIntoYaml(GetOperationResponseWithoutHiddenHeader.class); + int start = openApiYAML.indexOf("get:"); + String extractedYAML = openApiYAML.substring(start); + String expectedYAML = "get:\n" + + " summary: Simple get operation\n" + + " description: Defines a simple get operation with no inputs and a complex output\n" + + " operationId: getWithPayloadResponse\n" + + " responses:\n" + + " \"200\":\n" + + " description: voila!\n" + + " headers:\n" + + " X-Rate-Limit-Desc:\n" + + " description: The description of rate limit\n" + + " style: simple\n" + + " schema:\n" + + " type: string\n" + + " deprecated: true\n"; + assertEquals(expectedYAML, extractedYAML); + } + + @Test + public void testOperationWithResponseMultipleHeadersAndExamples() { + String openApiYAML = readIntoYaml(GetOperationWithResponseMultipleHeadersAndExamples.class); + int start = openApiYAML.indexOf("get:"); + String extractedYAML = openApiYAML.substring(start); + String expectedYAML = "get:\n" + + " summary: Simple get operation\n" + + " description: Defines a simple get operation with no inputs and a complex output\n" + + " operationId: getWithPayloadResponse\n" + + " responses:\n" + + " \"200\":\n" + + " description: voila!\n" + + " headers:\n" + + " X-Rate-Limit-Desc:\n" + + " description: The description of rate limit\n" + + " style: simple\n" + + " example: example1\n" + + " Rate-Limit-Limit:\n" + + " description: The number of allowed requests in the current period\n" + + " style: simple\n" + + " schema:\n" + + " type: object\n" + + " examples:\n" + + " ex 1:\n" + + " description: example description\n" + + " value: example value\n" + + " ex 2:\n" + + " description: example description 2\n" + + " value: example value 2\n" + + " deprecated: true\n"; + assertEquals(expectedYAML, extractedYAML); + } + @Test(description = "reads the pet resource from sample") public void testCompletePetResource() throws IOException { String expectedYAML = "openapi: 3.0.1\n" +