Skip to content

Commit 5e10e53

Browse files
committed
Added ability to set operation duplicate resolution
Fixed #2440
1 parent 281abeb commit 5e10e53

File tree

10 files changed

+188
-12
lines changed

10 files changed

+188
-12
lines changed

gradle/license.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ license {
99
java = 'SLASHSTAR_STYLE'
1010
groovy = 'SLASHSTAR_STYLE'
1111
}
12-
ext.year = '2017-2024'
12+
ext.year = '2017-2025'
1313

1414
exclude "**/transaction/**"
1515
exclude '**/*.txt'

openapi/src/main/java/io/micronaut/openapi/postprocessors/OpenApiOperationsPostProcessor.java

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,19 @@
1515
*/
1616
package io.micronaut.openapi.postprocessors;
1717

18+
import io.micronaut.context.exceptions.ConfigurationException;
1819
import io.micronaut.core.util.CollectionUtils;
20+
import io.micronaut.inject.visitor.VisitorContext;
21+
import io.micronaut.openapi.visitor.ConfigUtils;
22+
import io.micronaut.openapi.visitor.OperationUtils;
1923
import io.swagger.v3.oas.models.OpenAPI;
2024
import io.swagger.v3.oas.models.Operation;
25+
import io.swagger.v3.oas.models.PathItem;
2126

27+
import java.awt.datatransfer.StringSelection;
2228
import java.util.HashMap;
2329

30+
import static io.micronaut.openapi.visitor.ConfigUtils.getOperationDuplicateResolution;
2431
import static io.micronaut.openapi.visitor.StringUtil.UNDERSCORE;
2532

2633
/**
@@ -32,33 +39,59 @@ public class OpenApiOperationsPostProcessor {
3239
* Process operations, making operation ids unique.
3340
*
3441
* @param openApi OpenApi object with all definitions
42+
* @param context The visitor context
3543
*/
36-
public void processOperations(OpenAPI openApi) {
44+
public void processOperations(OpenAPI openApi, VisitorContext context) {
3745
if (CollectionUtils.isEmpty(openApi.getPaths())) {
3846
return;
3947
}
4048

49+
var operationPathsById = new HashMap<String, String>();
4150
var operationIdsIndex = new HashMap<String, Integer>();
4251

43-
for (var pathItem : openApi.getPaths().values()) {
44-
for (var operation : pathItem.readOperations()) {
52+
for (var pathItemEntry : openApi.getPaths().entrySet()) {
53+
var pathItem = pathItemEntry.getValue();
54+
var path = pathItemEntry.getKey();
55+
var operations = pathItem.readOperationsMap();
56+
for (var opEntry : operations.entrySet()) {
57+
var method = opEntry.getKey();
58+
var operation = opEntry.getValue();
4559
String operationId = operation.getOperationId();
60+
if (operationId == null) {
61+
continue;
62+
}
63+
var operationPath = operationPath(method, path);
4664

4765
if (!operationIdsIndex.containsKey(operationId)) {
4866
operationIdsIndex.put(operationId, 1);
67+
operationPathsById.put(operationId, operationPath);
4968
continue;
5069
}
5170
int nextValue = operationIdsIndex.get(operationId);
71+
if (getOperationDuplicateResolution(context) == ConfigUtils.DuplicateResolution.ERROR) {
72+
var existedOperationPath = operationPathsById.get(operationId);
73+
var methods = OperationUtils.getMethodsByOperationId(operationId);
74+
var methodsMessage = "";
75+
if (CollectionUtils.isNotEmpty(methods)) {
76+
methodsMessage = "\nMethods: " + String.join(", ", methods);
77+
}
78+
throw new ConfigurationException("Found 2 operations with same ID \"" + operationId + "\" for paths " + existedOperationPath + " and " + operationPath + methodsMessage);
79+
}
5280

5381
String newOperationId = operationId + UNDERSCORE + nextValue;
5482
operation.setOperationId(newOperationId);
5583
updateResponseDescription(operation, operationId, newOperationId);
5684

5785
operationIdsIndex.put(operationId, ++nextValue);
86+
operationPathsById.put(newOperationId, operationPath);
5887
}
5988
}
6089
}
6190

91+
private String operationPath(PathItem.HttpMethod method, String path) {
92+
return method + " " + path;
93+
}
94+
6295
private static void updateResponseDescription(Operation operation, String originalId, String newOperationId) {
6396
if (CollectionUtils.isEmpty(operation.getResponses())) {
6497
return;

openapi/src/main/java/io/micronaut/openapi/visitor/AbstractOpenApiEndpointVisitor.java

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1526,23 +1526,23 @@ private Pair<String, PathItem> readWebhook(@Nullable AnnotationValue<Webhook> we
15261526
return Pair.of(name, pathItem);
15271527
}
15281528

1529-
private Map<PathItem, Operation> readOperations(String path, HttpMethod httpMethod, List<PathItem> pathItems, MethodElement element, VisitorContext context, @Nullable ClassElement jsonViewClass) {
1529+
private Map<PathItem, Operation> readOperations(String path, HttpMethod httpMethod, List<PathItem> pathItems, MethodElement methodEl, VisitorContext context, @Nullable ClassElement jsonViewClass) {
15301530
var swaggerOperations = new HashMap<PathItem, Operation>(pathItems.size());
1531-
var operationAnn = element.findAnnotation(io.swagger.v3.oas.annotations.Operation.class).orElse(null);
1531+
var operationAnn = methodEl.findAnnotation(io.swagger.v3.oas.annotations.Operation.class).orElse(null);
15321532

15331533
for (PathItem pathItem : pathItems) {
15341534
var swaggerOperation = operationAnn != null ? toValue(operationAnn.getAnnotationName(), operationAnn.getValues(), context, Operation.class, jsonViewClass) : null;
15351535
if (swaggerOperation == null) {
15361536
swaggerOperation = new Operation();
15371537
}
15381538

1539-
addOperationDeprecatedExtension(element, swaggerOperation, context);
1539+
addOperationDeprecatedExtension(methodEl, swaggerOperation, context);
15401540

15411541
if (CollectionUtils.isNotEmpty(swaggerOperation.getParameters())) {
15421542
swaggerOperation.getParameters().removeIf(Objects::isNull);
15431543
}
15441544

1545-
ParameterElement[] methodParams = element.getParameters();
1545+
ParameterElement[] methodParams = methodEl.getParameters();
15461546
if (ArrayUtils.isNotEmpty(methodParams) && operationAnn != null) {
15471547
var paramAnns = operationAnn.getAnnotations(PROP_PARAMETERS, io.swagger.v3.oas.annotations.Parameter.class);
15481548
if (CollectionUtils.isNotEmpty(paramAnns)) {
@@ -1593,7 +1593,7 @@ private Map<PathItem, Operation> readOperations(String path, HttpMethod httpMeth
15931593
swaggerParam.setAllowReserved(true);
15941594
}
15951595
paramAnn.stringValue(PROP_EXAMPLE).ifPresent(swaggerParam::setExample);
1596-
var examples = readExamples(paramAnn.getAnnotations(PROP_EXAMPLES, ExampleObject.class), element, context);
1596+
var examples = readExamples(paramAnn.getAnnotations(PROP_EXAMPLES, ExampleObject.class), methodEl, context);
15971597
if (examples != null) {
15981598
examples.forEach(swaggerParam::addExample);
15991599
}
@@ -1622,7 +1622,7 @@ private Map<PathItem, Operation> readOperations(String path, HttpMethod httpMeth
16221622
String prefix;
16231623
String suffix;
16241624
boolean addAlways;
1625-
var apiDecoratorAnn = element.getDeclaredAnnotation(OpenAPIDecorator.class);
1625+
var apiDecoratorAnn = methodEl.getDeclaredAnnotation(OpenAPIDecorator.class);
16261626
if (apiDecoratorAnn != null) {
16271627
prefix = apiDecoratorAnn.stringValue().orElse(StringUtils.EMPTY_STRING);
16281628
if (prefix.isEmpty()) {
@@ -1642,11 +1642,13 @@ private Map<PathItem, Operation> readOperations(String path, HttpMethod httpMeth
16421642
}
16431643

16441644
if (StringUtils.isEmpty(swaggerOperation.getOperationId())) {
1645-
swaggerOperation.setOperationId(prefix + element.getName() + postfix + suffix);
1645+
swaggerOperation.setOperationId(prefix + methodEl.getName() + postfix + suffix);
16461646
} else if (addAlways) {
16471647
swaggerOperation.setOperationId(prefix + swaggerOperation.getOperationId() + postfix + suffix);
16481648
}
16491649

1650+
OperationUtils.addOperation(swaggerOperation.getOperationId(), methodEl);
1651+
16501652
if (swaggerOperation.getDescription() != null && swaggerOperation.getDescription().isEmpty()) {
16511653
swaggerOperation.setDescription(null);
16521654
}

openapi/src/main/java/io/micronaut/openapi/visitor/ConfigUtils.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@
128128
import static io.micronaut.openapi.visitor.OpenApiConfigProperty.MICRONAUT_OPENAPI_GENERATOR_EXTENSIONS_ENABLED;
129129
import static io.micronaut.openapi.visitor.OpenApiConfigProperty.MICRONAUT_OPENAPI_GROUPS;
130130
import static io.micronaut.openapi.visitor.OpenApiConfigProperty.MICRONAUT_OPENAPI_JSON_VIEW_DEFAULT_INCLUSION;
131+
import static io.micronaut.openapi.visitor.OpenApiConfigProperty.MICRONAUT_OPENAPI_OPERATION_DUPLICATE_RESOLUTION;
131132
import static io.micronaut.openapi.visitor.OpenApiConfigProperty.MICRONAUT_OPENAPI_PROJECT_DIR;
132133
import static io.micronaut.openapi.visitor.OpenApiConfigProperty.MICRONAUT_OPENAPI_PROPERTY_INCLUDE;
133134
import static io.micronaut.openapi.visitor.OpenApiConfigProperty.MICRONAUT_OPENAPI_PROPERTY_NAMING_STRATEGY;
@@ -371,6 +372,14 @@ public static DuplicateResolution getSchemaDuplicateResolution(VisitorContext co
371372
return DuplicateResolution.AUTO;
372373
}
373374

375+
public static DuplicateResolution getOperationDuplicateResolution(VisitorContext context) {
376+
var value = getConfigProperty(MICRONAUT_OPENAPI_OPERATION_DUPLICATE_RESOLUTION, context);
377+
if (StringUtils.isNotEmpty(value) && DuplicateResolution.ERROR.name().equalsIgnoreCase(value)) {
378+
return DuplicateResolution.ERROR;
379+
}
380+
return DuplicateResolution.AUTO;
381+
}
382+
374383
public static boolean isConstructorArgumentsAsRequired(VisitorContext context) {
375384
return getBooleanProperty(MICRONAUT_OPENAPI_CONSTRUCTOR_ARGUMENTS_AS_REQUIRED, true, context);
376385
}

openapi/src/main/java/io/micronaut/openapi/visitor/OpenApiApplicationVisitor.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -834,7 +834,7 @@ private OpenAPI postProcessOpenApi(OpenAPI openApi, VisitorContext context) {
834834
normalizeOpenApi(openApi, context);
835835
// Process after sorting so order is stable
836836
new JacksonDiscriminatorPostProcessor().addMissingDiscriminatorType(openApi);
837-
new OpenApiOperationsPostProcessor().processOperations(openApi);
837+
new OpenApiOperationsPostProcessor().processOperations(openApi, context);
838838

839839
removeUnusedSchemas(openApi);
840840

openapi/src/main/java/io/micronaut/openapi/visitor/OpenApiConfigProperty.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,15 @@ public interface OpenApiConfigProperty {
281281
* Default: false
282282
*/
283283
String MICRONAUT_OPENAPI_SWAGGER_FILE_GENERATION_ENABLED = "micronaut.openapi.swagger.file.generation.enabled";
284+
/**
285+
* System property to set operation duplicate resolution. Available values:
286+
* - auto - micronaut-openapi automatically add index suffix to duplicate operation ID.
287+
* - error - micronaut-openapi throws an exception when found duplicate operation ID.
288+
* <br>
289+
* Default: auto
290+
*/
291+
String MICRONAUT_OPENAPI_OPERATION_DUPLICATE_RESOLUTION = "micronaut.openapi.operation.duplicate-resolution";
292+
284293
/**
285294
* System property that enables extra schema processing.
286295
*/
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* Copyright 2017-2025 original authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.micronaut.openapi.visitor;
17+
18+
import io.micronaut.core.annotation.Internal;
19+
import io.micronaut.core.annotation.Nullable;
20+
import io.micronaut.inject.ast.MethodElement;
21+
22+
import java.util.ArrayList;
23+
import java.util.HashMap;
24+
import java.util.List;
25+
import java.util.Map;
26+
27+
/**
28+
* Methods to construct OpenPI schema definition.
29+
*
30+
* @since 6.19.2
31+
*/
32+
@Internal
33+
public final class OperationUtils {
34+
35+
/**
36+
* Stores the current in progress type.
37+
*/
38+
private static Map<String, List<String>> methodsByOperationId = new HashMap<>();
39+
40+
private OperationUtils() {
41+
}
42+
43+
/**
44+
* Cleanup context.
45+
*/
46+
public static void clean() {
47+
methodsByOperationId = new HashMap<>();
48+
}
49+
50+
@Nullable
51+
public static List<String> getMethodsByOperationId(String operationId) {
52+
return methodsByOperationId.get(operationId);
53+
}
54+
55+
public static void addOperation(@Nullable String operationId, MethodElement methodEl) {
56+
if (operationId == null){
57+
return;
58+
}
59+
var existedList = methodsByOperationId.computeIfAbsent(operationId, k -> new ArrayList<>());
60+
existedList.add(methodEl.getOwningType().getName() + '.' + methodEl.getName());
61+
}
62+
63+
}

openapi/src/main/java/io/micronaut/openapi/visitor/Utils.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,7 @@ public static void clean() {
410410
testJsonReference = null;
411411
creatorConstructorsCache = new HashMap<>();
412412
System.clearProperty(BIND_TYPE_AND_TYPES);
413+
OperationUtils.clean();
413414
SchemaDefinitionUtils.clean();
414415
OpenApiExtraSchemaVisitor.clean();
415416
OpenApiExcludeVisitor.clean();
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package io.micronaut.openapi.visitor
2+
3+
import io.micronaut.context.exceptions.ConfigurationException
4+
import io.micronaut.openapi.AbstractOpenApiTypeElementSpec
5+
import io.micronaut.openapi.javadoc.DocsFormat
6+
import io.swagger.v3.oas.models.OpenAPI
7+
import io.swagger.v3.oas.models.Operation
8+
import io.swagger.v3.oas.models.media.Schema
9+
import io.swagger.v3.oas.models.parameters.Parameter
10+
import spock.util.environment.RestoreSystemProperties
11+
12+
import static io.micronaut.openapi.visitor.OpenApiConfigProperty.MICRONAUT_OPENAPI_DOCS_FORMAT
13+
import static io.micronaut.openapi.visitor.OpenApiConfigProperty.MICRONAUT_OPENAPI_OPERATION_DUPLICATE_RESOLUTION
14+
15+
class OpenApiOperationIdSpec extends AbstractOpenApiTypeElementSpec {
16+
17+
@RestoreSystemProperties
18+
void "test duplicate operation ID resolution ERROR"() {
19+
given:
20+
System.setProperty(MICRONAUT_OPENAPI_OPERATION_DUPLICATE_RESOLUTION, ConfigUtils.DuplicateResolution.ERROR.name())
21+
22+
when:
23+
buildBeanDefinition('test.MyBean', '''
24+
package test;
25+
26+
import io.micronaut.http.annotation.Controller;
27+
import io.micronaut.http.annotation.Get;
28+
29+
@Controller("/resourceA")
30+
class ControllerA {
31+
32+
@Get
33+
String getResource() {
34+
return "test1";
35+
}
36+
}
37+
38+
@Controller("/resourceB")
39+
class ControllerB {
40+
41+
@Get
42+
String getResource() {
43+
return "test2";
44+
}
45+
}
46+
47+
@jakarta.inject.Singleton
48+
class MyBean {}
49+
''')
50+
51+
then:
52+
def e = thrown(RuntimeException)
53+
e.message.contains("Found 2 operations with same ID \"getResource\" for paths GET /resourceA and GET /resourceB")
54+
}
55+
}
56+

src/main/docs/guide/configuration/availableOptions.adoc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ You can set your custom paths separated by `,`. To set absolute paths use prefix
6262
classpath paths use prefix `classpath:` or use prefix `project:` to set paths from project
6363
directory. |
6464
|`*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`
65+
|`*micronaut.openapi.operation.duplicate-resolution*` | System property to set operation ID duplicate resolution. Available values: +
66+
`*auto*` - micronaut-openapi automatically add index suffix to duplicate operation ID. +
67+
`*error*` - micronaut-openapi throws an exception when found duplicate operation IDs. | Default: `auto`
6568
|`*micronaut.openapi.schema.extra.enable*` | If this property true, you can add some extra schemas to final OpenAPI spec file. | Default: `false`
6669
|`*micronaut.openapi.schema.duplicate-resolution*` | System property to set schema duplicate resolution. Available values: +
6770
`*auto*` - micronaut-openapi automatically add index suffix to duplicate schema. +

0 commit comments

Comments
 (0)