Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ commons-text = "1.14.0"
guava = "33.5.0-jre"
commonmark = "0.27.0"
kotlin-compile-testing = "1.6.0"

micronaut = "4.10.8"
micronaut-platform = "4.10.1"
micronaut-jaxrs = "4.10.0"
Expand Down
2 changes: 1 addition & 1 deletion gradle/license.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ license {
java = 'SLASHSTAR_STYLE'
groovy = 'SLASHSTAR_STYLE'
}
ext.year = '2017-2024'
ext.year = '2017-2025'

exclude "**/transaction/**"
exclude '**/*.txt'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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<String, String>();
var operationIdsIndex = new HashMap<String, Integer>();

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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1526,23 +1526,23 @@ private Pair<String, PathItem> readWebhook(@Nullable AnnotationValue<Webhook> we
return Pair.of(name, pathItem);
}

private Map<PathItem, Operation> readOperations(String path, HttpMethod httpMethod, List<PathItem> pathItems, MethodElement element, VisitorContext context, @Nullable ClassElement jsonViewClass) {
private Map<PathItem, Operation> readOperations(String path, HttpMethod httpMethod, List<PathItem> pathItems, MethodElement methodEl, VisitorContext context, @Nullable ClassElement jsonViewClass) {
var swaggerOperations = new HashMap<PathItem, Operation>(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;
if (swaggerOperation == null) {
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)) {
Expand Down Expand Up @@ -1593,7 +1593,7 @@ private Map<PathItem, Operation> 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);
}
Expand Down Expand Up @@ -1622,7 +1622,7 @@ private Map<PathItem, Operation> 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()) {
Expand All @@ -1642,11 +1642,13 @@ private Map<PathItem, Operation> 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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <br>
* Default: auto
*/
String MICRONAUT_OPENAPI_OPERATION_DUPLICATE_RESOLUTION = "micronaut.openapi.operation.duplicate-resolution";

/**
* System property that enables extra schema processing.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, List<String>> methodsByOperationId = new HashMap<>();

private OperationUtils() {
}

/**
* Cleanup context.
*/
public static void clean() {
methodsByOperationId = new HashMap<>();
}

@Nullable
public static List<String> 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());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package io.micronaut.openapi.visitor

import io.micronaut.context.exceptions.ConfigurationException
import io.micronaut.openapi.AbstractOpenApiTypeElementSpec
import io.micronaut.openapi.javadoc.DocsFormat
import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.Operation
import io.swagger.v3.oas.models.media.Schema
import io.swagger.v3.oas.models.parameters.Parameter
import spock.util.environment.RestoreSystemProperties

import static io.micronaut.openapi.visitor.OpenApiConfigProperty.MICRONAUT_OPENAPI_DOCS_FORMAT
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")
}
}

3 changes: 3 additions & 0 deletions src/main/docs/guide/configuration/availableOptions.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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. +
Expand Down
Loading