diff --git a/gradle/license.gradle b/gradle/license.gradle index acefa92927..124920ceca 100644 --- a/gradle/license.gradle +++ b/gradle/license.gradle @@ -9,7 +9,7 @@ license { java = 'SLASHSTAR_STYLE' groovy = 'SLASHSTAR_STYLE' } - ext.year = '2017-2024' + ext.year = '2017-2025' exclude "**/transaction/**" exclude '**/*.txt' diff --git a/openapi/src/main/java/io/micronaut/openapi/postprocessors/OpenApiOperationsPostProcessor.java b/openapi/src/main/java/io/micronaut/openapi/postprocessors/OpenApiOperationsPostProcessor.java index 2ddc8baa95..3dd9f8eecb 100644 --- a/openapi/src/main/java/io/micronaut/openapi/postprocessors/OpenApiOperationsPostProcessor.java +++ b/openapi/src/main/java/io/micronaut/openapi/postprocessors/OpenApiOperationsPostProcessor.java @@ -15,12 +15,18 @@ */ package io.micronaut.openapi.postprocessors; +import io.micronaut.context.exceptions.ConfigurationException; import io.micronaut.core.util.CollectionUtils; +import io.micronaut.inject.visitor.VisitorContext; +import io.micronaut.openapi.visitor.ConfigUtils; +import io.micronaut.openapi.visitor.OperationUtils; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; import java.util.HashMap; +import static io.micronaut.openapi.visitor.ConfigUtils.getOperationDuplicateResolution; import static io.micronaut.openapi.visitor.StringUtil.UNDERSCORE; /** @@ -32,33 +38,59 @@ public class OpenApiOperationsPostProcessor { * Process operations, making operation ids unique. * * @param openApi OpenApi object with all definitions + * @param context The visitor context */ - public void processOperations(OpenAPI openApi) { + public void processOperations(OpenAPI openApi, VisitorContext context) { if (CollectionUtils.isEmpty(openApi.getPaths())) { return; } + var operationPathsById = new HashMap(); var operationIdsIndex = new HashMap(); - for (var pathItem : openApi.getPaths().values()) { - for (var operation : pathItem.readOperations()) { + for (var pathItemEntry : openApi.getPaths().entrySet()) { + var pathItem = pathItemEntry.getValue(); + var path = pathItemEntry.getKey(); + var operations = pathItem.readOperationsMap(); + for (var opEntry : operations.entrySet()) { + var method = opEntry.getKey(); + var operation = opEntry.getValue(); String operationId = operation.getOperationId(); + if (operationId == null) { + continue; + } + var operationPath = operationPath(method, path); if (!operationIdsIndex.containsKey(operationId)) { operationIdsIndex.put(operationId, 1); + operationPathsById.put(operationId, operationPath); continue; } int nextValue = operationIdsIndex.get(operationId); + if (getOperationDuplicateResolution(context) == ConfigUtils.DuplicateResolution.ERROR) { + var existedOperationPath = operationPathsById.get(operationId); + var methods = OperationUtils.getMethodsByOperationId(operationId); + var methodsMessage = ""; + if (CollectionUtils.isNotEmpty(methods)) { + methodsMessage = "\nMethods: " + String.join(", ", methods); + } + throw new ConfigurationException("Found 2 operations with same ID \"" + operationId + "\" for paths " + existedOperationPath + " and " + operationPath + methodsMessage); + } String newOperationId = operationId + UNDERSCORE + nextValue; operation.setOperationId(newOperationId); updateResponseDescription(operation, operationId, newOperationId); operationIdsIndex.put(operationId, ++nextValue); + operationPathsById.put(newOperationId, operationPath); } } } + private String operationPath(PathItem.HttpMethod method, String path) { + return method + " " + path; + } + private static void updateResponseDescription(Operation operation, String originalId, String newOperationId) { if (CollectionUtils.isEmpty(operation.getResponses())) { return; 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 6f822775f3..68c2b5123c 100644 --- a/openapi/src/main/java/io/micronaut/openapi/visitor/AbstractOpenApiEndpointVisitor.java +++ b/openapi/src/main/java/io/micronaut/openapi/visitor/AbstractOpenApiEndpointVisitor.java @@ -1526,9 +1526,9 @@ private Pair readWebhook(@Nullable AnnotationValue we return Pair.of(name, pathItem); } - private Map readOperations(String path, HttpMethod httpMethod, List pathItems, MethodElement element, VisitorContext context, @Nullable ClassElement jsonViewClass) { + private Map readOperations(String path, HttpMethod httpMethod, List pathItems, MethodElement methodEl, VisitorContext context, @Nullable ClassElement jsonViewClass) { var swaggerOperations = new HashMap(pathItems.size()); - var operationAnn = element.findAnnotation(io.swagger.v3.oas.annotations.Operation.class).orElse(null); + var operationAnn = methodEl.findAnnotation(io.swagger.v3.oas.annotations.Operation.class).orElse(null); for (PathItem pathItem : pathItems) { var swaggerOperation = operationAnn != null ? toValue(operationAnn.getAnnotationName(), operationAnn.getValues(), context, Operation.class, jsonViewClass) : null; @@ -1536,13 +1536,13 @@ private Map readOperations(String path, HttpMethod httpMeth swaggerOperation = new Operation(); } - addOperationDeprecatedExtension(element, swaggerOperation, context); + addOperationDeprecatedExtension(methodEl, swaggerOperation, context); if (CollectionUtils.isNotEmpty(swaggerOperation.getParameters())) { swaggerOperation.getParameters().removeIf(Objects::isNull); } - ParameterElement[] methodParams = element.getParameters(); + ParameterElement[] methodParams = methodEl.getParameters(); if (ArrayUtils.isNotEmpty(methodParams) && operationAnn != null) { var paramAnns = operationAnn.getAnnotations(PROP_PARAMETERS, io.swagger.v3.oas.annotations.Parameter.class); if (CollectionUtils.isNotEmpty(paramAnns)) { @@ -1593,7 +1593,7 @@ private Map readOperations(String path, HttpMethod httpMeth swaggerParam.setAllowReserved(true); } paramAnn.stringValue(PROP_EXAMPLE).ifPresent(swaggerParam::setExample); - var examples = readExamples(paramAnn.getAnnotations(PROP_EXAMPLES, ExampleObject.class), element, context); + var examples = readExamples(paramAnn.getAnnotations(PROP_EXAMPLES, ExampleObject.class), methodEl, context); if (examples != null) { examples.forEach(swaggerParam::addExample); } @@ -1622,7 +1622,7 @@ private Map readOperations(String path, HttpMethod httpMeth String prefix; String suffix; boolean addAlways; - var apiDecoratorAnn = element.getDeclaredAnnotation(OpenAPIDecorator.class); + var apiDecoratorAnn = methodEl.getDeclaredAnnotation(OpenAPIDecorator.class); if (apiDecoratorAnn != null) { prefix = apiDecoratorAnn.stringValue().orElse(StringUtils.EMPTY_STRING); if (prefix.isEmpty()) { @@ -1642,11 +1642,13 @@ private Map readOperations(String path, HttpMethod httpMeth } if (StringUtils.isEmpty(swaggerOperation.getOperationId())) { - swaggerOperation.setOperationId(prefix + element.getName() + postfix + suffix); + swaggerOperation.setOperationId(prefix + methodEl.getName() + postfix + suffix); } else if (addAlways) { swaggerOperation.setOperationId(prefix + swaggerOperation.getOperationId() + postfix + suffix); } + OperationUtils.addOperation(swaggerOperation.getOperationId(), methodEl); + if (swaggerOperation.getDescription() != null && swaggerOperation.getDescription().isEmpty()) { swaggerOperation.setDescription(null); } diff --git a/openapi/src/main/java/io/micronaut/openapi/visitor/ConfigUtils.java b/openapi/src/main/java/io/micronaut/openapi/visitor/ConfigUtils.java index 4bc39b678e..81d05d2c96 100644 --- a/openapi/src/main/java/io/micronaut/openapi/visitor/ConfigUtils.java +++ b/openapi/src/main/java/io/micronaut/openapi/visitor/ConfigUtils.java @@ -128,6 +128,7 @@ import static io.micronaut.openapi.visitor.OpenApiConfigProperty.MICRONAUT_OPENAPI_GENERATOR_EXTENSIONS_ENABLED; import static io.micronaut.openapi.visitor.OpenApiConfigProperty.MICRONAUT_OPENAPI_GROUPS; import static io.micronaut.openapi.visitor.OpenApiConfigProperty.MICRONAUT_OPENAPI_JSON_VIEW_DEFAULT_INCLUSION; +import static io.micronaut.openapi.visitor.OpenApiConfigProperty.MICRONAUT_OPENAPI_OPERATION_DUPLICATE_RESOLUTION; import static io.micronaut.openapi.visitor.OpenApiConfigProperty.MICRONAUT_OPENAPI_PROJECT_DIR; import static io.micronaut.openapi.visitor.OpenApiConfigProperty.MICRONAUT_OPENAPI_PROPERTY_INCLUDE; import static io.micronaut.openapi.visitor.OpenApiConfigProperty.MICRONAUT_OPENAPI_PROPERTY_NAMING_STRATEGY; @@ -371,6 +372,14 @@ public static DuplicateResolution getSchemaDuplicateResolution(VisitorContext co return DuplicateResolution.AUTO; } + public static DuplicateResolution getOperationDuplicateResolution(VisitorContext context) { + var value = getConfigProperty(MICRONAUT_OPENAPI_OPERATION_DUPLICATE_RESOLUTION, context); + if (StringUtils.isNotEmpty(value) && DuplicateResolution.ERROR.name().equalsIgnoreCase(value)) { + return DuplicateResolution.ERROR; + } + return DuplicateResolution.AUTO; + } + public static boolean isConstructorArgumentsAsRequired(VisitorContext context) { return getBooleanProperty(MICRONAUT_OPENAPI_CONSTRUCTOR_ARGUMENTS_AS_REQUIRED, true, context); } diff --git a/openapi/src/main/java/io/micronaut/openapi/visitor/OpenApiApplicationVisitor.java b/openapi/src/main/java/io/micronaut/openapi/visitor/OpenApiApplicationVisitor.java index 625b72dc21..84a29be37c 100644 --- a/openapi/src/main/java/io/micronaut/openapi/visitor/OpenApiApplicationVisitor.java +++ b/openapi/src/main/java/io/micronaut/openapi/visitor/OpenApiApplicationVisitor.java @@ -834,7 +834,7 @@ private OpenAPI postProcessOpenApi(OpenAPI openApi, VisitorContext context) { normalizeOpenApi(openApi, context); // Process after sorting so order is stable new JacksonDiscriminatorPostProcessor().addMissingDiscriminatorType(openApi); - new OpenApiOperationsPostProcessor().processOperations(openApi); + new OpenApiOperationsPostProcessor().processOperations(openApi, context); removeUnusedSchemas(openApi); diff --git a/openapi/src/main/java/io/micronaut/openapi/visitor/OpenApiConfigProperty.java b/openapi/src/main/java/io/micronaut/openapi/visitor/OpenApiConfigProperty.java index 37663fca7d..72f0e643bb 100644 --- a/openapi/src/main/java/io/micronaut/openapi/visitor/OpenApiConfigProperty.java +++ b/openapi/src/main/java/io/micronaut/openapi/visitor/OpenApiConfigProperty.java @@ -281,6 +281,15 @@ public interface OpenApiConfigProperty { * Default: false */ String MICRONAUT_OPENAPI_SWAGGER_FILE_GENERATION_ENABLED = "micronaut.openapi.swagger.file.generation.enabled"; + /** + * System property to set operation duplicate resolution. Available values: + * - auto - micronaut-openapi automatically add index suffix to duplicate operation ID. + * - error - micronaut-openapi throws an exception when found duplicate operation ID. + *
+ * Default: auto + */ + String MICRONAUT_OPENAPI_OPERATION_DUPLICATE_RESOLUTION = "micronaut.openapi.operation.duplicate-resolution"; + /** * System property that enables extra schema processing. */ diff --git a/openapi/src/main/java/io/micronaut/openapi/visitor/OperationUtils.java b/openapi/src/main/java/io/micronaut/openapi/visitor/OperationUtils.java new file mode 100644 index 0000000000..b7b5be9563 --- /dev/null +++ b/openapi/src/main/java/io/micronaut/openapi/visitor/OperationUtils.java @@ -0,0 +1,63 @@ +/* + * Copyright 2017-2025 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.openapi.visitor; + +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.inject.ast.MethodElement; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Methods to construct OpenPI schema definition. + * + * @since 6.19.2 + */ +@Internal +public final class OperationUtils { + + /** + * Stores the current in progress type. + */ + private static Map> methodsByOperationId = new HashMap<>(); + + private OperationUtils() { + } + + /** + * Cleanup context. + */ + public static void clean() { + methodsByOperationId = new HashMap<>(); + } + + @Nullable + public static List getMethodsByOperationId(String operationId) { + return methodsByOperationId.get(operationId); + } + + public static void addOperation(@Nullable String operationId, MethodElement methodEl) { + if (operationId == null) { + return; + } + var existedList = methodsByOperationId.computeIfAbsent(operationId, k -> new ArrayList<>()); + existedList.add(methodEl.getOwningType().getName() + '.' + methodEl.getName()); + } + +} diff --git a/openapi/src/main/java/io/micronaut/openapi/visitor/Utils.java b/openapi/src/main/java/io/micronaut/openapi/visitor/Utils.java index 0a54c82e7e..37f661a4db 100644 --- a/openapi/src/main/java/io/micronaut/openapi/visitor/Utils.java +++ b/openapi/src/main/java/io/micronaut/openapi/visitor/Utils.java @@ -410,6 +410,7 @@ public static void clean() { testJsonReference = null; creatorConstructorsCache = new HashMap<>(); System.clearProperty(BIND_TYPE_AND_TYPES); + OperationUtils.clean(); SchemaDefinitionUtils.clean(); OpenApiExtraSchemaVisitor.clean(); OpenApiExcludeVisitor.clean(); diff --git a/openapi/src/test/groovy/io/micronaut/openapi/visitor/OpenApiOperationIdSpec.groovy b/openapi/src/test/groovy/io/micronaut/openapi/visitor/OpenApiOperationIdSpec.groovy new file mode 100644 index 0000000000..002f8f6028 --- /dev/null +++ b/openapi/src/test/groovy/io/micronaut/openapi/visitor/OpenApiOperationIdSpec.groovy @@ -0,0 +1,50 @@ +package io.micronaut.openapi.visitor + + +import io.micronaut.openapi.AbstractOpenApiTypeElementSpec +import spock.util.environment.RestoreSystemProperties + +import static io.micronaut.openapi.visitor.OpenApiConfigProperty.MICRONAUT_OPENAPI_OPERATION_DUPLICATE_RESOLUTION + +class OpenApiOperationIdSpec extends AbstractOpenApiTypeElementSpec { + + @RestoreSystemProperties + void "test duplicate operation ID resolution ERROR"() { + given: + System.setProperty(MICRONAUT_OPENAPI_OPERATION_DUPLICATE_RESOLUTION, ConfigUtils.DuplicateResolution.ERROR.name()) + + when: + buildBeanDefinition('test.MyBean', ''' +package test; + +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; + +@Controller("/resourceA") +class ControllerA { + + @Get + String getResource() { + return "test1"; + } +} + +@Controller("/resourceB") +class ControllerB { + + @Get + String getResource() { + return "test2"; + } +} + +@jakarta.inject.Singleton +class MyBean {} +''') + + then: + def e = thrown(RuntimeException) + e.message.contains("Found 2 operations with same ID \"getResource\" for paths GET /resourceA and GET /resourceB") + } +} + diff --git a/src/main/docs/guide/configuration/availableOptions.adoc b/src/main/docs/guide/configuration/availableOptions.adoc index 3b1c6c958b..65b03419ef 100644 --- a/src/main/docs/guide/configuration/availableOptions.adoc +++ b/src/main/docs/guide/configuration/availableOptions.adoc @@ -62,6 +62,9 @@ You can set your custom paths separated by `,`. To set absolute paths use prefix classpath paths use prefix `classpath:` or use prefix `project:` to set paths from project directory. | |`*micronaut.openapi.docs.format*` | System property to set Javadoc / KDoc conversion mode. Available values: **PLAIN**, **HTML_TO_MD**, **MD_TO_HTML**. + | Default: `HTML_TO_MD` +|`*micronaut.openapi.operation.duplicate-resolution*` | System property to set operation ID duplicate resolution. Available values: + +`*auto*` - micronaut-openapi automatically add index suffix to duplicate operation ID. + +`*error*` - micronaut-openapi throws an exception when found duplicate operation IDs. | Default: `auto` |`*micronaut.openapi.schema.extra.enable*` | If this property true, you can add some extra schemas to final OpenAPI spec file. | Default: `false` |`*micronaut.openapi.schema.duplicate-resolution*` | System property to set schema duplicate resolution. Available values: + `*auto*` - micronaut-openapi automatically add index suffix to duplicate schema. +