Skip to content

Commit

Permalink
Merge pull request #45965 from phillip-kruger/openapi-auto-400
Browse files Browse the repository at this point in the history
OpenAPI Auto add Bad Request response for operation input
  • Loading branch information
phillip-kruger authored Feb 11, 2025
2 parents d4f441b + cd15adb commit f3d7851
Show file tree
Hide file tree
Showing 7 changed files with 370 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
* <ul>
* <li>Add operation descriptions based on the associated Java method name handling the operation
* <li>Add operation tags based on the associated Java class of the operation
* <li>Add operation descriptions based on the associated Java method name handling the operation</li>
* <li>Add operation tags based on the associated Java class of the operation</li>
* <li>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.</li>
* <li>Add Bad Request (400) response for invalid input (if none is provided)</li>
* </ul>
*/
public class OperationFilter implements OASFilter {
Expand All @@ -42,20 +46,22 @@ 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<String, ClassAndMethod> classNameMap,
Map<String, List<String>> rolesAllowedMethodReferences,
List<String> 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);
this.authenticatedMethodReferences = Objects.requireNonNull(authenticatedMethodReferences);
this.defaultSecuritySchemeName = Objects.requireNonNull(defaultSecuritySchemeName);
this.doAutoTag = doAutoTag;
this.doAutoOperation = doAutoOperation;
this.doAutoBadRequest = doAutoBadRequest;
this.alwaysIncludeScopesValidForScheme = alwaysIncludeScopesValidForScheme;
}

Expand All @@ -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);
});
}

Expand All @@ -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<PathItem.HttpMethod, Operation> 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<PathItem.HttpMethod, Operation> operation) {
return operation.getKey().equals(PathItem.HttpMethod.POST)
|| operation.getKey().equals(PathItem.HttpMethod.PUT);
}

private boolean hasBody(Map.Entry<PathItem.HttpMethod, Operation> operation) {
return operation.getValue().getRequestBody() != null;
}

private boolean isStringOrNumberOrBoolean(Map.Entry<PathItem.HttpMethod, Operation> 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<PathItem.HttpMethod, Operation> operation, OpenAPI openAPI) {
boolean isFile = false;
Content content = operation.getValue().getRequestBody().getContent();
if (content != null) {
for (Map.Entry<String, MediaType> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,11 @@ public boolean autoAddTags() {
return false;
}

@Override
public boolean autoAddBadRequestResponse() {
return false;
}

@Override
public boolean autoAddOperationSummary() {
return false;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}

}
Loading

0 comments on commit f3d7851

Please sign in to comment.