diff --git a/extensions/smallrye-openapi-common/deployment/src/main/java/io/quarkus/smallrye/openapi/common/deployment/SmallRyeOpenApiConfig.java b/extensions/smallrye-openapi-common/deployment/src/main/java/io/quarkus/smallrye/openapi/common/deployment/SmallRyeOpenApiConfig.java index a41556b76fb34..67e681e839156 100644 --- a/extensions/smallrye-openapi-common/deployment/src/main/java/io/quarkus/smallrye/openapi/common/deployment/SmallRyeOpenApiConfig.java +++ b/extensions/smallrye-openapi-common/deployment/src/main/java/io/quarkus/smallrye/openapi/common/deployment/SmallRyeOpenApiConfig.java @@ -94,6 +94,12 @@ public interface SmallRyeOpenApiConfig { @WithDefault("true") boolean autoAddTags(); + /** + * This will automatically add Bad Request (400 HTTP response) API response to operations with an input. + */ + @WithDefault("true") + boolean autoAddBadRequestResponse(); + /** * This will automatically add a summary to operations based on the Java method name. */ diff --git a/extensions/smallrye-openapi/deployment/src/main/java/io/quarkus/smallrye/openapi/deployment/SmallRyeOpenApiProcessor.java b/extensions/smallrye-openapi/deployment/src/main/java/io/quarkus/smallrye/openapi/deployment/SmallRyeOpenApiProcessor.java index 3ec94c7a83a4d..1c54cf468a027 100644 --- a/extensions/smallrye-openapi/deployment/src/main/java/io/quarkus/smallrye/openapi/deployment/SmallRyeOpenApiProcessor.java +++ b/extensions/smallrye-openapi/deployment/src/main/java/io/quarkus/smallrye/openapi/deployment/SmallRyeOpenApiProcessor.java @@ -561,7 +561,8 @@ private OASFilter getOperationFilter(OpenApiFilteredIndexViewBuildItem indexView if (!classNamesMethods.isEmpty() || !rolesAllowedMethods.isEmpty() || !authenticatedMethods.isEmpty()) { return new OperationFilter(classNamesMethods, rolesAllowedMethods, authenticatedMethods, config.securitySchemeName(), - config.autoAddTags(), config.autoAddOperationSummary(), isOpenApi_3_1_0_OrGreater(config)); + config.autoAddTags(), config.autoAddOperationSummary(), config.autoAddBadRequestResponse(), + isOpenApi_3_1_0_OrGreater(config)); } return null; diff --git a/extensions/smallrye-openapi/deployment/src/main/java/io/quarkus/smallrye/openapi/deployment/filter/OperationFilter.java b/extensions/smallrye-openapi/deployment/src/main/java/io/quarkus/smallrye/openapi/deployment/filter/OperationFilter.java index 296a2c4746e5a..159969b79659c 100644 --- a/extensions/smallrye-openapi/deployment/src/main/java/io/quarkus/smallrye/openapi/deployment/filter/OperationFilter.java +++ b/extensions/smallrye-openapi/deployment/src/main/java/io/quarkus/smallrye/openapi/deployment/filter/OperationFilter.java @@ -17,19 +17,23 @@ import org.eclipse.microprofile.openapi.models.Operation; import org.eclipse.microprofile.openapi.models.PathItem; import org.eclipse.microprofile.openapi.models.Paths; +import org.eclipse.microprofile.openapi.models.media.Content; +import org.eclipse.microprofile.openapi.models.media.MediaType; +import org.eclipse.microprofile.openapi.models.media.Schema; import org.eclipse.microprofile.openapi.models.responses.APIResponse; import org.eclipse.microprofile.openapi.models.responses.APIResponses; import org.eclipse.microprofile.openapi.models.security.SecurityRequirement; import org.eclipse.microprofile.openapi.models.security.SecurityScheme; /** - * This filter replaces the former AutoTagFilter and AutoRolesAllowedFilter and has three functions: + * This filter has the following functions: * */ public class OperationFilter implements OASFilter { @@ -42,13 +46,14 @@ public class OperationFilter implements OASFilter { private final String defaultSecuritySchemeName; private final boolean doAutoTag; private final boolean doAutoOperation; + private final boolean doAutoBadRequest; private final boolean alwaysIncludeScopesValidForScheme; public OperationFilter(Map classNameMap, Map> rolesAllowedMethodReferences, List authenticatedMethodReferences, String defaultSecuritySchemeName, - boolean doAutoTag, boolean doAutoOperation, boolean alwaysIncludeScopesValidForScheme) { + boolean doAutoTag, boolean doAutoOperation, boolean doAutoBadRequest, boolean alwaysIncludeScopesValidForScheme) { this.classNameMap = Objects.requireNonNull(classNameMap); this.rolesAllowedMethodReferences = Objects.requireNonNull(rolesAllowedMethodReferences); @@ -56,6 +61,7 @@ public OperationFilter(Map classNameMap, this.defaultSecuritySchemeName = Objects.requireNonNull(defaultSecuritySchemeName); this.doAutoTag = doAutoTag; this.doAutoOperation = doAutoOperation; + this.doAutoBadRequest = doAutoBadRequest; this.alwaysIncludeScopesValidForScheme = alwaysIncludeScopesValidForScheme; } @@ -77,18 +83,18 @@ public void filterOpenAPI(OpenAPI openAPI) { .map(Map.Entry::getValue) .map(PathItem::getOperations) .filter(Objects::nonNull) - .map(Map::values) - .flatMap(Collection::stream) + .flatMap(operations -> operations.entrySet().stream()) .forEach(operation -> { - final String methodRef = methodRef(operation); + final String methodRef = methodRef(operation.getValue()); if (methodRef != null) { - maybeSetSummaryAndTag(operation, methodRef); - maybeAddSecurityRequirement(operation, methodRef, schemeName, scopesValidForScheme, + maybeSetSummaryAndTag(operation.getValue(), methodRef); + maybeAddSecurityRequirement(operation.getValue(), methodRef, schemeName, scopesValidForScheme, defaultSecurityErrors); + maybeAddBadRequestResponse(openAPI, operation, methodRef); } - operation.removeExtension(EXT_METHOD_REF); + operation.getValue().removeExtension(EXT_METHOD_REF); }); } @@ -97,6 +103,131 @@ private String methodRef(Operation operation) { return (String) (extensions != null ? extensions.get(EXT_METHOD_REF) : null); } + private void maybeAddBadRequestResponse(OpenAPI openAPI, Map.Entry operation, + String methodRef) { + if (!classNameMap.containsKey(methodRef)) { + return; + } + + if (doAutoBadRequest + && isPOSTorPUT(operation) // Only applies to PUT and POST + && hasBody(operation) // Only applies to input + && !isStringOrNumberOrBoolean(operation, openAPI) // Except String, Number and boolean + && !isFileUpload(operation, openAPI)) { // and file + if (!operation.getValue().getResponses().hasAPIResponse("400")) { // Only when the user has not already added one + operation.getValue().getResponses().addAPIResponse("400", + OASFactory.createAPIResponse().description("Bad Request")); + } + } + } + + private boolean isPOSTorPUT(Map.Entry operation) { + return operation.getKey().equals(PathItem.HttpMethod.POST) + || operation.getKey().equals(PathItem.HttpMethod.PUT); + } + + private boolean hasBody(Map.Entry operation) { + return operation.getValue().getRequestBody() != null; + } + + private boolean isStringOrNumberOrBoolean(Map.Entry operation, OpenAPI openAPI) { + boolean isStringOrNumberOrBoolean = false; + Content content = operation.getValue().getRequestBody().getContent(); + if (content != null) { + for (MediaType mediaType : content.getMediaTypes().values()) { + if (mediaType != null && mediaType.getSchema() != null) { + Schema schema = mediaType.getSchema(); + + if (schema.getRef() != null + || (schema.getContentSchema() != null && schema.getContentSchema().getRef() != null)) + schema = resolveSchema(schema, openAPI.getComponents()); + if (isString(schema) || isNumber(schema) || isBoolean(schema)) { + isStringOrNumberOrBoolean = true; + } + } + } + } + return isStringOrNumberOrBoolean; + } + + private Schema resolveSchema(Schema schema, Components components) { + while (schema != null) { + // Resolve `$ref` schema + if (schema.getRef() != null && components != null) { + String refName = schema.getRef().replace("#/components/schemas/", ""); + schema = components.getSchemas().get(refName); + if (schema == null) + break; + } else if (schema.getContentSchema() != null) { + schema = schema.getContentSchema(); + continue; + } + + break; + } + return schema; + } + + private boolean isFileUpload(Map.Entry operation, OpenAPI openAPI) { + boolean isFile = false; + Content content = operation.getValue().getRequestBody().getContent(); + if (content != null) { + for (Map.Entry kv : content.getMediaTypes().entrySet()) { + String mediaTypeKey = kv.getKey(); + if ("multipart/form-data".equals(mediaTypeKey) || "application/octet-stream".equals(mediaTypeKey)) { + MediaType mediaType = kv.getValue(); + if (mediaType != null && mediaType.getSchema() != null) { + if (isFileSchema(mediaType.getSchema(), openAPI.getComponents())) { + isFile = true; + } + } + } + } + } + return isFile; + } + + private boolean isFileSchema(Schema schema, Components components) { + if (isString(schema) && isBinaryFormat(schema)) { + return true; // Direct file schema + } + if (isObject(schema) && schema.getProperties() != null) { + // Check if it has a "file" property with type "string" and format "binary" + return schema.getProperties().values().stream() + .anyMatch(prop -> isString(prop) && isBinaryFormat(prop)); + } + if (schema.getRef() != null && components != null) { + // Resolve reference and check recursively + String refName = schema.getRef().replace("#/components/schemas/", ""); + Schema referencedSchema = components.getSchemas().get(refName); + if (referencedSchema != null) { + return isFileSchema(referencedSchema, components); + } + } + return false; + } + + private boolean isString(Schema schema) { + return schema != null && schema.getType() != null && schema.getType().contains(Schema.SchemaType.STRING); + } + + private boolean isNumber(Schema schema) { + return schema != null && schema.getType() != null && (schema.getType().contains(Schema.SchemaType.INTEGER) + || schema.getType().contains(Schema.SchemaType.NUMBER)); + } + + private boolean isBoolean(Schema schema) { + return schema != null && schema.getType() != null && schema.getType().contains(Schema.SchemaType.BOOLEAN); + } + + private boolean isObject(Schema schema) { + return schema != null && schema.getType() != null && schema.getType().contains(Schema.SchemaType.OBJECT); + } + + private boolean isBinaryFormat(Schema schema) { + return "binary".equals(schema.getFormat()); + } + private void maybeSetSummaryAndTag(Operation operation, String methodRef) { if (!classNameMap.containsKey(methodRef)) { return; diff --git a/extensions/smallrye-openapi/deployment/src/test/java/io/quarkus/smallrye/openapi/deployment/filter/SecurityConfigFilterTest.java b/extensions/smallrye-openapi/deployment/src/test/java/io/quarkus/smallrye/openapi/deployment/filter/SecurityConfigFilterTest.java index 807399e5982e0..2bf6c3ba209e8 100644 --- a/extensions/smallrye-openapi/deployment/src/test/java/io/quarkus/smallrye/openapi/deployment/filter/SecurityConfigFilterTest.java +++ b/extensions/smallrye-openapi/deployment/src/test/java/io/quarkus/smallrye/openapi/deployment/filter/SecurityConfigFilterTest.java @@ -127,6 +127,11 @@ public boolean autoAddTags() { return false; } + @Override + public boolean autoAddBadRequestResponse() { + return false; + } + @Override public boolean autoAddOperationSummary() { return false; diff --git a/extensions/smallrye-openapi/deployment/src/test/java/io/quarkus/smallrye/openapi/test/jaxrs/AutoBadRequestResource.java b/extensions/smallrye-openapi/deployment/src/test/java/io/quarkus/smallrye/openapi/test/jaxrs/AutoBadRequestResource.java new file mode 100644 index 0000000000000..62cb6a362616a --- /dev/null +++ b/extensions/smallrye-openapi/deployment/src/test/java/io/quarkus/smallrye/openapi/test/jaxrs/AutoBadRequestResource.java @@ -0,0 +1,115 @@ +package io.quarkus.smallrye.openapi.test.jaxrs; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.jboss.resteasy.reactive.MultipartForm; +import org.jboss.resteasy.reactive.PartType; +import org.jboss.resteasy.reactive.RestForm; +import org.jboss.resteasy.reactive.multipart.FileUpload; + +@Path("/auto") +public class AutoBadRequestResource { + + @POST + @Path("/") + public void addBar(MyBean myBean) { + } + + @PUT + @Path("/") + public void updateBar(MyBean myBean) { + } + + @POST + @Path("/string") + public void addString(String foo) { + } + + @PUT + @Path("/string") + public void updateString(String foo) { + } + + @POST + @Path("/file") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.TEXT_PLAIN) + public Response uploadFile(@RestForm("file") FileUpload file) { + return Response.accepted().build(); + } + + @PUT + @Path("/file") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.TEXT_PLAIN) + public Response updateFile(@RestForm("file") FileUpload file) { + return Response.accepted().build(); + } + + @POST + @Path("/multipart") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.TEXT_PLAIN) + public Response uploadMultipart(@MultipartForm FileUploadForm form) { + return Response.accepted().build(); + } + + @PUT + @Path("/multipart") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.TEXT_PLAIN) + public Response updateMultipart(@MultipartForm FileUploadForm form) { + return Response.accepted().build(); + } + + @POST + @Path("/provided") + @APIResponses({ + @APIResponse(responseCode = "204", description = "Successful"), + @APIResponse(responseCode = "400", description = "Invalid bean supplied") + }) + public void addProvidedBar(MyBean myBean) { + } + + @PUT + @Path("/provided") + @APIResponses({ + @APIResponse(responseCode = "204", description = "Successful"), + @APIResponse(responseCode = "400", description = "Invalid bean supplied") + }) + public void updateProvidedBar(MyBean myBean) { + } + + @POST + @Path("/nobody") + public void addNobodyBar() { + } + + @PUT + @Path("/nobody") + public void updateNobodyBar() { + } + + private static class MyBean { + public String bar; + } + + private static class FileUploadForm { + @RestForm("file") + @PartType(MediaType.APPLICATION_OCTET_STREAM) + public byte[] file; + + @RestForm("fileName") + @PartType(MediaType.TEXT_PLAIN) + public String fileName; + } + +} diff --git a/extensions/smallrye-openapi/deployment/src/test/java/io/quarkus/smallrye/openapi/test/jaxrs/AutoBadRequestTestCase.java b/extensions/smallrye-openapi/deployment/src/test/java/io/quarkus/smallrye/openapi/test/jaxrs/AutoBadRequestTestCase.java new file mode 100644 index 0000000000000..f8dda35ac1cbe --- /dev/null +++ b/extensions/smallrye-openapi/deployment/src/test/java/io/quarkus/smallrye/openapi/test/jaxrs/AutoBadRequestTestCase.java @@ -0,0 +1,99 @@ +package io.quarkus.smallrye.openapi.test.jaxrs; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.restassured.RestAssured; + +class AutoBadRequestTestCase { + @RegisterExtension + static QuarkusUnitTest runner = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(AutoBadRequestResource.class)); + + @Test + void testInOpenApi() { + RestAssured.given().header("Accept", "application/json") + .when() + .get("/q/openapi") + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(200) + .body("paths.'/auto'.post.responses.400.description", Matchers.is("Bad Request")) + .and() + .body("paths.'/auto'.put.responses.400.description", Matchers.is("Bad Request")); + } + + @Test + void testProvidedInOpenApi() { + RestAssured.given().header("Accept", "application/json") + .when() + .get("/q/openapi") + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(200) + .body("paths.'/auto/provided'.post.responses.400.description", Matchers.is("Invalid bean supplied")) + .and() + .body("paths.'/auto/provided'.put.responses.400.description", Matchers.is("Invalid bean supplied")); + } + + @Test + void testNobodyInOpenApi() { + RestAssured.given().header("Accept", "application/json") + .when() + .get("/q/openapi") + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(200) + .body("paths.'/auto/nobody'.post.responses.400", Matchers.is(Matchers.emptyOrNullString())) + .and() + .body("paths.'/auto/nobody'.put.responses.400", Matchers.is(Matchers.emptyOrNullString())); + } + + @Test + void testStringInOpenApi() { + RestAssured.given().header("Accept", "application/json") + .when() + .get("/q/openapi") + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(200) + .body("paths.'/auto/string'.post.responses.400", Matchers.is(Matchers.emptyOrNullString())) + .and() + .body("paths.'/auto/string'.put.responses.400", Matchers.is(Matchers.emptyOrNullString())); + } + + @Test + void testFileInOpenApi() { + RestAssured.given().header("Accept", "application/json") + .when() + .get("/q/openapi") + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(200) + .body("paths.'/auto/file'.post.responses.400", Matchers.is(Matchers.emptyOrNullString())) + .and() + .body("paths.'/auto/file'.put.responses.400", Matchers.is(Matchers.emptyOrNullString())); + } + + @Test + void testMultipartInOpenApi() { + RestAssured.given().header("Accept", "application/json") + .when() + .get("/q/openapi") + .then() + .log().ifValidationFails() + .assertThat() + .statusCode(200) + .body("paths.'/auto/multipart'.post.responses.400", Matchers.is(Matchers.emptyOrNullString())) + .and() + .body("paths.'/auto/multipart'.put.responses.400", Matchers.is(Matchers.emptyOrNullString())); + } +} diff --git a/tcks/microprofile-openapi/pom.xml b/tcks/microprofile-openapi/pom.xml index 835c48a290666..e704edbf5c9f8 100644 --- a/tcks/microprofile-openapi/pom.xml +++ b/tcks/microprofile-openapi/pom.xml @@ -30,6 +30,7 @@ / false + false