Skip to content

Commit f3d7851

Browse files
Merge pull request #45965 from phillip-kruger/openapi-auto-400
OpenAPI Auto add Bad Request response for operation input
2 parents d4f441b + cd15adb commit f3d7851

File tree

7 files changed

+370
-12
lines changed

7 files changed

+370
-12
lines changed

extensions/smallrye-openapi-common/deployment/src/main/java/io/quarkus/smallrye/openapi/common/deployment/SmallRyeOpenApiConfig.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,12 @@ public interface SmallRyeOpenApiConfig {
9494
@WithDefault("true")
9595
boolean autoAddTags();
9696

97+
/**
98+
* This will automatically add Bad Request (400 HTTP response) API response to operations with an input.
99+
*/
100+
@WithDefault("true")
101+
boolean autoAddBadRequestResponse();
102+
97103
/**
98104
* This will automatically add a summary to operations based on the Java method name.
99105
*/

extensions/smallrye-openapi/deployment/src/main/java/io/quarkus/smallrye/openapi/deployment/SmallRyeOpenApiProcessor.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -561,7 +561,8 @@ private OASFilter getOperationFilter(OpenApiFilteredIndexViewBuildItem indexView
561561
if (!classNamesMethods.isEmpty() || !rolesAllowedMethods.isEmpty() || !authenticatedMethods.isEmpty()) {
562562
return new OperationFilter(classNamesMethods, rolesAllowedMethods, authenticatedMethods,
563563
config.securitySchemeName(),
564-
config.autoAddTags(), config.autoAddOperationSummary(), isOpenApi_3_1_0_OrGreater(config));
564+
config.autoAddTags(), config.autoAddOperationSummary(), config.autoAddBadRequestResponse(),
565+
isOpenApi_3_1_0_OrGreater(config));
565566
}
566567

567568
return null;

extensions/smallrye-openapi/deployment/src/main/java/io/quarkus/smallrye/openapi/deployment/filter/OperationFilter.java

Lines changed: 142 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,23 @@
1717
import org.eclipse.microprofile.openapi.models.Operation;
1818
import org.eclipse.microprofile.openapi.models.PathItem;
1919
import org.eclipse.microprofile.openapi.models.Paths;
20+
import org.eclipse.microprofile.openapi.models.media.Content;
21+
import org.eclipse.microprofile.openapi.models.media.MediaType;
22+
import org.eclipse.microprofile.openapi.models.media.Schema;
2023
import org.eclipse.microprofile.openapi.models.responses.APIResponse;
2124
import org.eclipse.microprofile.openapi.models.responses.APIResponses;
2225
import org.eclipse.microprofile.openapi.models.security.SecurityRequirement;
2326
import org.eclipse.microprofile.openapi.models.security.SecurityScheme;
2427

2528
/**
26-
* This filter replaces the former AutoTagFilter and AutoRolesAllowedFilter and has three functions:
29+
* This filter has the following functions:
2730
* <ul>
28-
* <li>Add operation descriptions based on the associated Java method name handling the operation
29-
* <li>Add operation tags based on the associated Java class of the operation
31+
* <li>Add operation descriptions based on the associated Java method name handling the operation</li>
32+
* <li>Add operation tags based on the associated Java class of the operation</li>
3033
* <li>Add security requirements based on discovered {@link jakarta.annotation.security.RolesAllowed},
3134
* {@link io.quarkus.security.PermissionsAllowed}, and {@link io.quarkus.security.Authenticated}
32-
* annotations.
35+
* annotations. Also add the expected security responses if needed.</li>
36+
* <li>Add Bad Request (400) response for invalid input (if none is provided)</li>
3337
* </ul>
3438
*/
3539
public class OperationFilter implements OASFilter {
@@ -42,20 +46,22 @@ public class OperationFilter implements OASFilter {
4246
private final String defaultSecuritySchemeName;
4347
private final boolean doAutoTag;
4448
private final boolean doAutoOperation;
49+
private final boolean doAutoBadRequest;
4550
private final boolean alwaysIncludeScopesValidForScheme;
4651

4752
public OperationFilter(Map<String, ClassAndMethod> classNameMap,
4853
Map<String, List<String>> rolesAllowedMethodReferences,
4954
List<String> authenticatedMethodReferences,
5055
String defaultSecuritySchemeName,
51-
boolean doAutoTag, boolean doAutoOperation, boolean alwaysIncludeScopesValidForScheme) {
56+
boolean doAutoTag, boolean doAutoOperation, boolean doAutoBadRequest, boolean alwaysIncludeScopesValidForScheme) {
5257

5358
this.classNameMap = Objects.requireNonNull(classNameMap);
5459
this.rolesAllowedMethodReferences = Objects.requireNonNull(rolesAllowedMethodReferences);
5560
this.authenticatedMethodReferences = Objects.requireNonNull(authenticatedMethodReferences);
5661
this.defaultSecuritySchemeName = Objects.requireNonNull(defaultSecuritySchemeName);
5762
this.doAutoTag = doAutoTag;
5863
this.doAutoOperation = doAutoOperation;
64+
this.doAutoBadRequest = doAutoBadRequest;
5965
this.alwaysIncludeScopesValidForScheme = alwaysIncludeScopesValidForScheme;
6066
}
6167

@@ -77,18 +83,18 @@ public void filterOpenAPI(OpenAPI openAPI) {
7783
.map(Map.Entry::getValue)
7884
.map(PathItem::getOperations)
7985
.filter(Objects::nonNull)
80-
.map(Map::values)
81-
.flatMap(Collection::stream)
86+
.flatMap(operations -> operations.entrySet().stream())
8287
.forEach(operation -> {
83-
final String methodRef = methodRef(operation);
88+
final String methodRef = methodRef(operation.getValue());
8489

8590
if (methodRef != null) {
86-
maybeSetSummaryAndTag(operation, methodRef);
87-
maybeAddSecurityRequirement(operation, methodRef, schemeName, scopesValidForScheme,
91+
maybeSetSummaryAndTag(operation.getValue(), methodRef);
92+
maybeAddSecurityRequirement(operation.getValue(), methodRef, schemeName, scopesValidForScheme,
8893
defaultSecurityErrors);
94+
maybeAddBadRequestResponse(openAPI, operation, methodRef);
8995
}
9096

91-
operation.removeExtension(EXT_METHOD_REF);
97+
operation.getValue().removeExtension(EXT_METHOD_REF);
9298
});
9399
}
94100

@@ -97,6 +103,131 @@ private String methodRef(Operation operation) {
97103
return (String) (extensions != null ? extensions.get(EXT_METHOD_REF) : null);
98104
}
99105

106+
private void maybeAddBadRequestResponse(OpenAPI openAPI, Map.Entry<PathItem.HttpMethod, Operation> operation,
107+
String methodRef) {
108+
if (!classNameMap.containsKey(methodRef)) {
109+
return;
110+
}
111+
112+
if (doAutoBadRequest
113+
&& isPOSTorPUT(operation) // Only applies to PUT and POST
114+
&& hasBody(operation) // Only applies to input
115+
&& !isStringOrNumberOrBoolean(operation, openAPI) // Except String, Number and boolean
116+
&& !isFileUpload(operation, openAPI)) { // and file
117+
if (!operation.getValue().getResponses().hasAPIResponse("400")) { // Only when the user has not already added one
118+
operation.getValue().getResponses().addAPIResponse("400",
119+
OASFactory.createAPIResponse().description("Bad Request"));
120+
}
121+
}
122+
}
123+
124+
private boolean isPOSTorPUT(Map.Entry<PathItem.HttpMethod, Operation> operation) {
125+
return operation.getKey().equals(PathItem.HttpMethod.POST)
126+
|| operation.getKey().equals(PathItem.HttpMethod.PUT);
127+
}
128+
129+
private boolean hasBody(Map.Entry<PathItem.HttpMethod, Operation> operation) {
130+
return operation.getValue().getRequestBody() != null;
131+
}
132+
133+
private boolean isStringOrNumberOrBoolean(Map.Entry<PathItem.HttpMethod, Operation> operation, OpenAPI openAPI) {
134+
boolean isStringOrNumberOrBoolean = false;
135+
Content content = operation.getValue().getRequestBody().getContent();
136+
if (content != null) {
137+
for (MediaType mediaType : content.getMediaTypes().values()) {
138+
if (mediaType != null && mediaType.getSchema() != null) {
139+
Schema schema = mediaType.getSchema();
140+
141+
if (schema.getRef() != null
142+
|| (schema.getContentSchema() != null && schema.getContentSchema().getRef() != null))
143+
schema = resolveSchema(schema, openAPI.getComponents());
144+
if (isString(schema) || isNumber(schema) || isBoolean(schema)) {
145+
isStringOrNumberOrBoolean = true;
146+
}
147+
}
148+
}
149+
}
150+
return isStringOrNumberOrBoolean;
151+
}
152+
153+
private Schema resolveSchema(Schema schema, Components components) {
154+
while (schema != null) {
155+
// Resolve `$ref` schema
156+
if (schema.getRef() != null && components != null) {
157+
String refName = schema.getRef().replace("#/components/schemas/", "");
158+
schema = components.getSchemas().get(refName);
159+
if (schema == null)
160+
break;
161+
} else if (schema.getContentSchema() != null) {
162+
schema = schema.getContentSchema();
163+
continue;
164+
}
165+
166+
break;
167+
}
168+
return schema;
169+
}
170+
171+
private boolean isFileUpload(Map.Entry<PathItem.HttpMethod, Operation> operation, OpenAPI openAPI) {
172+
boolean isFile = false;
173+
Content content = operation.getValue().getRequestBody().getContent();
174+
if (content != null) {
175+
for (Map.Entry<String, MediaType> kv : content.getMediaTypes().entrySet()) {
176+
String mediaTypeKey = kv.getKey();
177+
if ("multipart/form-data".equals(mediaTypeKey) || "application/octet-stream".equals(mediaTypeKey)) {
178+
MediaType mediaType = kv.getValue();
179+
if (mediaType != null && mediaType.getSchema() != null) {
180+
if (isFileSchema(mediaType.getSchema(), openAPI.getComponents())) {
181+
isFile = true;
182+
}
183+
}
184+
}
185+
}
186+
}
187+
return isFile;
188+
}
189+
190+
private boolean isFileSchema(Schema schema, Components components) {
191+
if (isString(schema) && isBinaryFormat(schema)) {
192+
return true; // Direct file schema
193+
}
194+
if (isObject(schema) && schema.getProperties() != null) {
195+
// Check if it has a "file" property with type "string" and format "binary"
196+
return schema.getProperties().values().stream()
197+
.anyMatch(prop -> isString(prop) && isBinaryFormat(prop));
198+
}
199+
if (schema.getRef() != null && components != null) {
200+
// Resolve reference and check recursively
201+
String refName = schema.getRef().replace("#/components/schemas/", "");
202+
Schema referencedSchema = components.getSchemas().get(refName);
203+
if (referencedSchema != null) {
204+
return isFileSchema(referencedSchema, components);
205+
}
206+
}
207+
return false;
208+
}
209+
210+
private boolean isString(Schema schema) {
211+
return schema != null && schema.getType() != null && schema.getType().contains(Schema.SchemaType.STRING);
212+
}
213+
214+
private boolean isNumber(Schema schema) {
215+
return schema != null && schema.getType() != null && (schema.getType().contains(Schema.SchemaType.INTEGER)
216+
|| schema.getType().contains(Schema.SchemaType.NUMBER));
217+
}
218+
219+
private boolean isBoolean(Schema schema) {
220+
return schema != null && schema.getType() != null && schema.getType().contains(Schema.SchemaType.BOOLEAN);
221+
}
222+
223+
private boolean isObject(Schema schema) {
224+
return schema != null && schema.getType() != null && schema.getType().contains(Schema.SchemaType.OBJECT);
225+
}
226+
227+
private boolean isBinaryFormat(Schema schema) {
228+
return "binary".equals(schema.getFormat());
229+
}
230+
100231
private void maybeSetSummaryAndTag(Operation operation, String methodRef) {
101232
if (!classNameMap.containsKey(methodRef)) {
102233
return;

extensions/smallrye-openapi/deployment/src/test/java/io/quarkus/smallrye/openapi/deployment/filter/SecurityConfigFilterTest.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,11 @@ public boolean autoAddTags() {
127127
return false;
128128
}
129129

130+
@Override
131+
public boolean autoAddBadRequestResponse() {
132+
return false;
133+
}
134+
130135
@Override
131136
public boolean autoAddOperationSummary() {
132137
return false;
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package io.quarkus.smallrye.openapi.test.jaxrs;
2+
3+
import jakarta.ws.rs.Consumes;
4+
import jakarta.ws.rs.POST;
5+
import jakarta.ws.rs.PUT;
6+
import jakarta.ws.rs.Path;
7+
import jakarta.ws.rs.Produces;
8+
import jakarta.ws.rs.core.MediaType;
9+
import jakarta.ws.rs.core.Response;
10+
11+
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
12+
import org.eclipse.microprofile.openapi.annotations.responses.APIResponses;
13+
import org.jboss.resteasy.reactive.MultipartForm;
14+
import org.jboss.resteasy.reactive.PartType;
15+
import org.jboss.resteasy.reactive.RestForm;
16+
import org.jboss.resteasy.reactive.multipart.FileUpload;
17+
18+
@Path("/auto")
19+
public class AutoBadRequestResource {
20+
21+
@POST
22+
@Path("/")
23+
public void addBar(MyBean myBean) {
24+
}
25+
26+
@PUT
27+
@Path("/")
28+
public void updateBar(MyBean myBean) {
29+
}
30+
31+
@POST
32+
@Path("/string")
33+
public void addString(String foo) {
34+
}
35+
36+
@PUT
37+
@Path("/string")
38+
public void updateString(String foo) {
39+
}
40+
41+
@POST
42+
@Path("/file")
43+
@Consumes(MediaType.MULTIPART_FORM_DATA)
44+
@Produces(MediaType.TEXT_PLAIN)
45+
public Response uploadFile(@RestForm("file") FileUpload file) {
46+
return Response.accepted().build();
47+
}
48+
49+
@PUT
50+
@Path("/file")
51+
@Consumes(MediaType.MULTIPART_FORM_DATA)
52+
@Produces(MediaType.TEXT_PLAIN)
53+
public Response updateFile(@RestForm("file") FileUpload file) {
54+
return Response.accepted().build();
55+
}
56+
57+
@POST
58+
@Path("/multipart")
59+
@Consumes(MediaType.MULTIPART_FORM_DATA)
60+
@Produces(MediaType.TEXT_PLAIN)
61+
public Response uploadMultipart(@MultipartForm FileUploadForm form) {
62+
return Response.accepted().build();
63+
}
64+
65+
@PUT
66+
@Path("/multipart")
67+
@Consumes(MediaType.MULTIPART_FORM_DATA)
68+
@Produces(MediaType.TEXT_PLAIN)
69+
public Response updateMultipart(@MultipartForm FileUploadForm form) {
70+
return Response.accepted().build();
71+
}
72+
73+
@POST
74+
@Path("/provided")
75+
@APIResponses({
76+
@APIResponse(responseCode = "204", description = "Successful"),
77+
@APIResponse(responseCode = "400", description = "Invalid bean supplied")
78+
})
79+
public void addProvidedBar(MyBean myBean) {
80+
}
81+
82+
@PUT
83+
@Path("/provided")
84+
@APIResponses({
85+
@APIResponse(responseCode = "204", description = "Successful"),
86+
@APIResponse(responseCode = "400", description = "Invalid bean supplied")
87+
})
88+
public void updateProvidedBar(MyBean myBean) {
89+
}
90+
91+
@POST
92+
@Path("/nobody")
93+
public void addNobodyBar() {
94+
}
95+
96+
@PUT
97+
@Path("/nobody")
98+
public void updateNobodyBar() {
99+
}
100+
101+
private static class MyBean {
102+
public String bar;
103+
}
104+
105+
private static class FileUploadForm {
106+
@RestForm("file")
107+
@PartType(MediaType.APPLICATION_OCTET_STREAM)
108+
public byte[] file;
109+
110+
@RestForm("fileName")
111+
@PartType(MediaType.TEXT_PLAIN)
112+
public String fileName;
113+
}
114+
115+
}

0 commit comments

Comments
 (0)