diff --git a/openapi/src/main/java/io/micronaut/openapi/visitor/AbstractOpenApiEndpointVisitor.java b/openapi/src/main/java/io/micronaut/openapi/visitor/AbstractOpenApiEndpointVisitor.java index 653006dccc..59f0a45274 100644 --- a/openapi/src/main/java/io/micronaut/openapi/visitor/AbstractOpenApiEndpointVisitor.java +++ b/openapi/src/main/java/io/micronaut/openapi/visitor/AbstractOpenApiEndpointVisitor.java @@ -163,6 +163,12 @@ import static io.micronaut.openapi.visitor.OpenApiModelProp.PROP_SCHEMA; import static io.micronaut.openapi.visitor.OpenApiModelProp.PROP_STYLE; import static io.micronaut.openapi.visitor.OpenApiModelProp.PROP_VALUE; +import static io.micronaut.openapi.visitor.SchemaDefinitionUtils.bindSchemaAnnotationValue; +import static io.micronaut.openapi.visitor.SchemaDefinitionUtils.bindSchemaForElement; +import static io.micronaut.openapi.visitor.SchemaDefinitionUtils.processSchemaProperty; +import static io.micronaut.openapi.visitor.SchemaDefinitionUtils.resolveSchema; +import static io.micronaut.openapi.visitor.SchemaDefinitionUtils.toValue; +import static io.micronaut.openapi.visitor.SchemaDefinitionUtils.toValueMap; import static io.micronaut.openapi.visitor.SchemaUtils.COMPONENTS_CALLBACKS_PREFIX; import static io.micronaut.openapi.visitor.SchemaUtils.TYPE_OBJECT; import static io.micronaut.openapi.visitor.SchemaUtils.getOperationOnPathItem; @@ -173,12 +179,6 @@ import static io.micronaut.openapi.visitor.StringUtil.CLOSE_BRACE; import static io.micronaut.openapi.visitor.StringUtil.OPEN_BRACE; import static io.micronaut.openapi.visitor.StringUtil.THREE_DOTS; -import static io.micronaut.openapi.visitor.SchemaDefinitionUtils.bindSchemaAnnotationValue; -import static io.micronaut.openapi.visitor.SchemaDefinitionUtils.bindSchemaForElement; -import static io.micronaut.openapi.visitor.SchemaDefinitionUtils.processSchemaProperty; -import static io.micronaut.openapi.visitor.SchemaDefinitionUtils.resolveSchema; -import static io.micronaut.openapi.visitor.SchemaDefinitionUtils.toValue; -import static io.micronaut.openapi.visitor.SchemaDefinitionUtils.toValueMap; import static io.micronaut.openapi.visitor.Utils.DEFAULT_MEDIA_TYPES; import static io.micronaut.openapi.visitor.Utils.getMediaType; import static io.micronaut.openapi.visitor.Utils.resolveWebhooks; @@ -295,7 +295,6 @@ private boolean containsTag(String name, List tags) { * * @param element The MethodElement. * @param context The context. - * * @return The security requirements. */ protected abstract List methodSecurityRequirements(MethodElement element, VisitorContext context); @@ -305,7 +304,6 @@ private boolean containsTag(String name, List tags) { * * @param element The MethodElement. * @param context The context. - * * @return The servers. */ protected abstract List methodServers(MethodElement element, VisitorContext context); @@ -315,7 +313,6 @@ private boolean containsTag(String name, List tags) { * * @param element The ClassElement. * @param context The context. - * * @return The class tags. */ protected abstract List classTags(ClassElement element, VisitorContext context); @@ -325,7 +322,6 @@ private boolean containsTag(String name, List tags) { * * @param element The ClassElement. * @param context The context. - * * @return true if the specified element should not be processed. */ protected abstract boolean ignore(ClassElement element, VisitorContext context); @@ -335,7 +331,6 @@ private boolean containsTag(String name, List tags) { * * @param element The ClassElement. * @param context The context. - * * @return true if the specified element should not be processed. */ protected abstract boolean ignore(MethodElement element, VisitorContext context); @@ -344,7 +339,6 @@ private boolean containsTag(String name, List tags) { * Returns the HttpMethod of the element. * * @param element The MethodElement. - * * @return The HttpMethod of the element. */ protected abstract HttpMethod httpMethod(MethodElement element); @@ -354,7 +348,6 @@ private boolean containsTag(String name, List tags) { * * @param element The MethodElement. * @param context The context - * * @return The uri paths of the element. */ protected abstract List uriMatchTemplates(MethodElement element, VisitorContext context); @@ -363,7 +356,6 @@ private boolean containsTag(String name, List tags) { * Returns the consumes media types. * * @param element The MethodElement. - * * @return The consumes media types. */ protected abstract List consumesMediaTypes(MethodElement element); @@ -372,7 +364,6 @@ private boolean containsTag(String name, List tags) { * Returns the produces media types. * * @param element The MethodElement. - * * @return The produces media types. */ protected abstract List producesMediaTypes(MethodElement element); @@ -381,7 +372,6 @@ private boolean containsTag(String name, List tags) { * Returns the description for the element. * * @param element The MethodElement. - * * @return The description for the element. */ protected abstract String description(MethodElement element); @@ -420,7 +410,7 @@ public void visitMethod(MethodElement element, VisitorContext context) { return; } incrementVisitedElements(context); - OpenAPI openAPI = Utils.resolveOpenApi(context); + OpenAPI openApi = Utils.resolveOpenApi(context); JavadocDescription javadocDescription; boolean permitsRequestBody = HttpMethod.permitsRequestBody(httpMethod); @@ -445,14 +435,12 @@ public void visitMethod(MethodElement element, VisitorContext context) { var webhookValue = element.getAnnotation(Webhook.class); var webhookPair = readWebhook(webhookValue, httpMethod, context); if (webhookPair != null) { - resolveWebhooks(openAPI).put(webhookPair.getFirst(), webhookPair.getSecond()); + resolveWebhooks(openApi).put(webhookPair.getFirst(), webhookPair.getSecond()); } for (Map.Entry> pathItemEntry : pathItemsMap.entrySet()) { List pathItems = pathItemEntry.getValue(); - final OpenAPI openApi = Utils.resolveOpenApi(context); - Map swaggerOperations = readOperations(pathItemEntry.getKey(), httpMethod, pathItems, element, context, jsonViewClass); for (Map.Entry operationEntry : swaggerOperations.entrySet()) { @@ -465,7 +453,7 @@ public void visitMethod(MethodElement element, VisitorContext context) { swaggerOperation.setExternalDocs(externalDocs); } - readTags(element, context, swaggerOperation, classTags == null ? Collections.emptyList() : classTags, openAPI); + readTags(element, context, swaggerOperation, classTags == null ? Collections.emptyList() : classTags, openApi); readSecurityRequirements(element, pathItemEntry.getKey(), swaggerOperation, context); @@ -481,7 +469,7 @@ public void visitMethod(MethodElement element, VisitorContext context) { swaggerOperation.setDeprecated(true); } - readResponse(element, context, openAPI, swaggerOperation, javadocDescription, jsonViewClass); + readResponse(element, context, openApi, swaggerOperation, javadocDescription, jsonViewClass); boolean isRequestBodySchemaSet = false; @@ -504,23 +492,28 @@ public void visitMethod(MethodElement element, VisitorContext context) { setOperationOnPathItem(operationEntry.getKey(), httpMethod, swaggerOperation); + var queryParams = new HashMap(); var pathVariables = new HashMap(); for (UriMatchTemplate matchTemplate : matchTemplates) { - for (Map.Entry varEntry : pathVariables(matchTemplate).entrySet()) { + for (Map.Entry varEntry : uriVariables(matchTemplate).entrySet()) { if (pathItemEntry.getKey().contains(OPEN_BRACE + varEntry.getKey() + CLOSE_BRACE)) { pathVariables.put(varEntry.getKey(), varEntry.getValue()); } + if (varEntry.getValue().isQuery()) { + queryParams.put(varEntry.getKey(), varEntry.getValue()); + } } // @Parameters declared at method level take precedence over the declared as method arguments, so we process them first - processParameterAnnotationInMethod(element, openAPI, matchTemplate, httpMethod, swaggerOperation, pathVariables, context); + processParameterAnnotationInMethod(element, openApi, matchTemplate, httpMethod, swaggerOperation, pathVariables, context); } var extraBodyParameters = new ArrayList(); for (Operation operation : swaggerOperations.values()) { - processParameters(element, context, openAPI, operation, javadocDescription, permitsRequestBody, pathVariables, consumesMediaTypes, extraBodyParameters, httpMethod, matchTemplates, pathItems); - processExtraBodyParameters(context, httpMethod, openAPI, operation, javadocDescription, isRequestBodySchemaSet, consumesMediaTypes, extraBodyParameters); + processParameters(element, context, openApi, operation, javadocDescription, permitsRequestBody, pathVariables, consumesMediaTypes, extraBodyParameters, httpMethod, matchTemplates, pathItems); + processExtraBodyParameters(context, httpMethod, openApi, operation, javadocDescription, isRequestBodySchemaSet, consumesMediaTypes, extraBodyParameters); processMicronautVersionAndGroup(operation, pathItemEntry.getKey(), httpMethod, consumesMediaTypes, producesMediaTypes, element, context); + addParamsByUriTemplate(pathItemEntry.getKey(), pathVariables, queryParams, swaggerOperation); } if (webhookPair != null) { @@ -530,6 +523,53 @@ public void visitMethod(MethodElement element, VisitorContext context) { } } + private void addParamsByUriTemplate(String path, Map pathVariables, + Map queryParams, + Operation operation) { + + // check path variables in URL template which do not map to method parameters + for (var entry : pathVariables.entrySet()) { + var varName = entry.getKey(); + var pathVar = entry.getValue(); + if (pathVar.isExploded() + || !path.contains(OPEN_BRACE + varName + CLOSE_BRACE) + || isAlreadyAdded(varName, operation)) { + continue; + } + + operation.addParametersItem(new Parameter() + .in(ParameterIn.PATH.toString()) + .name(varName) + .required(true) + .schema(PrimitiveType.STRING.createProperty())); + } + + for (var entry : queryParams.entrySet()) { + var varName = entry.getKey(); + var pathVar = entry.getValue(); + if (pathVar.isExploded() || isAlreadyAdded(varName, operation)) { + continue; + } + + operation.addParametersItem(new Parameter() + .in(ParameterIn.QUERY.toString()) + .name(varName) + .schema(PrimitiveType.STRING.createProperty())); + } + } + + private boolean isAlreadyAdded(String paramName, Operation operation) { + if (CollectionUtils.isEmpty(operation.getParameters())) { + return false; + } + for (var param : operation.getParameters()) { + if (param.getName().equals(paramName)) { + return true; + } + } + return false; + } + private void processExtraBodyParameters(VisitorContext context, HttpMethod httpMethod, OpenAPI openAPI, Operation swaggerOperation, JavadocDescription javadocDescription, @@ -645,8 +685,8 @@ private boolean alreadyProcessedParameter(List swaggerParameters, Par } private Map readExamples(List> exampleAnns, - Element element, - VisitorContext context) { + Element element, + VisitorContext context) { if (CollectionUtils.isEmpty(exampleAnns)) { return null; } @@ -730,7 +770,6 @@ private void processParameterAnnotationInMethod(MethodElement element, parameter.setIn(ParameterIn.QUERY.toString()); } } - } } } @@ -1279,7 +1318,7 @@ private ClassElement returnType(MethodElement element, VisitorContext context) { return returnType; } - private Map pathVariables(UriMatchTemplate matchTemplate) { + private Map uriVariables(UriMatchTemplate matchTemplate) { List pv = matchTemplate.getVariables(); var pathVariables = new LinkedHashMap(pv.size()); for (UriMatchVariable variable : pv) { @@ -1315,8 +1354,8 @@ private JavadocDescription getMethodDescription(MethodElement element, } private Pair readWebhook(@Nullable AnnotationValue webhookAnnValue, - HttpMethod httpMethod, - VisitorContext context) { + HttpMethod httpMethod, + VisitorContext context) { if (webhookAnnValue == null) { return null; } diff --git a/openapi/src/test/groovy/io/micronaut/openapi/visitor/OpenApiControllerVisitorSpec.groovy b/openapi/src/test/groovy/io/micronaut/openapi/visitor/OpenApiControllerVisitorSpec.groovy index 8285317c0b..4c4c3afc1f 100644 --- a/openapi/src/test/groovy/io/micronaut/openapi/visitor/OpenApiControllerVisitorSpec.groovy +++ b/openapi/src/test/groovy/io/micronaut/openapi/visitor/OpenApiControllerVisitorSpec.groovy @@ -2220,4 +2220,165 @@ class MyBean {} examples.example2.value.p21 == "v1" examples.example2.value.p22 == 123 } + + void "test route parameter, but no matching method argument"() { + given: + buildBeanDefinition('test.MyBean', ''' +package test; + +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Post; +import jakarta.inject.Singleton; + +@Controller("/test") +class TestController { + + @Post("{id}") + HttpResponse reqPathVar() { + return HttpResponse.ok(); + } + + @Post("endpoint2{/opt1,opt2}") + HttpResponse optPathVars() { + return HttpResponse.ok(); + } + + @Post("endpoint3{?opt1,opt2}") + HttpResponse optQueryParams() { + return HttpResponse.ok(); + } +} + +@Singleton +class MyBean {} +''') + when: "The OpenAPI is retrieved" + def openApi = Utils.testReference + + then: "the state is correct" + openApi != null + + when: + def reqPathVarOp = openApi.paths.get("/test/{id}").post + def optPathVarsOp1 = openApi.paths.get("/test/endpoint2/{opt1}").post + def optPathVarsOp2 = openApi.paths.get("/test/endpoint2/{opt1}/{opt2}").post + def optQueryParamsOp = openApi.paths.get("/test/endpoint3").post + + then: + reqPathVarOp + reqPathVarOp.parameters + reqPathVarOp.parameters.size() == 1 + reqPathVarOp.parameters[0].in == "path" + reqPathVarOp.parameters[0].name == "id" + reqPathVarOp.parameters[0].schema + reqPathVarOp.parameters[0].schema.type == "string" + reqPathVarOp.parameters[0].required + + optPathVarsOp1 + optPathVarsOp1.parameters + optPathVarsOp1.parameters.size() == 1 + optPathVarsOp1.parameters[0].in == "path" + optPathVarsOp1.parameters[0].name == "opt1" + optPathVarsOp1.parameters[0].schema + optPathVarsOp1.parameters[0].schema.type == "string" + optPathVarsOp1.parameters[0].required + + optPathVarsOp2 + optPathVarsOp2.parameters + optPathVarsOp2.parameters.size() == 2 + optPathVarsOp2.parameters[0].in == "path" + optPathVarsOp2.parameters[0].name == "opt1" + optPathVarsOp2.parameters[0].schema + optPathVarsOp2.parameters[0].schema.type == "string" + optPathVarsOp2.parameters[0].required + optPathVarsOp2.parameters[1].in == "path" + optPathVarsOp2.parameters[1].name == "opt2" + optPathVarsOp2.parameters[1].schema + optPathVarsOp2.parameters[1].schema.type == "string" + optPathVarsOp2.parameters[1].required + + optQueryParamsOp + optQueryParamsOp.parameters + optQueryParamsOp.parameters.size() == 2 + optQueryParamsOp.parameters[0].in == "query" + optQueryParamsOp.parameters[0].name == "opt1" + optQueryParamsOp.parameters[0].schema + optQueryParamsOp.parameters[0].schema.type == "string" + !optQueryParamsOp.parameters[0].required + optQueryParamsOp.parameters[1].in == "query" + optQueryParamsOp.parameters[1].name == "opt2" + optQueryParamsOp.parameters[1].schema + optQueryParamsOp.parameters[1].schema.type == "string" + !optQueryParamsOp.parameters[1].required + } + + void "test @Body method argument has precedence over other arguments"() { + given: + buildBeanDefinition('test.MyBean', ''' +package test; + +import io.micronaut.core.convert.ArgumentConversionContext; +import io.micronaut.core.type.Argument; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Body; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Post; +import io.micronaut.http.bind.binders.TypedRequestArgumentBinder; +import io.micronaut.serde.annotation.Serdeable; +import io.swagger.v3.oas.annotations.Parameter;import jakarta.inject.Singleton; + +import java.util.Optional; + +@Serdeable +record SimpleBody(String value) {} + +@Serdeable +record FilterProvidedArgument(String id) {} + +@Singleton +class TestArgumentBinder implements TypedRequestArgumentBinder { + @Override + public Argument argumentType() { + return Argument.of(FilterProvidedArgument.class); + } + + @Override + public BindingResult bind(ArgumentConversionContext context, HttpRequest source) { + return () -> Optional.of(new FilterProvidedArgument("my-id")); + } +} + +@Controller("/test") +class TestController { + @Post + HttpResponse save(@Body SimpleBody body, @Parameter(hidden = true) FilterProvidedArgument filterProvidedArgument) { + return HttpResponse.ok(body); + } +} + +@Singleton +class MyBean {} +''') + when: "The OpenAPI is retrieved" + OpenAPI openAPI = Utils.testReference + + then: "the state is correct" + openAPI != null + + when: + Operation operation = openAPI.paths.get("/test").post + + then: + operation + + and: + operation.requestBody + operation.requestBody.content + operation.requestBody.content.size() == 1 + operation.requestBody.content."application/json" + operation.requestBody.content."application/json".schema + operation.requestBody.content."application/json".schema.$ref == "#/components/schemas/SimpleBody" + } }