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:
*
- * - Add operation descriptions based on the associated Java method name handling the operation
- *
- Add operation tags based on the associated Java class of the operation
+ *
- Add operation descriptions based on the associated Java method name handling the operation
+ * - Add operation tags based on the associated Java class of the operation
* - Add security requirements based on discovered {@link jakarta.annotation.security.RolesAllowed},
* {@link io.quarkus.security.PermissionsAllowed}, and {@link io.quarkus.security.Authenticated}
- * annotations.
+ * annotations. Also add the expected security responses if needed.
+ * - Add Bad Request (400) response for invalid input (if none is provided)
*
*/
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