From ca3e8ecd1015457c2b4d90eca23909c1a109b050 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Tue, 23 Jun 2026 00:09:52 +0200 Subject: [PATCH 01/13] fix(MergedSpecBuilder): merge path items by HTTP method instead of overwriting on path collision --- .../codegen/config/MergedSpecBuilder.java | 205 ++++++++++----- .../codegen/config/MergedSpecBuilderTest.java | 244 ++++++++++++++++++ .../bugs/mergerTest/spec-collision.json | 70 +++++ .../bugs/mergerTest/spec-collision.yaml | 52 ++++ .../bugs/mergerTest/spec-extensions.json | 42 +++ .../bugs/mergerTest/spec-extensions.yaml | 31 +++ .../resources/bugs/mergerTest/spec-noext.txt | 1 + .../bugs/mergerTest/spec-schema-conflict.json | 39 +++ .../bugs/mergerTest/spec-schema-conflict.yaml | 28 ++ 9 files changed, 649 insertions(+), 63 deletions(-) create mode 100644 modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-collision.json create mode 100644 modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-collision.yaml create mode 100644 modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-extensions.json create mode 100644 modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-extensions.yaml create mode 100644 modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-noext.txt create mode 100644 modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-schema-conflict.json create mode 100644 modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-schema-conflict.yaml diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java index f56dc392e882..27aa523ee363 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java @@ -1,25 +1,27 @@ package org.openapitools.codegen.config; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; -import com.google.common.collect.ImmutableMap; +import com.fasterxml.jackson.core.JsonProcessingException; import io.swagger.parser.OpenAPIParser; +import io.swagger.v3.core.util.Json; +import io.swagger.v3.core.util.Yaml; +import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.Paths; +import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.servers.Server; import io.swagger.v3.parser.core.models.ParseOptions; -import org.apache.commons.lang3.ObjectUtils; import org.openapitools.codegen.auth.AuthParser; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.util.*; -import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -27,6 +29,8 @@ public class MergedSpecBuilder { private static final Logger LOGGER = LoggerFactory.getLogger(MergedSpecBuilder.class); + private static final Set SPEC_EXTENSIONS = new HashSet<>(Arrays.asList(".yaml", ".yml", ".json")); + private final String inputSpecRootDirectory; private final String mergeFileName; private final String mergedFileInfoName; @@ -56,11 +60,10 @@ public String buildMergedSpec() { } LOGGER.info("In spec root directory {} found specs {}", inputSpecRootDirectory, specRelatedPaths); - String openapiVersion = null; boolean isJson = false; ParseOptions options = new ParseOptions(); options.setResolve(true); - List allPaths = new ArrayList<>(); + List parsedSpecs = new ArrayList<>(); List allServers = new ArrayList<>(); for (String specRelatedPath : specRelatedPaths) { @@ -72,26 +75,37 @@ public String buildMergedSpec() { .readLocation(specPath, AuthParser.parse(auth), options) .getOpenAPI(); - if (openapiVersion == null) { - openapiVersion = result.getOpenapi(); - if (specRelatedPath.toLowerCase(Locale.ROOT).endsWith(".json")) { - isJson = true; - } + if (result == null) { + LOGGER.error("Failed to read file: {}. It would be ignored", specPath); + continue; + } + + if (parsedSpecs.isEmpty() && specRelatedPath.toLowerCase(Locale.ROOT).endsWith(".json")) { + isJson = true; } - allServers.addAll(ObjectUtils.defaultIfNull(result.getServers(), Collections.emptyList())); - allPaths.add(new SpecWithPaths(specRelatedPath, result.getPaths().keySet())); + allServers.addAll(Optional.ofNullable(result.getServers()).orElse(Collections.emptyList())); + parsedSpecs.add(result); } catch (Exception e) { LOGGER.error("Failed to read file: {}. It would be ignored", specPath); } } - Map mergedSpec = generatedMergedSpec(openapiVersion, allPaths, allServers); + if (parsedSpecs.isEmpty()) { + throw new RuntimeException("Spec directory doesn't contain any valid specification"); + } + + OpenAPI merged = mergeSpecs(parsedSpecs, allServers); + String mergedFilename = this.mergeFileName + (isJson ? ".json" : ".yaml"); - Path mergedFilePath = Paths.get(inputSpecRootDirectory, mergedFilename); + Path mergedFilePath = java.nio.file.Paths.get(inputSpecRootDirectory, mergedFilename); try { - ObjectMapper objectMapper = isJson ? new ObjectMapper() : new ObjectMapper(new YAMLFactory()); - Files.write(mergedFilePath, objectMapper.writeValueAsBytes(mergedSpec), StandardOpenOption.CREATE, StandardOpenOption.WRITE); + String content = isJson + ? Json.mapper().writerWithDefaultPrettyPrinter().writeValueAsString(merged) + : Yaml.mapper().writerWithDefaultPrettyPrinter().writeValueAsString(merged); + Files.write(mergedFilePath, content.getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.WRITE); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to serialize merged spec", e); } catch (IOException e) { throw new RuntimeException(e); } @@ -99,43 +113,113 @@ public String buildMergedSpec() { return mergedFilePath.toString(); } - private Map generatedMergedSpec(String openapiVersion, List allPaths, List allServers) { - Map spec = generateHeader(openapiVersion, mergedFileInfoName, mergedFileInfoDescription, mergedFileInfoVersion, allServers); - Map paths = new HashMap<>(); - spec.put("paths", paths); - - for (SpecWithPaths specWithPaths : allPaths) { - for (String path : specWithPaths.paths) { - String specRelatedPath = "./" + specWithPaths.specRelatedPath + "#/paths/" + path.replace("/", "~1"); - paths.put(path, ImmutableMap.of( - "$ref", specRelatedPath - )); + /** + * Merges a list of parsed OpenAPI specs into a single spec. + * + *

Path items are merged by HTTP method: if two specs define the same URL path, their + * operations are combined (e.g. GET from one file + POST from another) rather than one + * overwriting the other. A warning is logged when the same path+method appears in multiple specs; + * the first occurrence is kept.

+ * + *

Component maps (schemas, responses, requestBodies, parameters, headers, examples, + * links, callbacks, securitySchemes) are merged by name. Structurally identical duplicates + * are silently deduplicated. A warning is logged if the same component name appears with + * different definitions; the first definition is kept.

+ */ + OpenAPI mergeSpecs(List specs, List allServers) { + OpenAPI merged = new OpenAPI(); + merged.openapi(specs.get(0).getOpenapi() != null ? specs.get(0).getOpenapi() : "3.0.3"); + + Info info = new Info() + .title(mergedFileInfoName) + .description(mergedFileInfoDescription) + .version(mergedFileInfoVersion); + merged.info(info); + + List distinctServerUrls = allServers.stream() + .map(Server::getUrl) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + if (distinctServerUrls.isEmpty()) { + merged.addServersItem(new Server().url("http://localhost:8080")); + } else { + distinctServerUrls.forEach(url -> merged.addServersItem(new Server().url(url))); + } + + merged.setPaths(new Paths()); + merged.setComponents(new Components()); + + for (OpenAPI spec : specs) { + if (spec.getPaths() != null) { + spec.getPaths().forEach((pathKey, incomingPathItem) -> { + PathItem existing = merged.getPaths().get(pathKey); + if (existing == null) { + merged.getPaths().addPathItem(pathKey, incomingPathItem); + } else { + mergePathItem(existing, incomingPathItem, pathKey); + } + }); + } + if (spec.getComponents() != null) { + mergeComponents(merged.getComponents(), spec.getComponents()); } } - return spec; + return merged; } - private static Map generateHeader(String openapiVersion, String title, String description, String version, List allServers) { - Map map = new HashMap<>(); - map.put("openapi", openapiVersion); - map.put("info", ImmutableMap.of( - "title", title, - "description", description, - "version", version - )); - - Set> servers = allServers.stream() - .map(Server::getUrl) - .distinct() - .map(url -> ImmutableMap.of("url", url)) - .collect(Collectors.collectingAndThen(Collectors.toSet(), Optional::of)) - .filter(Predicate.not(Set::isEmpty)) - .orElseGet(() -> Collections.singleton(ImmutableMap.of("url", "http://localhost:8080"))); + /** + * Merges HTTP method operations from {@code incoming} into {@code existing} for the same path URL. + * Path-level metadata (summary, description, servers, parameters, extensions) is kept from + * {@code existing} (i.e. the first spec that defined this path). A warning is logged for any + * path+method that already exists in {@code existing}. + */ + private void mergePathItem(PathItem existing, PathItem incoming, String pathKey) { + if (incoming.readOperationsMap() == null) { + return; + } + incoming.readOperationsMap().forEach((method, operation) -> { + if (existing.readOperationsMap() != null && existing.readOperationsMap().containsKey(method)) { + LOGGER.warn("Path+method collision during spec merge: {} {} is defined in multiple specs. Keeping the first occurrence.", method, pathKey); + } else { + existing.operation(method, operation); + } + }); + } - map.put("servers", servers); + /** + * Merges all component maps from {@code source} into {@code target}. + * Identical definitions are silently deduplicated. Conflicting definitions (same name, different + * structure) generate a warning and keep the first definition. + */ + private void mergeComponents(Components target, Components source) { + mergeComponentMap(target.getSchemas(), source.getSchemas(), "schema", target::addSchemas); + mergeComponentMap(target.getResponses(), source.getResponses(), "response", target::addResponses); + mergeComponentMap(target.getRequestBodies(), source.getRequestBodies(), "requestBody", target::addRequestBodies); + mergeComponentMap(target.getParameters(), source.getParameters(), "parameter", target::addParameters); + mergeComponentMap(target.getHeaders(), source.getHeaders(), "header", target::addHeaders); + mergeComponentMap(target.getExamples(), source.getExamples(), "example", target::addExamples); + mergeComponentMap(target.getLinks(), source.getLinks(), "link", target::addLinks); + mergeComponentMap(target.getCallbacks(), source.getCallbacks(), "callback", target::addCallbacks); + mergeComponentMap(target.getSecuritySchemes(), source.getSecuritySchemes(), "securityScheme", target::addSecuritySchemes); + } - return map; + private void mergeComponentMap(Map existing, Map incoming, + String typeName, java.util.function.BiConsumer adder) { + if (incoming == null) { + return; + } + incoming.forEach((name, value) -> { + if (existing != null && existing.containsKey(name)) { + if (!Objects.equals(existing.get(name), value)) { + LOGGER.warn("Component {} name conflict during spec merge: '{}' is defined in multiple specs with different definitions. Keeping the first definition.", typeName, name); + } + // identical or keeping first — either way, skip + } else { + adder.accept(name, value); + } + }); } private List getAllSpecFilesInDirectory() { @@ -143,7 +227,12 @@ private List getAllSpecFilesInDirectory() { try (Stream pathStream = Files.walk(rootDirectory)) { return pathStream .filter(path -> !Files.isDirectory(path)) + .filter(path -> { + String name = path.getFileName().toString().toLowerCase(Locale.ROOT); + return SPEC_EXTENSIONS.stream().anyMatch(name::endsWith); + }) .map(path -> rootDirectory.relativize(path).toString()) + .sorted() .collect(Collectors.toList()); } catch (IOException e) { throw new RuntimeException("Exception while listing files in spec root directory: " + inputSpecRootDirectory, e); @@ -152,22 +241,12 @@ private List getAllSpecFilesInDirectory() { private void deleteMergedFileFromPreviousRun() { try { - Files.deleteIfExists(Paths.get(inputSpecRootDirectory + File.separator + mergeFileName + ".json")); - } catch (IOException e) { + Files.deleteIfExists(java.nio.file.Paths.get(inputSpecRootDirectory + File.separator + mergeFileName + ".json")); + } catch (IOException ignored) { } try { - Files.deleteIfExists(Paths.get(inputSpecRootDirectory + File.separator + mergeFileName + ".yaml")); - } catch (IOException e) { - } - } - - private static class SpecWithPaths { - private final String specRelatedPath; - private final Set paths; - - private SpecWithPaths(final String specRelatedPath, final Set paths) { - this.specRelatedPath = specRelatedPath; - this.paths = paths; + Files.deleteIfExists(java.nio.file.Paths.get(inputSpecRootDirectory + File.separator + mergeFileName + ".yaml")); + } catch (IOException ignored) { } } } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/MergedSpecBuilderTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/MergedSpecBuilderTest.java index 42f9e0a547d1..330d1429c70b 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/MergedSpecBuilderTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/MergedSpecBuilderTest.java @@ -3,22 +3,28 @@ import com.google.common.collect.ImmutableMap; import io.swagger.parser.OpenAPIParser; import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.PathItem; import io.swagger.v3.parser.core.models.ParseOptions; import org.openapitools.codegen.ClientOptInput; import org.openapitools.codegen.DefaultGenerator; import org.openapitools.codegen.java.assertions.JavaFileAssert; import org.openapitools.codegen.languages.SpringCodegen; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.slf4j.LoggerFactory; import org.testng.annotations.Test; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; +import java.util.List; import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; import static org.openapitools.codegen.languages.SpringCodegen.*; +import static org.testng.Assert.*; public class MergedSpecBuilderTest { @@ -101,4 +107,242 @@ private void assertFilesFromMergedSpec(String mergedSpec) throws IOException { .assertMethod("getSpec2Field").hasReturnType("BigDecimal"); } + // ---- Path-collision tests ---- + + @Test + public void shouldMergeSpecsWithCollidingPaths_yaml() throws IOException { + shouldMergeSpecsWithCollidingPaths("yaml"); + } + + @Test + public void shouldMergeSpecsWithCollidingPaths_json() throws IOException { + shouldMergeSpecsWithCollidingPaths("json"); + } + + private void shouldMergeSpecsWithCollidingPaths(String fileExt) throws IOException { + File dir = Files.createTempDirectory("spec-collision").toFile().getCanonicalFile(); + dir.deleteOnExit(); + + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec1." + fileExt), dir.toPath().resolve("spec1." + fileExt)); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec-collision." + fileExt), dir.toPath().resolve("spec-collision." + fileExt)); + + String mergedSpec = new MergedSpecBuilder(dir.getAbsolutePath().replace('\\', '/'), "_merged") + .buildMergedSpec(); + + ParseOptions parseOptions = new ParseOptions(); + parseOptions.setResolve(true); + OpenAPI openAPI = new OpenAPIParser().readLocation(mergedSpec, null, parseOptions).getOpenAPI(); + + assertNotNull(openAPI.getPaths(), "Merged spec must have paths"); + + // /spec1 must have both GET (from spec1) and POST (from spec-collision) + PathItem spec1Path = openAPI.getPaths().get("/spec1"); + assertNotNull(spec1Path, "/spec1 path must exist in merged spec"); + assertNotNull(spec1Path.getGet(), "/spec1 GET must be present (from spec1)"); + assertNotNull(spec1Path.getPost(), "/spec1 POST must be present (from spec-collision)"); + + // /collision path from spec-collision must also be present + assertNotNull(openAPI.getPaths().get("/collision"), "/collision path must exist in merged spec"); + + // schemas from both specs must be present + assertNotNull(openAPI.getComponents().getSchemas().get("Spec1Model"), "Spec1Model schema must exist"); + assertNotNull(openAPI.getComponents().getSchemas().get("CollisionModel"), "CollisionModel schema must exist"); + } + + // ---- Vendor extensions tests ---- + + @Test + public void shouldPreserveVendorExtensions_yaml() throws IOException { + shouldPreserveVendorExtensions("yaml"); + } + + @Test + public void shouldPreserveVendorExtensions_json() throws IOException { + shouldPreserveVendorExtensions("json"); + } + + private void shouldPreserveVendorExtensions(String fileExt) throws IOException { + File dir = Files.createTempDirectory("spec-extensions").toFile().getCanonicalFile(); + dir.deleteOnExit(); + + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec-extensions." + fileExt), dir.toPath().resolve("spec-extensions." + fileExt)); + + String mergedSpec = new MergedSpecBuilder(dir.getAbsolutePath().replace('\\', '/'), "_merged") + .buildMergedSpec(); + + ParseOptions parseOptions = new ParseOptions(); + parseOptions.setResolve(true); + OpenAPI openAPI = new OpenAPIParser().readLocation(mergedSpec, null, parseOptions).getOpenAPI(); + + assertNotNull(openAPI.getPaths(), "Merged spec must have paths"); + + PathItem extPath = openAPI.getPaths().get("/ext-path"); + assertNotNull(extPath, "/ext-path must exist"); + assertNotNull(extPath.getExtensions(), "Path-level extensions must be preserved"); + assertEquals(extPath.getExtensions().get("x-custom-path-ext"), "path-level-value", "x-custom-path-ext must be preserved on path"); + + assertNotNull(extPath.getGet(), "GET operation must exist on /ext-path"); + assertNotNull(extPath.getGet().getExtensions(), "Operation-level extensions must be preserved"); + assertEquals(extPath.getGet().getExtensions().get("x-custom-op-ext"), "operation-level-value", "x-custom-op-ext must be preserved on operation"); + + assertNotNull(openAPI.getComponents().getSchemas().get("ExtModel"), "ExtModel must exist"); + assertNotNull(openAPI.getComponents().getSchemas().get("ExtModel").getExtensions(), "Schema-level extensions must be preserved"); + assertEquals(openAPI.getComponents().getSchemas().get("ExtModel").getExtensions().get("x-custom-schema-ext"), "schema-level-value", "x-custom-schema-ext must be preserved on schema"); + } + + // ---- Component merging tests ---- + + @Test + public void shouldMergeComponentsFromBothSpecs_yaml() throws IOException { + shouldMergeComponentsFromBothSpecs("yaml"); + } + + @Test + public void shouldMergeComponentsFromBothSpecs_json() throws IOException { + shouldMergeComponentsFromBothSpecs("json"); + } + + private void shouldMergeComponentsFromBothSpecs(String fileExt) throws IOException { + File dir = Files.createTempDirectory("spec-components").toFile().getCanonicalFile(); + dir.deleteOnExit(); + + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec1." + fileExt), dir.toPath().resolve("spec1." + fileExt)); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec2." + fileExt), dir.toPath().resolve("spec2." + fileExt)); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec-collision." + fileExt), dir.toPath().resolve("spec-collision." + fileExt)); + + String mergedSpec = new MergedSpecBuilder(dir.getAbsolutePath().replace('\\', '/'), "_merged") + .buildMergedSpec(); + + ParseOptions parseOptions = new ParseOptions(); + parseOptions.setResolve(true); + OpenAPI openAPI = new OpenAPIParser().readLocation(mergedSpec, null, parseOptions).getOpenAPI(); + + Map schemas = openAPI.getComponents().getSchemas(); + assertNotNull(schemas.get("Spec1Model"), "Spec1Model must be present"); + assertNotNull(schemas.get("Spec2Model"), "Spec2Model must be present"); + assertNotNull(schemas.get("CollisionModel"), "CollisionModel must be present"); + } + + // ---- Identical duplicate schema test ---- + + @Test + public void shouldHandleDuplicateIdenticalSchemas_yaml() throws IOException { + shouldHandleDuplicateIdenticalSchemas("yaml"); + } + + @Test + public void shouldHandleDuplicateIdenticalSchemas_json() throws IOException { + shouldHandleDuplicateIdenticalSchemas("json"); + } + + /** + * spec-collision defines Spec1Model identically to spec1 — same name, same structure. + * The merged result must contain exactly one Spec1Model without errors. + */ + private void shouldHandleDuplicateIdenticalSchemas(String fileExt) throws IOException { + File dir = Files.createTempDirectory("spec-dup-schema").toFile().getCanonicalFile(); + dir.deleteOnExit(); + + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec1." + fileExt), dir.toPath().resolve("spec1." + fileExt)); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec-collision." + fileExt), dir.toPath().resolve("spec-collision." + fileExt)); + + // Must not throw + String mergedSpec = new MergedSpecBuilder(dir.getAbsolutePath().replace('\\', '/'), "_merged") + .buildMergedSpec(); + + ParseOptions parseOptions = new ParseOptions(); + parseOptions.setResolve(true); + OpenAPI openAPI = new OpenAPIParser().readLocation(mergedSpec, null, parseOptions).getOpenAPI(); + + // Spec1Model is defined in both spec1 and spec-collision with identical structure + assertNotNull(openAPI.getComponents().getSchemas().get("Spec1Model"), "Spec1Model must be present exactly once"); + } + + // ---- Non-spec file filter test ---- + + @Test + public void shouldIgnoreNonSpecFiles_yaml() throws IOException { + shouldIgnoreNonSpecFiles("yaml"); + } + + @Test + public void shouldIgnoreNonSpecFiles_json() throws IOException { + shouldIgnoreNonSpecFiles("json"); + } + + private void shouldIgnoreNonSpecFiles(String fileExt) throws IOException { + File dir = Files.createTempDirectory("spec-noext").toFile().getCanonicalFile(); + dir.deleteOnExit(); + + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec1." + fileExt), dir.toPath().resolve("spec1." + fileExt)); + // Copy the non-spec .txt file into the same directory + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec-noext.txt"), dir.toPath().resolve("spec-noext.txt")); + + // Must not throw despite the .txt file being present + String mergedSpec = new MergedSpecBuilder(dir.getAbsolutePath().replace('\\', '/'), "_merged") + .buildMergedSpec(); + + ParseOptions parseOptions = new ParseOptions(); + parseOptions.setResolve(true); + OpenAPI openAPI = new OpenAPIParser().readLocation(mergedSpec, null, parseOptions).getOpenAPI(); + + // Spec from spec1 must be present; no error from the .txt file + assertNotNull(openAPI.getPaths().get("/spec1"), "/spec1 path must be present"); + assertNotNull(openAPI.getComponents().getSchemas().get("Spec1Model"), "Spec1Model must be present"); + } + + // ---- Schema name conflict warning test ---- + + @Test + public void shouldWarnOnSchemaNameConflict_yaml() throws IOException { + shouldWarnOnSchemaNameConflict("yaml"); + } + + @Test + public void shouldWarnOnSchemaNameConflict_json() throws IOException { + shouldWarnOnSchemaNameConflict("json"); + } + + /** + * spec1 and spec-schema-conflict both define Spec1Model but with different properties. + * The merge must keep the first definition and emit a WARN log. + */ + private void shouldWarnOnSchemaNameConflict(String fileExt) throws IOException { + ch.qos.logback.classic.Logger logger = + (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(MergedSpecBuilder.class); + ListAppender listAppender = new ListAppender<>(); + listAppender.start(); + logger.addAppender(listAppender); + + try { + File dir = Files.createTempDirectory("spec-schema-conflict").toFile().getCanonicalFile(); + dir.deleteOnExit(); + + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec1." + fileExt), dir.toPath().resolve("spec1." + fileExt)); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec-schema-conflict." + fileExt), dir.toPath().resolve("spec-schema-conflict." + fileExt)); + + String mergedSpec = new MergedSpecBuilder(dir.getAbsolutePath().replace('\\', '/'), "_merged") + .buildMergedSpec(); + + ParseOptions parseOptions = new ParseOptions(); + parseOptions.setResolve(true); + OpenAPI openAPI = new OpenAPIParser().readLocation(mergedSpec, null, parseOptions).getOpenAPI(); + + // First alphabetical file (spec-schema-conflict: differentField) is kept + assertNotNull(openAPI.getComponents().getSchemas().get("Spec1Model"), "Spec1Model must be present"); + assertNotNull(openAPI.getComponents().getSchemas().get("Spec1Model").getProperties().get("differentField"), + "differentField (from first-alphabetical spec) must be kept"); + assertNull(openAPI.getComponents().getSchemas().get("Spec1Model").getProperties().get("spec1Field"), + "spec1Field (from second spec) must NOT be present"); + + // A WARN about the conflict must have been logged + List warnLogs = listAppender.list.stream() + .filter(e -> e.getLevel() == ch.qos.logback.classic.Level.WARN) + .filter(e -> e.getFormattedMessage().contains("Spec1Model")) + .collect(Collectors.toList()); + assertFalse(warnLogs.isEmpty(), "A WARN log about the Spec1Model name conflict must be emitted"); + } finally { + logger.detachAppender(listAppender); + } + } } diff --git a/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-collision.json b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-collision.json new file mode 100644 index 000000000000..a027821d8639 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-collision.json @@ -0,0 +1,70 @@ +{ + "openapi": "3.0.3", + "info": { + "version": "1.0.0", + "title": "Collision Test Spec" + }, + "servers": [ + { "url": "api.my-domain.com/my-context-root/v1" } + ], + "paths": { + "/spec1": { + "post": { + "tags": ["spec1"], + "summary": "create spec1", + "operationId": "createSpec1", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Spec1Model" } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Spec1Model" } + } + } + } + } + } + }, + "/collision": { + "get": { + "tags": ["collision"], + "summary": "collision endpoint", + "operationId": "collisionOperation", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CollisionModel" } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "CollisionModel": { + "type": "object", + "properties": { + "collisionField": { "type": "string" } + } + }, + "Spec1Model": { + "type": "object", + "properties": { + "spec1Field": { "type": "string" } + } + } + } + } +} diff --git a/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-collision.yaml b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-collision.yaml new file mode 100644 index 000000000000..52c4fe2dab8f --- /dev/null +++ b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-collision.yaml @@ -0,0 +1,52 @@ +openapi: 3.0.3 +info: + version: 1.0.0 + title: Collision Test Spec +servers: + - url: api.my-domain.com/my-context-root/v1 +paths: + /spec1: + post: + tags: + - spec1 + summary: create spec1 + operationId: createSpec1 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Spec1Model' + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/Spec1Model' + /collision: + get: + tags: + - collision + summary: collision endpoint + operationId: collisionOperation + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/CollisionModel' + +components: + schemas: + CollisionModel: + type: object + properties: + collisionField: + type: string + Spec1Model: + type: object + properties: + spec1Field: + type: string diff --git a/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-extensions.json b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-extensions.json new file mode 100644 index 000000000000..38fd32ff96e8 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-extensions.json @@ -0,0 +1,42 @@ +{ + "openapi": "3.0.3", + "info": { + "version": "1.0.0", + "title": "Extensions Test Spec" + }, + "servers": [ + { "url": "api.my-domain.com/my-context-root/v1" } + ], + "paths": { + "/ext-path": { + "x-custom-path-ext": "path-level-value", + "get": { + "tags": ["ext"], + "summary": "extensions test", + "operationId": "extOperation", + "x-custom-op-ext": "operation-level-value", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ExtModel" } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ExtModel": { + "type": "object", + "x-custom-schema-ext": "schema-level-value", + "properties": { + "extField": { "type": "string" } + } + } + } + } +} diff --git a/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-extensions.yaml b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-extensions.yaml new file mode 100644 index 000000000000..85161c8cf770 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-extensions.yaml @@ -0,0 +1,31 @@ +openapi: 3.0.3 +info: + version: 1.0.0 + title: Extensions Test Spec +servers: + - url: api.my-domain.com/my-context-root/v1 +paths: + /ext-path: + x-custom-path-ext: path-level-value + get: + tags: + - ext + summary: extensions test + operationId: extOperation + x-custom-op-ext: operation-level-value + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ExtModel' + +components: + schemas: + ExtModel: + type: object + x-custom-schema-ext: schema-level-value + properties: + extField: + type: string diff --git a/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-noext.txt b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-noext.txt new file mode 100644 index 000000000000..b92555272056 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-noext.txt @@ -0,0 +1 @@ +This is not a spec file. It should be silently ignored by MergedSpecBuilder. diff --git a/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-schema-conflict.json b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-schema-conflict.json new file mode 100644 index 000000000000..c6472790c110 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-schema-conflict.json @@ -0,0 +1,39 @@ +{ + "openapi": "3.0.3", + "info": { + "version": "1.0.0", + "title": "Schema Conflict Test Spec" + }, + "servers": [ + { "url": "api.my-domain.com/my-context-root/v1" } + ], + "paths": { + "/schema-conflict": { + "get": { + "tags": ["schemaConflict"], + "summary": "schema conflict test", + "operationId": "schemaConflictOperation", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Spec1Model" } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Spec1Model": { + "type": "object", + "properties": { + "differentField": { "type": "integer" } + } + } + } + } +} diff --git a/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-schema-conflict.yaml b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-schema-conflict.yaml new file mode 100644 index 000000000000..54ed1b860ea8 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/bugs/mergerTest/spec-schema-conflict.yaml @@ -0,0 +1,28 @@ +openapi: 3.0.3 +info: + version: 1.0.0 + title: Schema Conflict Test Spec +servers: + - url: api.my-domain.com/my-context-root/v1 +paths: + /schema-conflict: + get: + tags: + - schemaConflict + summary: schema conflict test + operationId: schemaConflictOperation + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Spec1Model' + +components: + schemas: + Spec1Model: + type: object + properties: + differentField: + type: integer From 8972528a389f31b60d2c467050112d4374b6df72 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Tue, 23 Jun 2026 00:45:35 +0200 Subject: [PATCH 02/13] fix(kotlin-spring): fix Flow and Flux array return types for reactive generators --- .../languages/KotlinSpringServerCodegen.java | 13 ++ .../main/resources/kotlin-spring/api.mustache | 2 +- .../kotlin-spring/apiDelegate.mustache | 2 +- .../kotlin-spring/apiInterface.mustache | 2 +- .../apiInterface.mustache | 1 - .../httpInterfaceReturnTypes.mustache | 2 +- .../kotlin-spring/returnTypes.mustache | 2 +- .../resources/kotlin-spring/service.mustache | 2 +- .../kotlin-spring/serviceImpl.mustache | 2 +- .../spring/KotlinSpringServerCodegenTest.java | 119 ++++++++++++++---- 10 files changed, 118 insertions(+), 29 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java index b94c7c42d2bc..52e3afb48810 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java @@ -1614,6 +1614,19 @@ public void setReturnContainer(final String returnContainer) { } }); + // Flow is broken — StringDecoder intercepts String and returns the entire + // JSON array as a single blob instead of using Jackson. Fix by switching + // array-of-string operations to List (with suspend). + // See https://github.com/spring-projects/spring-framework/issues/22662 + // Note: check operation.returnType (set by doDataTypeAssignment) which holds the + // unwrapped inner type, e.g. "kotlin.String" for List arrays. + // The declarative-http-interface library forces useFlowForArrayReturnType=false, + // so this condition only fires for the spring-boot coroutines path. + if (reactive && useFlowForArrayReturnType + && operation.isArray && "kotlin.String".equals(operation.returnType)) { + operation.vendorExtensions.put("x-reactive-array-string-return", true); + } + // Generate sealed response interface metadata if enabled if (useSealedResponseInterfaces && responses != null && !responses.isEmpty()) { // Generate sealed interface name from operation ID diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/api.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/api.mustache index dcc950cdeb75..b5b8e877dda6 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/api.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/api.mustache @@ -106,7 +106,7 @@ class {{classname}}Controller({{#serviceInterface}}@Autowired(required = true) v produces = [{{#produces}}"{{{mediaType}}}"{{^-last}}, {{/-last}}{{/produces}}]{{/hasProduces}}{{#hasConsumes}}, consumes = [{{#consumes}}"{{{mediaType}}}"{{^-last}}, {{/-last}}{{/consumes}}]{{/hasConsumes}}{{/singleContentTypes}} ) - {{#suspendFunctions}}suspend {{/suspendFunctions}}{{^suspendFunctions}}{{#reactive}}{{^isArray}}suspend {{/isArray}}{{#isArray}}{{^useFlowForArrayReturnType}}suspend {{/useFlowForArrayReturnType}}{{/isArray}}{{/reactive}}{{/suspendFunctions}}fun {{operationId}}({{#allParams}} + {{#suspendFunctions}}suspend {{/suspendFunctions}}{{^suspendFunctions}}{{#reactive}}{{^isArray}}suspend {{/isArray}}{{#isArray}}{{#vendorExtensions.x-reactive-array-string-return}}suspend {{/vendorExtensions.x-reactive-array-string-return}}{{^vendorExtensions.x-reactive-array-string-return}}{{^useFlowForArrayReturnType}}suspend {{/useFlowForArrayReturnType}}{{/vendorExtensions.x-reactive-array-string-return}}{{/isArray}}{{/reactive}}{{/suspendFunctions}}fun {{operationId}}({{#allParams}} {{>queryParams}}{{>pathParams}}{{>headerParams}}{{>cookieParams}}{{>bodyParams}}{{>formParams}}{{^-last}},{{/-last}}{{/allParams}}{{#includeHttpRequestContext}}{{#hasParams}}, {{/hasParams}}{{#swagger1AnnotationLibrary}}@ApiParam(hidden = true) {{/swagger1AnnotationLibrary}}{{#swagger2AnnotationLibrary}}@Parameter(hidden = true) {{/swagger2AnnotationLibrary}}{{#reactive}}exchange: org.springframework.web.server.ServerWebExchange{{/reactive}}{{^reactive}}request: {{javaxPackage}}.servlet.http.HttpServletRequest{{/reactive}}{{/includeHttpRequestContext}}{{#vendorExtensions.x-spring-paginated}}{{#hasParams}}, {{/hasParams}}{{^hasParams}}{{#includeHttpRequestContext}}, diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/apiDelegate.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/apiDelegate.mustache index 9dbeecc9a1a9..e6efd9948039 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/apiDelegate.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/apiDelegate.mustache @@ -32,7 +32,7 @@ interface {{classname}}Delegate { /** * @see {{classname}}#{{operationId}} */ - {{#suspendFunctions}}suspend {{/suspendFunctions}}{{^suspendFunctions}}{{#reactive}}{{^isArray}}suspend {{/isArray}}{{#isArray}}{{^useFlowForArrayReturnType}}suspend {{/useFlowForArrayReturnType}}{{/isArray}}{{/reactive}}{{/suspendFunctions}}fun {{operationId}}({{#allParams}}{{{paramName}}}: {{^reactive}}{{>optionalDataType}}{{/reactive}}{{#reactive}}{{^isArray}}{{>optionalDataType}}{{/isArray}}{{#isArray}}{{#isBodyParam}}Flow<{{{baseType}}}>{{/isBodyParam}}{{^isBodyParam}}{{>optionalDataType}}{{/isBodyParam}}{{/isArray}}{{/reactive}}{{^-last}}, + {{#suspendFunctions}}suspend {{/suspendFunctions}}{{^suspendFunctions}}{{#reactive}}{{^isArray}}suspend {{/isArray}}{{#isArray}}{{#vendorExtensions.x-reactive-array-string-return}}suspend {{/vendorExtensions.x-reactive-array-string-return}}{{^vendorExtensions.x-reactive-array-string-return}}{{^useFlowForArrayReturnType}}suspend {{/useFlowForArrayReturnType}}{{/vendorExtensions.x-reactive-array-string-return}}{{/isArray}}{{/reactive}}{{/suspendFunctions}}fun {{operationId}}({{#allParams}}{{{paramName}}}: {{^reactive}}{{>optionalDataType}}{{/reactive}}{{#reactive}}{{^isArray}}{{>optionalDataType}}{{/isArray}}{{#isArray}}{{#isBodyParam}}Flow<{{{baseType}}}>{{/isBodyParam}}{{^isBodyParam}}{{>optionalDataType}}{{/isBodyParam}}{{/isArray}}{{/reactive}}{{^-last}}, {{/-last}}{{/allParams}}{{#includeHttpRequestContext}}{{#hasParams}}, {{/hasParams}}{{#reactive}}exchange: org.springframework.web.server.ServerWebExchange{{/reactive}}{{^reactive}}request: {{javaxPackage}}.servlet.http.HttpServletRequest{{/reactive}}{{/includeHttpRequestContext}}{{#vendorExtensions.x-spring-paginated}}{{#hasParams}}, {{/hasParams}}{{^hasParams}}{{#includeHttpRequestContext}}, diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/apiInterface.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/apiInterface.mustache index 851b07427a6f..0f42a7e473d9 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/apiInterface.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/apiInterface.mustache @@ -121,7 +121,7 @@ interface {{classname}} { produces = [{{#produces}}"{{{mediaType}}}"{{^-last}}, {{/-last}}{{/produces}}]{{/hasProduces}}{{#hasConsumes}}, consumes = [{{#consumes}}"{{{mediaType}}}"{{^-last}}, {{/-last}}{{/consumes}}]{{/hasConsumes}}{{/singleContentTypes}} ) - {{#suspendFunctions}}suspend {{/suspendFunctions}}{{^suspendFunctions}}{{#reactive}}{{^isArray}}suspend {{/isArray}}{{#isArray}}{{^useFlowForArrayReturnType}}suspend {{/useFlowForArrayReturnType}}{{/isArray}}{{/reactive}}{{/suspendFunctions}}fun {{operationId}}({{#allParams}} + {{#suspendFunctions}}suspend {{/suspendFunctions}}{{^suspendFunctions}}{{#reactive}}{{^isArray}}suspend {{/isArray}}{{#isArray}}{{#vendorExtensions.x-reactive-array-string-return}}suspend {{/vendorExtensions.x-reactive-array-string-return}}{{^vendorExtensions.x-reactive-array-string-return}}{{^useFlowForArrayReturnType}}suspend {{/useFlowForArrayReturnType}}{{/vendorExtensions.x-reactive-array-string-return}}{{/isArray}}{{/reactive}}{{/suspendFunctions}}fun {{operationId}}({{#allParams}} {{>queryParams}}{{>pathParams}}{{>headerParams}}{{>cookieParams}}{{>bodyParams}}{{>formParams}}{{^-last}},{{/-last}}{{/allParams}}{{#includeHttpRequestContext}}{{#hasParams}}, {{/hasParams}}{{#swagger1AnnotationLibrary}}@ApiParam(hidden = true) {{/swagger1AnnotationLibrary}}{{#swagger2AnnotationLibrary}}@Parameter(hidden = true) {{/swagger2AnnotationLibrary}}{{#reactive}}exchange: org.springframework.web.server.ServerWebExchange{{/reactive}}{{^reactive}}request: {{javaxPackage}}.servlet.http.HttpServletRequest{{/reactive}}{{/includeHttpRequestContext}}{{#vendorExtensions.x-spring-paginated}}{{#hasParams}}, {{/hasParams}}{{^hasParams}}{{#includeHttpRequestContext}}, diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/libraries/spring-declarative-http-interface/apiInterface.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/libraries/spring-declarative-http-interface/apiInterface.mustache index 53ce2730aa8b..2e7a59289cb4 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/libraries/spring-declarative-http-interface/apiInterface.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/libraries/spring-declarative-http-interface/apiInterface.mustache @@ -43,7 +43,6 @@ import {{javaxPackage}}.validation.constraints.* {{/useBeanValidation}} {{#reactiveModeReactor}} -import reactor.core.publisher.Flux import reactor.core.publisher.Mono {{/reactiveModeReactor}} diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/libraries/spring-declarative-http-interface/httpInterfaceReturnTypes.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/libraries/spring-declarative-http-interface/httpInterfaceReturnTypes.mustache index 4f36af09bfba..f4bb3da4fa13 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/libraries/spring-declarative-http-interface/httpInterfaceReturnTypes.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/libraries/spring-declarative-http-interface/httpInterfaceReturnTypes.mustache @@ -6,7 +6,7 @@ {{#isArray}} {{! array handle reactive - reactor with/without ResponseEntity wrapper}} {{#reactiveModeReactor}} -{{#useResponseEntity}}Mono{{#useResponseEntity}}>>{{/useResponseEntity}} +Mono<{{#useResponseEntity}}ResponseEntity<{{/useResponseEntity}}{{{returnContainer}}}<{{#useSealedResponseInterfaces}}{{#vendorExtensions.x-sealed-response-interface}}{{vendorExtensions.x-sealed-response-interface}}{{/vendorExtensions.x-sealed-response-interface}}{{^vendorExtensions.x-sealed-response-interface}}{{{returnType}}}{{/vendorExtensions.x-sealed-response-interface}}{{/useSealedResponseInterfaces}}{{^useSealedResponseInterfaces}}{{{returnType}}}{{/useSealedResponseInterfaces}}>{{#useResponseEntity}}>{{/useResponseEntity}}> {{/reactiveModeReactor}} {{! array handle reactive - coroutines with/without ResponseEntity wrapper}} {{#reactiveModeCoroutines}} diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/returnTypes.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/returnTypes.mustache index 612aa9ec0599..a3a7a14b00ff 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/returnTypes.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/returnTypes.mustache @@ -1 +1 @@ -{{#isMap}}Map{{/isMap}}{{#isArray}}{{#reactive}}{{#useFlowForArrayReturnType}}Flow{{/useFlowForArrayReturnType}}{{^useFlowForArrayReturnType}}{{{returnContainer}}}{{/useFlowForArrayReturnType}}{{/reactive}}{{^reactive}}{{{returnContainer}}}{{/reactive}}<{{{returnType}}}>{{/isArray}}{{^returnContainer}}{{{returnType}}}{{/returnContainer}} \ No newline at end of file +{{#isMap}}Map{{/isMap}}{{#isArray}}{{#reactive}}{{#vendorExtensions.x-reactive-array-string-return}}{{{returnContainer}}}{{/vendorExtensions.x-reactive-array-string-return}}{{^vendorExtensions.x-reactive-array-string-return}}{{#useFlowForArrayReturnType}}Flow{{/useFlowForArrayReturnType}}{{^useFlowForArrayReturnType}}{{{returnContainer}}}{{/useFlowForArrayReturnType}}{{/vendorExtensions.x-reactive-array-string-return}}{{/reactive}}{{^reactive}}{{{returnContainer}}}{{/reactive}}<{{{returnType}}}>{{/isArray}}{{^returnContainer}}{{{returnType}}}{{/returnContainer}} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/service.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/service.mustache index c6372d1bf827..e8972a7afd51 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/service.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/service.mustache @@ -33,7 +33,7 @@ interface {{classname}}Service { {{#isDeprecated}} @Deprecated(message="Operation is deprecated") {{/isDeprecated}} - {{#suspendFunctions}}suspend {{/suspendFunctions}}{{^suspendFunctions}}{{#reactive}}{{^isArray}}suspend {{/isArray}}{{#isArray}}{{^useFlowForArrayReturnType}}suspend {{/useFlowForArrayReturnType}}{{/isArray}}{{/reactive}}{{/suspendFunctions}}fun {{operationId}}({{#allParams}}{{{paramName}}}: {{^isBodyParam}}{{>optionalDataType}}{{/isBodyParam}}{{#isBodyParam}}{{^reactive}}{{>optionalDataType}}{{/reactive}}{{#reactive}}{{^isArray}}{{>optionalDataType}}{{/isArray}}{{#isArray}}Flow<{{{baseType}}}>{{/isArray}}{{/reactive}}{{/isBodyParam}}{{^-last}}, {{/-last}}{{/allParams}}): {{>returnTypes}} + {{#suspendFunctions}}suspend {{/suspendFunctions}}{{^suspendFunctions}}{{#reactive}}{{^isArray}}suspend {{/isArray}}{{#isArray}}{{#vendorExtensions.x-reactive-array-string-return}}suspend {{/vendorExtensions.x-reactive-array-string-return}}{{^vendorExtensions.x-reactive-array-string-return}}{{^useFlowForArrayReturnType}}suspend {{/useFlowForArrayReturnType}}{{/vendorExtensions.x-reactive-array-string-return}}{{/isArray}}{{/reactive}}{{/suspendFunctions}}fun {{operationId}}({{#allParams}}{{{paramName}}}: {{^isBodyParam}}{{>optionalDataType}}{{/isBodyParam}}{{#isBodyParam}}{{^reactive}}{{>optionalDataType}}{{/reactive}}{{#reactive}}{{^isArray}}{{>optionalDataType}}{{/isArray}}{{#isArray}}Flow<{{{baseType}}}>{{/isArray}}{{/reactive}}{{/isBodyParam}}{{^-last}}, {{/-last}}{{/allParams}}): {{>returnTypes}} {{/operation}} } {{/operations}} diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/serviceImpl.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/serviceImpl.mustache index 181bfa52991c..18d61ae1bb23 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/serviceImpl.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/serviceImpl.mustache @@ -11,7 +11,7 @@ import org.springframework.stereotype.Service class {{classname}}ServiceImpl : {{classname}}Service { {{#operation}} - override {{#reactive}}{{^isArray}}suspend {{/isArray}}{{#isArray}}{{^useFlowForArrayReturnType}}suspend {{/useFlowForArrayReturnType}}{{/isArray}}{{/reactive}}fun {{operationId}}({{#allParams}}{{{paramName}}}: {{^isBodyParam}}{{>optionalDataType}}{{/isBodyParam}}{{#isBodyParam}}{{^reactive}}{{>optionalDataType}}{{/reactive}}{{#reactive}}{{^isArray}}{{>optionalDataType}}{{/isArray}}{{#isArray}}Flow<{{{baseType}}}>{{/isArray}}{{/reactive}}{{/isBodyParam}}{{^-last}}, {{/-last}}{{/allParams}}): {{>returnTypes}} { + override {{#reactive}}{{^isArray}}suspend {{/isArray}}{{#isArray}}{{#vendorExtensions.x-reactive-array-string-return}}suspend {{/vendorExtensions.x-reactive-array-string-return}}{{^vendorExtensions.x-reactive-array-string-return}}{{^useFlowForArrayReturnType}}suspend {{/useFlowForArrayReturnType}}{{/vendorExtensions.x-reactive-array-string-return}}{{/isArray}}{{/reactive}}fun {{operationId}}({{#allParams}}{{{paramName}}}: {{^isBodyParam}}{{>optionalDataType}}{{/isBodyParam}}{{#isBodyParam}}{{^reactive}}{{>optionalDataType}}{{/reactive}}{{#reactive}}{{^isArray}}{{>optionalDataType}}{{/isArray}}{{#isArray}}Flow<{{{baseType}}}>{{/isArray}}{{/reactive}}{{/isBodyParam}}{{^-last}}, {{/-last}}{{/allParams}}): {{>returnTypes}} { TODO("Implement me") } {{/operation}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java index 7fbc5ceea289..72dccb02eb76 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java @@ -373,13 +373,13 @@ public void delegateReactiveWithTags() throws Exception { "ApiUtil"); assertFileContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV2Api.kt"), - "import kotlinx.coroutines.flow.Flow", "ResponseEntity>"); + "import kotlinx.coroutines.flow.Flow", "ResponseEntity>"); assertFileNotContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV2Api.kt"), "exchange"); assertFileContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV2ApiDelegate.kt"), - "import kotlinx.coroutines.flow.Flow", "ResponseEntity>"); + "import kotlinx.coroutines.flow.Flow", "suspend fun", "ResponseEntity>"); assertFileNotContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV2ApiDelegate.kt"), - "suspend fun", "ApiUtil"); + "ApiUtil"); assertFileContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV3Api.kt"), "import kotlinx.coroutines.flow.Flow", "requestBody: Flow"); @@ -1279,8 +1279,7 @@ public void generateHttpInterfaceReactiveWithReactorResponseEntity() throws Exce Path path = Paths.get(outputPath + "/src/main/kotlin/org/openapitools/api/StoreApi.kt"); assertFileContains( path, - "import reactor.core.publisher.Flux\n" - + "import reactor.core.publisher.Mono", + "import reactor.core.publisher.Mono", " @HttpExchange(\n" + " // \"/store/inventory\"\n" + " url = PATH_GET_INVENTORY,\n" @@ -1393,8 +1392,7 @@ public void generateHttpInterfaceReactiveWithReactor() throws Exception { Path path = Paths.get(outputPath + "/src/main/kotlin/org/openapitools/api/StoreApi.kt"); assertFileContains( path, - "import reactor.core.publisher.Flux\n" - + "import reactor.core.publisher.Mono", + "import reactor.core.publisher.Mono", " fun getInventory(\n" + " ): Mono>", " fun deleteOrder(\n" @@ -3129,21 +3127,21 @@ public void reactiveWithFlow() throws Exception { ); assertFileNotContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1ApiController.kt"), - "List"); + "Flow"); - assertFileNotContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1Api.kt"), - "List"); assertFileContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1Api.kt"), + "List"); + assertFileNotContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1Api.kt"), "Flow"); - assertFileNotContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1ApiDelegate.kt"), - "List"); assertFileContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1ApiDelegate.kt"), + "List"); + assertFileNotContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1ApiDelegate.kt"), "Flow"); - assertFileNotContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1ApiService.kt"), - "List"); assertFileContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1ApiService.kt"), + "List"); + assertFileNotContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1ApiService.kt"), "Flow"); } @@ -3175,21 +3173,21 @@ public void reactiveWithDefaultValueFlow() throws Exception { ); assertFileNotContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1ApiController.kt"), - "List"); + "Flow"); - assertFileNotContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1Api.kt"), - "List"); assertFileContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1Api.kt"), + "List"); + assertFileNotContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1Api.kt"), "Flow"); - assertFileNotContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1ApiDelegate.kt"), - "List"); assertFileContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1ApiDelegate.kt"), + "List"); + assertFileNotContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1ApiDelegate.kt"), "Flow"); - assertFileNotContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1ApiService.kt"), - "List"); assertFileContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1ApiService.kt"), + "List"); + assertFileNotContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1ApiService.kt"), "Flow"); } @@ -4152,6 +4150,85 @@ public void springPaginatedDelegateCallPassesPageable() throws Exception { } } + @Test(description = "reactive spring-boot: array-of-string returns List with suspend, not Flow (issue #22662)") + public void reactiveArrayOfStringReturnsListNotFlow() throws Exception { + File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); + KotlinSpringServerCodegen codegen = new KotlinSpringServerCodegen(); + codegen.setOutputDir(output.getAbsolutePath()); + codegen.additionalProperties().put(KotlinSpringServerCodegen.REACTIVE, true); + codegen.additionalProperties().put(KotlinSpringServerCodegen.USE_FLOW_FOR_ARRAY_RETURN_TYPE, true); + codegen.additionalProperties().put(KotlinSpringServerCodegen.INTERFACE_ONLY, true); + + List files = new DefaultGenerator() + .opts(new ClientOptInput() + .openAPI(TestUtils.parseSpec("src/test/resources/bugs/issue_7118.yaml")) + .config(codegen)) + .generate(); + + Path apiPath = files.stream() + .filter(f -> f.getName().equals("UsersApi.kt")) + .findFirst() + .orElseThrow() + .toPath(); + + assertFileContains(apiPath, "suspend fun", "List"); + assertFileNotContains(apiPath, "Flow"); + } + + @Test(description = "declarative http interface reactor: array-of-string returns Mono>, not Flux (issue #22662)") + public void declarativeReactorArrayOfStringReturnsMono() throws Exception { + File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); + KotlinSpringServerCodegen codegen = new KotlinSpringServerCodegen(); + codegen.setOutputDir(output.getAbsolutePath()); + codegen.additionalProperties().put(CodegenConstants.LIBRARY, SPRING_DECLARATIVE_HTTP_INTERFACE_LIBRARY); + codegen.additionalProperties().put(KotlinSpringServerCodegen.REACTIVE, true); + codegen.additionalProperties().put(KotlinSpringServerCodegen.DECLARATIVE_INTERFACE_REACTIVE_MODE, "reactor"); + codegen.additionalProperties().put(KotlinSpringServerCodegen.USE_RESPONSE_ENTITY, false); + codegen.additionalProperties().put(KotlinSpringServerCodegen.USE_FLOW_FOR_ARRAY_RETURN_TYPE, false); + + List files = new DefaultGenerator() + .opts(new ClientOptInput() + .openAPI(TestUtils.parseSpec("src/test/resources/bugs/issue_7118.yaml")) + .config(codegen)) + .generate(); + + Path apiPath = files.stream() + .filter(f -> f.getName().equals("UsersApi.kt")) + .findFirst() + .orElseThrow() + .toPath(); + + assertFileContains(apiPath, "Mono>"); + assertFileNotContains(apiPath, "Flux", "import reactor.core.publisher.Flux"); + } + + @Test(description = "declarative http interface reactor + ResponseEntity: array-of-string returns Mono>> (issue #22662)") + public void declarativeReactorArrayOfStringReturnsMonoResponseEntity() throws Exception { + File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); + KotlinSpringServerCodegen codegen = new KotlinSpringServerCodegen(); + codegen.setOutputDir(output.getAbsolutePath()); + codegen.additionalProperties().put(CodegenConstants.LIBRARY, SPRING_DECLARATIVE_HTTP_INTERFACE_LIBRARY); + codegen.additionalProperties().put(KotlinSpringServerCodegen.REACTIVE, true); + codegen.additionalProperties().put(KotlinSpringServerCodegen.DECLARATIVE_INTERFACE_REACTIVE_MODE, "reactor"); + codegen.additionalProperties().put(KotlinSpringServerCodegen.USE_RESPONSE_ENTITY, true); + codegen.additionalProperties().put(KotlinSpringServerCodegen.USE_FLOW_FOR_ARRAY_RETURN_TYPE, false); + + List files = new DefaultGenerator() + .opts(new ClientOptInput() + .openAPI(TestUtils.parseSpec("src/test/resources/bugs/issue_7118.yaml")) + .config(codegen)) + .generate(); + + Path apiPath = files.stream() + .filter(f -> f.getName().equals("UsersApi.kt")) + .findFirst() + .orElseThrow() + .toPath(); + + assertFileContains(apiPath, "Mono>>"); + assertFileNotContains(apiPath, "Flux", "import reactor.core.publisher.Flux"); + } + private Map generateFromContract(String url) throws IOException { return generateFromContract(url, new HashMap<>(), new HashMap<>()); } From 8aa4e271581ca666bc876c9217bb70bc1a7e2859 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Tue, 23 Jun 2026 00:46:01 +0200 Subject: [PATCH 03/13] Revert "fix(kotlin-spring): fix Flow and Flux array return types for reactive generators" This reverts commit 8972528a389f31b60d2c467050112d4374b6df72. --- .../languages/KotlinSpringServerCodegen.java | 13 -- .../main/resources/kotlin-spring/api.mustache | 2 +- .../kotlin-spring/apiDelegate.mustache | 2 +- .../kotlin-spring/apiInterface.mustache | 2 +- .../apiInterface.mustache | 1 + .../httpInterfaceReturnTypes.mustache | 2 +- .../kotlin-spring/returnTypes.mustache | 2 +- .../resources/kotlin-spring/service.mustache | 2 +- .../kotlin-spring/serviceImpl.mustache | 2 +- .../spring/KotlinSpringServerCodegenTest.java | 119 ++++-------------- 10 files changed, 29 insertions(+), 118 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java index 52e3afb48810..b94c7c42d2bc 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java @@ -1614,19 +1614,6 @@ public void setReturnContainer(final String returnContainer) { } }); - // Flow is broken — StringDecoder intercepts String and returns the entire - // JSON array as a single blob instead of using Jackson. Fix by switching - // array-of-string operations to List (with suspend). - // See https://github.com/spring-projects/spring-framework/issues/22662 - // Note: check operation.returnType (set by doDataTypeAssignment) which holds the - // unwrapped inner type, e.g. "kotlin.String" for List arrays. - // The declarative-http-interface library forces useFlowForArrayReturnType=false, - // so this condition only fires for the spring-boot coroutines path. - if (reactive && useFlowForArrayReturnType - && operation.isArray && "kotlin.String".equals(operation.returnType)) { - operation.vendorExtensions.put("x-reactive-array-string-return", true); - } - // Generate sealed response interface metadata if enabled if (useSealedResponseInterfaces && responses != null && !responses.isEmpty()) { // Generate sealed interface name from operation ID diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/api.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/api.mustache index b5b8e877dda6..dcc950cdeb75 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/api.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/api.mustache @@ -106,7 +106,7 @@ class {{classname}}Controller({{#serviceInterface}}@Autowired(required = true) v produces = [{{#produces}}"{{{mediaType}}}"{{^-last}}, {{/-last}}{{/produces}}]{{/hasProduces}}{{#hasConsumes}}, consumes = [{{#consumes}}"{{{mediaType}}}"{{^-last}}, {{/-last}}{{/consumes}}]{{/hasConsumes}}{{/singleContentTypes}} ) - {{#suspendFunctions}}suspend {{/suspendFunctions}}{{^suspendFunctions}}{{#reactive}}{{^isArray}}suspend {{/isArray}}{{#isArray}}{{#vendorExtensions.x-reactive-array-string-return}}suspend {{/vendorExtensions.x-reactive-array-string-return}}{{^vendorExtensions.x-reactive-array-string-return}}{{^useFlowForArrayReturnType}}suspend {{/useFlowForArrayReturnType}}{{/vendorExtensions.x-reactive-array-string-return}}{{/isArray}}{{/reactive}}{{/suspendFunctions}}fun {{operationId}}({{#allParams}} + {{#suspendFunctions}}suspend {{/suspendFunctions}}{{^suspendFunctions}}{{#reactive}}{{^isArray}}suspend {{/isArray}}{{#isArray}}{{^useFlowForArrayReturnType}}suspend {{/useFlowForArrayReturnType}}{{/isArray}}{{/reactive}}{{/suspendFunctions}}fun {{operationId}}({{#allParams}} {{>queryParams}}{{>pathParams}}{{>headerParams}}{{>cookieParams}}{{>bodyParams}}{{>formParams}}{{^-last}},{{/-last}}{{/allParams}}{{#includeHttpRequestContext}}{{#hasParams}}, {{/hasParams}}{{#swagger1AnnotationLibrary}}@ApiParam(hidden = true) {{/swagger1AnnotationLibrary}}{{#swagger2AnnotationLibrary}}@Parameter(hidden = true) {{/swagger2AnnotationLibrary}}{{#reactive}}exchange: org.springframework.web.server.ServerWebExchange{{/reactive}}{{^reactive}}request: {{javaxPackage}}.servlet.http.HttpServletRequest{{/reactive}}{{/includeHttpRequestContext}}{{#vendorExtensions.x-spring-paginated}}{{#hasParams}}, {{/hasParams}}{{^hasParams}}{{#includeHttpRequestContext}}, diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/apiDelegate.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/apiDelegate.mustache index e6efd9948039..9dbeecc9a1a9 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/apiDelegate.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/apiDelegate.mustache @@ -32,7 +32,7 @@ interface {{classname}}Delegate { /** * @see {{classname}}#{{operationId}} */ - {{#suspendFunctions}}suspend {{/suspendFunctions}}{{^suspendFunctions}}{{#reactive}}{{^isArray}}suspend {{/isArray}}{{#isArray}}{{#vendorExtensions.x-reactive-array-string-return}}suspend {{/vendorExtensions.x-reactive-array-string-return}}{{^vendorExtensions.x-reactive-array-string-return}}{{^useFlowForArrayReturnType}}suspend {{/useFlowForArrayReturnType}}{{/vendorExtensions.x-reactive-array-string-return}}{{/isArray}}{{/reactive}}{{/suspendFunctions}}fun {{operationId}}({{#allParams}}{{{paramName}}}: {{^reactive}}{{>optionalDataType}}{{/reactive}}{{#reactive}}{{^isArray}}{{>optionalDataType}}{{/isArray}}{{#isArray}}{{#isBodyParam}}Flow<{{{baseType}}}>{{/isBodyParam}}{{^isBodyParam}}{{>optionalDataType}}{{/isBodyParam}}{{/isArray}}{{/reactive}}{{^-last}}, + {{#suspendFunctions}}suspend {{/suspendFunctions}}{{^suspendFunctions}}{{#reactive}}{{^isArray}}suspend {{/isArray}}{{#isArray}}{{^useFlowForArrayReturnType}}suspend {{/useFlowForArrayReturnType}}{{/isArray}}{{/reactive}}{{/suspendFunctions}}fun {{operationId}}({{#allParams}}{{{paramName}}}: {{^reactive}}{{>optionalDataType}}{{/reactive}}{{#reactive}}{{^isArray}}{{>optionalDataType}}{{/isArray}}{{#isArray}}{{#isBodyParam}}Flow<{{{baseType}}}>{{/isBodyParam}}{{^isBodyParam}}{{>optionalDataType}}{{/isBodyParam}}{{/isArray}}{{/reactive}}{{^-last}}, {{/-last}}{{/allParams}}{{#includeHttpRequestContext}}{{#hasParams}}, {{/hasParams}}{{#reactive}}exchange: org.springframework.web.server.ServerWebExchange{{/reactive}}{{^reactive}}request: {{javaxPackage}}.servlet.http.HttpServletRequest{{/reactive}}{{/includeHttpRequestContext}}{{#vendorExtensions.x-spring-paginated}}{{#hasParams}}, {{/hasParams}}{{^hasParams}}{{#includeHttpRequestContext}}, diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/apiInterface.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/apiInterface.mustache index 0f42a7e473d9..851b07427a6f 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/apiInterface.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/apiInterface.mustache @@ -121,7 +121,7 @@ interface {{classname}} { produces = [{{#produces}}"{{{mediaType}}}"{{^-last}}, {{/-last}}{{/produces}}]{{/hasProduces}}{{#hasConsumes}}, consumes = [{{#consumes}}"{{{mediaType}}}"{{^-last}}, {{/-last}}{{/consumes}}]{{/hasConsumes}}{{/singleContentTypes}} ) - {{#suspendFunctions}}suspend {{/suspendFunctions}}{{^suspendFunctions}}{{#reactive}}{{^isArray}}suspend {{/isArray}}{{#isArray}}{{#vendorExtensions.x-reactive-array-string-return}}suspend {{/vendorExtensions.x-reactive-array-string-return}}{{^vendorExtensions.x-reactive-array-string-return}}{{^useFlowForArrayReturnType}}suspend {{/useFlowForArrayReturnType}}{{/vendorExtensions.x-reactive-array-string-return}}{{/isArray}}{{/reactive}}{{/suspendFunctions}}fun {{operationId}}({{#allParams}} + {{#suspendFunctions}}suspend {{/suspendFunctions}}{{^suspendFunctions}}{{#reactive}}{{^isArray}}suspend {{/isArray}}{{#isArray}}{{^useFlowForArrayReturnType}}suspend {{/useFlowForArrayReturnType}}{{/isArray}}{{/reactive}}{{/suspendFunctions}}fun {{operationId}}({{#allParams}} {{>queryParams}}{{>pathParams}}{{>headerParams}}{{>cookieParams}}{{>bodyParams}}{{>formParams}}{{^-last}},{{/-last}}{{/allParams}}{{#includeHttpRequestContext}}{{#hasParams}}, {{/hasParams}}{{#swagger1AnnotationLibrary}}@ApiParam(hidden = true) {{/swagger1AnnotationLibrary}}{{#swagger2AnnotationLibrary}}@Parameter(hidden = true) {{/swagger2AnnotationLibrary}}{{#reactive}}exchange: org.springframework.web.server.ServerWebExchange{{/reactive}}{{^reactive}}request: {{javaxPackage}}.servlet.http.HttpServletRequest{{/reactive}}{{/includeHttpRequestContext}}{{#vendorExtensions.x-spring-paginated}}{{#hasParams}}, {{/hasParams}}{{^hasParams}}{{#includeHttpRequestContext}}, diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/libraries/spring-declarative-http-interface/apiInterface.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/libraries/spring-declarative-http-interface/apiInterface.mustache index 2e7a59289cb4..53ce2730aa8b 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/libraries/spring-declarative-http-interface/apiInterface.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/libraries/spring-declarative-http-interface/apiInterface.mustache @@ -43,6 +43,7 @@ import {{javaxPackage}}.validation.constraints.* {{/useBeanValidation}} {{#reactiveModeReactor}} +import reactor.core.publisher.Flux import reactor.core.publisher.Mono {{/reactiveModeReactor}} diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/libraries/spring-declarative-http-interface/httpInterfaceReturnTypes.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/libraries/spring-declarative-http-interface/httpInterfaceReturnTypes.mustache index f4bb3da4fa13..4f36af09bfba 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/libraries/spring-declarative-http-interface/httpInterfaceReturnTypes.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/libraries/spring-declarative-http-interface/httpInterfaceReturnTypes.mustache @@ -6,7 +6,7 @@ {{#isArray}} {{! array handle reactive - reactor with/without ResponseEntity wrapper}} {{#reactiveModeReactor}} -Mono<{{#useResponseEntity}}ResponseEntity<{{/useResponseEntity}}{{{returnContainer}}}<{{#useSealedResponseInterfaces}}{{#vendorExtensions.x-sealed-response-interface}}{{vendorExtensions.x-sealed-response-interface}}{{/vendorExtensions.x-sealed-response-interface}}{{^vendorExtensions.x-sealed-response-interface}}{{{returnType}}}{{/vendorExtensions.x-sealed-response-interface}}{{/useSealedResponseInterfaces}}{{^useSealedResponseInterfaces}}{{{returnType}}}{{/useSealedResponseInterfaces}}>{{#useResponseEntity}}>{{/useResponseEntity}}> +{{#useResponseEntity}}Mono{{#useResponseEntity}}>>{{/useResponseEntity}} {{/reactiveModeReactor}} {{! array handle reactive - coroutines with/without ResponseEntity wrapper}} {{#reactiveModeCoroutines}} diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/returnTypes.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/returnTypes.mustache index a3a7a14b00ff..612aa9ec0599 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/returnTypes.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/returnTypes.mustache @@ -1 +1 @@ -{{#isMap}}Map{{/isMap}}{{#isArray}}{{#reactive}}{{#vendorExtensions.x-reactive-array-string-return}}{{{returnContainer}}}{{/vendorExtensions.x-reactive-array-string-return}}{{^vendorExtensions.x-reactive-array-string-return}}{{#useFlowForArrayReturnType}}Flow{{/useFlowForArrayReturnType}}{{^useFlowForArrayReturnType}}{{{returnContainer}}}{{/useFlowForArrayReturnType}}{{/vendorExtensions.x-reactive-array-string-return}}{{/reactive}}{{^reactive}}{{{returnContainer}}}{{/reactive}}<{{{returnType}}}>{{/isArray}}{{^returnContainer}}{{{returnType}}}{{/returnContainer}} \ No newline at end of file +{{#isMap}}Map{{/isMap}}{{#isArray}}{{#reactive}}{{#useFlowForArrayReturnType}}Flow{{/useFlowForArrayReturnType}}{{^useFlowForArrayReturnType}}{{{returnContainer}}}{{/useFlowForArrayReturnType}}{{/reactive}}{{^reactive}}{{{returnContainer}}}{{/reactive}}<{{{returnType}}}>{{/isArray}}{{^returnContainer}}{{{returnType}}}{{/returnContainer}} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/service.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/service.mustache index e8972a7afd51..c6372d1bf827 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/service.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/service.mustache @@ -33,7 +33,7 @@ interface {{classname}}Service { {{#isDeprecated}} @Deprecated(message="Operation is deprecated") {{/isDeprecated}} - {{#suspendFunctions}}suspend {{/suspendFunctions}}{{^suspendFunctions}}{{#reactive}}{{^isArray}}suspend {{/isArray}}{{#isArray}}{{#vendorExtensions.x-reactive-array-string-return}}suspend {{/vendorExtensions.x-reactive-array-string-return}}{{^vendorExtensions.x-reactive-array-string-return}}{{^useFlowForArrayReturnType}}suspend {{/useFlowForArrayReturnType}}{{/vendorExtensions.x-reactive-array-string-return}}{{/isArray}}{{/reactive}}{{/suspendFunctions}}fun {{operationId}}({{#allParams}}{{{paramName}}}: {{^isBodyParam}}{{>optionalDataType}}{{/isBodyParam}}{{#isBodyParam}}{{^reactive}}{{>optionalDataType}}{{/reactive}}{{#reactive}}{{^isArray}}{{>optionalDataType}}{{/isArray}}{{#isArray}}Flow<{{{baseType}}}>{{/isArray}}{{/reactive}}{{/isBodyParam}}{{^-last}}, {{/-last}}{{/allParams}}): {{>returnTypes}} + {{#suspendFunctions}}suspend {{/suspendFunctions}}{{^suspendFunctions}}{{#reactive}}{{^isArray}}suspend {{/isArray}}{{#isArray}}{{^useFlowForArrayReturnType}}suspend {{/useFlowForArrayReturnType}}{{/isArray}}{{/reactive}}{{/suspendFunctions}}fun {{operationId}}({{#allParams}}{{{paramName}}}: {{^isBodyParam}}{{>optionalDataType}}{{/isBodyParam}}{{#isBodyParam}}{{^reactive}}{{>optionalDataType}}{{/reactive}}{{#reactive}}{{^isArray}}{{>optionalDataType}}{{/isArray}}{{#isArray}}Flow<{{{baseType}}}>{{/isArray}}{{/reactive}}{{/isBodyParam}}{{^-last}}, {{/-last}}{{/allParams}}): {{>returnTypes}} {{/operation}} } {{/operations}} diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/serviceImpl.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/serviceImpl.mustache index 18d61ae1bb23..181bfa52991c 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/serviceImpl.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/serviceImpl.mustache @@ -11,7 +11,7 @@ import org.springframework.stereotype.Service class {{classname}}ServiceImpl : {{classname}}Service { {{#operation}} - override {{#reactive}}{{^isArray}}suspend {{/isArray}}{{#isArray}}{{#vendorExtensions.x-reactive-array-string-return}}suspend {{/vendorExtensions.x-reactive-array-string-return}}{{^vendorExtensions.x-reactive-array-string-return}}{{^useFlowForArrayReturnType}}suspend {{/useFlowForArrayReturnType}}{{/vendorExtensions.x-reactive-array-string-return}}{{/isArray}}{{/reactive}}fun {{operationId}}({{#allParams}}{{{paramName}}}: {{^isBodyParam}}{{>optionalDataType}}{{/isBodyParam}}{{#isBodyParam}}{{^reactive}}{{>optionalDataType}}{{/reactive}}{{#reactive}}{{^isArray}}{{>optionalDataType}}{{/isArray}}{{#isArray}}Flow<{{{baseType}}}>{{/isArray}}{{/reactive}}{{/isBodyParam}}{{^-last}}, {{/-last}}{{/allParams}}): {{>returnTypes}} { + override {{#reactive}}{{^isArray}}suspend {{/isArray}}{{#isArray}}{{^useFlowForArrayReturnType}}suspend {{/useFlowForArrayReturnType}}{{/isArray}}{{/reactive}}fun {{operationId}}({{#allParams}}{{{paramName}}}: {{^isBodyParam}}{{>optionalDataType}}{{/isBodyParam}}{{#isBodyParam}}{{^reactive}}{{>optionalDataType}}{{/reactive}}{{#reactive}}{{^isArray}}{{>optionalDataType}}{{/isArray}}{{#isArray}}Flow<{{{baseType}}}>{{/isArray}}{{/reactive}}{{/isBodyParam}}{{^-last}}, {{/-last}}{{/allParams}}): {{>returnTypes}} { TODO("Implement me") } {{/operation}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java index 72dccb02eb76..7fbc5ceea289 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java @@ -373,13 +373,13 @@ public void delegateReactiveWithTags() throws Exception { "ApiUtil"); assertFileContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV2Api.kt"), - "import kotlinx.coroutines.flow.Flow", "ResponseEntity>"); + "import kotlinx.coroutines.flow.Flow", "ResponseEntity>"); assertFileNotContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV2Api.kt"), "exchange"); assertFileContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV2ApiDelegate.kt"), - "import kotlinx.coroutines.flow.Flow", "suspend fun", "ResponseEntity>"); + "import kotlinx.coroutines.flow.Flow", "ResponseEntity>"); assertFileNotContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV2ApiDelegate.kt"), - "ApiUtil"); + "suspend fun", "ApiUtil"); assertFileContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV3Api.kt"), "import kotlinx.coroutines.flow.Flow", "requestBody: Flow"); @@ -1279,7 +1279,8 @@ public void generateHttpInterfaceReactiveWithReactorResponseEntity() throws Exce Path path = Paths.get(outputPath + "/src/main/kotlin/org/openapitools/api/StoreApi.kt"); assertFileContains( path, - "import reactor.core.publisher.Mono", + "import reactor.core.publisher.Flux\n" + + "import reactor.core.publisher.Mono", " @HttpExchange(\n" + " // \"/store/inventory\"\n" + " url = PATH_GET_INVENTORY,\n" @@ -1392,7 +1393,8 @@ public void generateHttpInterfaceReactiveWithReactor() throws Exception { Path path = Paths.get(outputPath + "/src/main/kotlin/org/openapitools/api/StoreApi.kt"); assertFileContains( path, - "import reactor.core.publisher.Mono", + "import reactor.core.publisher.Flux\n" + + "import reactor.core.publisher.Mono", " fun getInventory(\n" + " ): Mono>", " fun deleteOrder(\n" @@ -3127,21 +3129,21 @@ public void reactiveWithFlow() throws Exception { ); assertFileNotContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1ApiController.kt"), - "Flow"); - - assertFileContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1Api.kt"), "List"); + assertFileNotContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1Api.kt"), + "List"); + assertFileContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1Api.kt"), "Flow"); - assertFileContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1ApiDelegate.kt"), - "List"); assertFileNotContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1ApiDelegate.kt"), + "List"); + assertFileContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1ApiDelegate.kt"), "Flow"); - assertFileContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1ApiService.kt"), - "List"); assertFileNotContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1ApiService.kt"), + "List"); + assertFileContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1ApiService.kt"), "Flow"); } @@ -3173,21 +3175,21 @@ public void reactiveWithDefaultValueFlow() throws Exception { ); assertFileNotContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1ApiController.kt"), - "Flow"); - - assertFileContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1Api.kt"), "List"); + assertFileNotContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1Api.kt"), + "List"); + assertFileContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1Api.kt"), "Flow"); - assertFileContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1ApiDelegate.kt"), - "List"); assertFileNotContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1ApiDelegate.kt"), + "List"); + assertFileContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1ApiDelegate.kt"), "Flow"); - assertFileContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1ApiService.kt"), - "List"); assertFileNotContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1ApiService.kt"), + "List"); + assertFileContains(Paths.get(output + "/src/main/kotlin/org/openapitools/api/TestV1ApiService.kt"), "Flow"); } @@ -4150,85 +4152,6 @@ public void springPaginatedDelegateCallPassesPageable() throws Exception { } } - @Test(description = "reactive spring-boot: array-of-string returns List with suspend, not Flow (issue #22662)") - public void reactiveArrayOfStringReturnsListNotFlow() throws Exception { - File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); - KotlinSpringServerCodegen codegen = new KotlinSpringServerCodegen(); - codegen.setOutputDir(output.getAbsolutePath()); - codegen.additionalProperties().put(KotlinSpringServerCodegen.REACTIVE, true); - codegen.additionalProperties().put(KotlinSpringServerCodegen.USE_FLOW_FOR_ARRAY_RETURN_TYPE, true); - codegen.additionalProperties().put(KotlinSpringServerCodegen.INTERFACE_ONLY, true); - - List files = new DefaultGenerator() - .opts(new ClientOptInput() - .openAPI(TestUtils.parseSpec("src/test/resources/bugs/issue_7118.yaml")) - .config(codegen)) - .generate(); - - Path apiPath = files.stream() - .filter(f -> f.getName().equals("UsersApi.kt")) - .findFirst() - .orElseThrow() - .toPath(); - - assertFileContains(apiPath, "suspend fun", "List"); - assertFileNotContains(apiPath, "Flow"); - } - - @Test(description = "declarative http interface reactor: array-of-string returns Mono>, not Flux (issue #22662)") - public void declarativeReactorArrayOfStringReturnsMono() throws Exception { - File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); - KotlinSpringServerCodegen codegen = new KotlinSpringServerCodegen(); - codegen.setOutputDir(output.getAbsolutePath()); - codegen.additionalProperties().put(CodegenConstants.LIBRARY, SPRING_DECLARATIVE_HTTP_INTERFACE_LIBRARY); - codegen.additionalProperties().put(KotlinSpringServerCodegen.REACTIVE, true); - codegen.additionalProperties().put(KotlinSpringServerCodegen.DECLARATIVE_INTERFACE_REACTIVE_MODE, "reactor"); - codegen.additionalProperties().put(KotlinSpringServerCodegen.USE_RESPONSE_ENTITY, false); - codegen.additionalProperties().put(KotlinSpringServerCodegen.USE_FLOW_FOR_ARRAY_RETURN_TYPE, false); - - List files = new DefaultGenerator() - .opts(new ClientOptInput() - .openAPI(TestUtils.parseSpec("src/test/resources/bugs/issue_7118.yaml")) - .config(codegen)) - .generate(); - - Path apiPath = files.stream() - .filter(f -> f.getName().equals("UsersApi.kt")) - .findFirst() - .orElseThrow() - .toPath(); - - assertFileContains(apiPath, "Mono>"); - assertFileNotContains(apiPath, "Flux", "import reactor.core.publisher.Flux"); - } - - @Test(description = "declarative http interface reactor + ResponseEntity: array-of-string returns Mono>> (issue #22662)") - public void declarativeReactorArrayOfStringReturnsMonoResponseEntity() throws Exception { - File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); - KotlinSpringServerCodegen codegen = new KotlinSpringServerCodegen(); - codegen.setOutputDir(output.getAbsolutePath()); - codegen.additionalProperties().put(CodegenConstants.LIBRARY, SPRING_DECLARATIVE_HTTP_INTERFACE_LIBRARY); - codegen.additionalProperties().put(KotlinSpringServerCodegen.REACTIVE, true); - codegen.additionalProperties().put(KotlinSpringServerCodegen.DECLARATIVE_INTERFACE_REACTIVE_MODE, "reactor"); - codegen.additionalProperties().put(KotlinSpringServerCodegen.USE_RESPONSE_ENTITY, true); - codegen.additionalProperties().put(KotlinSpringServerCodegen.USE_FLOW_FOR_ARRAY_RETURN_TYPE, false); - - List files = new DefaultGenerator() - .opts(new ClientOptInput() - .openAPI(TestUtils.parseSpec("src/test/resources/bugs/issue_7118.yaml")) - .config(codegen)) - .generate(); - - Path apiPath = files.stream() - .filter(f -> f.getName().equals("UsersApi.kt")) - .findFirst() - .orElseThrow() - .toPath(); - - assertFileContains(apiPath, "Mono>>"); - assertFileNotContains(apiPath, "Flux", "import reactor.core.publisher.Flux"); - } - private Map generateFromContract(String url) throws IOException { return generateFromContract(url, new HashMap<>(), new HashMap<>()); } From 818f1969cc2fc13e3ec954f699faa69473fe4913 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Tue, 23 Jun 2026 01:05:13 +0200 Subject: [PATCH 04/13] fix(petstore): update operation IDs for pets endpoints to prevent collisions in open api merging test --- .../src/test/resources/specs/petstore-v3.1.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/openapi-generator-gradle-plugin/src/test/resources/specs/petstore-v3.1.yaml b/modules/openapi-generator-gradle-plugin/src/test/resources/specs/petstore-v3.1.yaml index 0a8d3d15973c..426d253a62d9 100644 --- a/modules/openapi-generator-gradle-plugin/src/test/resources/specs/petstore-v3.1.yaml +++ b/modules/openapi-generator-gradle-plugin/src/test/resources/specs/petstore-v3.1.yaml @@ -10,7 +10,7 @@ paths: /v3/pets: get: summary: List all pets - operationId: listPets + operationId: listPetsV3 tags: - pets parameters: @@ -41,7 +41,7 @@ paths: $ref: "#/components/schemas/Error" post: summary: Create a pet - operationId: createPets + operationId: createPetsV3 tags: - pets responses: @@ -56,7 +56,7 @@ paths: /v3/pets/{petId}: get: summary: Info for a specific pet - operationId: showPetById + operationId: showPetByIdV3 tags: - pets parameters: From 6d1a54b0bf970fa91ab6f12371e19976df75d001 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Tue, 30 Jun 2026 12:00:39 +0200 Subject: [PATCH 05/13] feat: add merge conflict strategy to MergedSpecBuilder for handling component and path+method conflicts --- .../openapitools/codegen/cmd/Generate.java | 11 +++- .../codegen/plugin/CodeGenMojo.java | 9 +++ .../codegen/plugin/ValidateMojo.java | 10 +++ .../codegen/config/MergedSpecBuilder.java | 42 ++++++++++++- .../codegen/config/MergedSpecBuilderTest.java | 62 +++++++++++++++++++ 5 files changed, 130 insertions(+), 4 deletions(-) diff --git a/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java b/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java index 2907c43afa68..7b4d614512f0 100644 --- a/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java +++ b/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java @@ -68,6 +68,10 @@ public class Generate extends OpenApiGeneratorCommand { @Option(name = "--merged-spec-filename", title = "Name of resulted merged specs file (used along with --input-spec-root-directory option)") private String mergedFileName; + @Option(name = "--merge-conflict-strategy", title = "Merge conflict strategy", + description = "Strategy when two specs define the same component/path+method with different definitions: WARN (default, keep first) or FAIL (abort).") + private String mergeConflictStrategy; + @Option(name = {"-t", "--template-dir"}, title = "template directory", description = "folder containing the template files") private String templateDir; @@ -346,8 +350,11 @@ public class Generate extends OpenApiGeneratorCommand { @Override public void execute() { if (StringUtils.isNotBlank(inputSpecRootDirectory)) { - spec = new MergedSpecBuilder(inputSpecRootDirectory, StringUtils.isBlank(mergedFileName) ? "_merged_spec" : mergedFileName) - .buildMergedSpec(); + MergedSpecBuilder builder = new MergedSpecBuilder(inputSpecRootDirectory, StringUtils.isBlank(mergedFileName) ? "_merged_spec" : mergedFileName); + if (StringUtils.isNotBlank(mergeConflictStrategy)) { + builder.withConflictStrategy(MergedSpecBuilder.MergeConflictStrategy.valueOf(mergeConflictStrategy.toUpperCase(java.util.Locale.ROOT))); + } + spec = builder.buildMergedSpec(); System.out.println("Merge input spec would be used - " + spec); } diff --git a/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java b/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java index 2308efc620e3..5a2fb5c722d5 100644 --- a/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java +++ b/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java @@ -137,6 +137,14 @@ public class CodeGenMojo extends AbstractMojo { @Parameter(name = "mergedFileInfoVersion", property = "openapi.generator.maven.plugin.mergedFileInfoVersion", defaultValue = "1.0.0") private String mergedFileInfoVersion; + /** + * Strategy when two specs define the same component name or path+method with different + * definitions. Accepted values: WARN (default, keep the first definition and log a warning) + * or FAIL (abort the build with an error). + */ + @Parameter(name = "mergeConflictStrategy", property = "openapi.generator.maven.plugin.mergeConflictStrategy", defaultValue = "WARN") + private String mergeConflictStrategy; + /** * Git host, e.g. gitlab.com. */ @@ -584,6 +592,7 @@ public void execute() throws MojoExecutionException { inputSpec = new MergedSpecBuilder(inputSpecRootDirectory, mergedFileName, mergedFileInfoName, mergedFileInfoDescription, mergedFileInfoVersion, auth) + .withConflictStrategy(MergedSpecBuilder.MergeConflictStrategy.valueOf(mergeConflictStrategy.toUpperCase(java.util.Locale.ROOT))) .buildMergedSpec(); LOGGER.info("Merge input spec would be used - {}", inputSpec); } diff --git a/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/ValidateMojo.java b/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/ValidateMojo.java index 27a44219edf4..b71ca2775435 100644 --- a/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/ValidateMojo.java +++ b/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/ValidateMojo.java @@ -127,6 +127,15 @@ public class ValidateMojo extends AbstractMojo { */ @Parameter(name = "mergedFileInfoVersion", property = "openapi.generator.maven.plugin.mergedFileInfoVersion", defaultValue = "1.0.0") private String mergedFileInfoVersion; + + /** + * Strategy when two specs define the same component name or path+method with different + * definitions. Accepted values: WARN (default, keep the first definition and log a warning) + * or FAIL (abort the build with an error). + */ + @Parameter(name = "mergeConflictStrategy", property = "openapi.generator.maven.plugin.mergeConflictStrategy", defaultValue = "WARN") + private String mergeConflictStrategy; + /** * The path to the collapsed single-file representation of the OpenAPI spec. */ @@ -251,6 +260,7 @@ private Optional mergeInDirectory() { mergedSpec = Optional.of(new MergedSpecBuilder(inputSpecRootDirectory, mergedFileName, mergedFileInfoName, mergedFileInfoDescription, mergedFileInfoVersion, auth) + .withConflictStrategy(MergedSpecBuilder.MergeConflictStrategy.valueOf(mergeConflictStrategy.toUpperCase(java.util.Locale.ROOT))) .buildMergedSpec()); LOGGER.info("Merge input spec would be used - {}", mergedSpec.get()); } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java index 27aa523ee363..0b112b08861e 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java @@ -27,6 +27,17 @@ public class MergedSpecBuilder { + /** + * Controls what happens when two specs define the same component name (schema, response, etc.) + * or the same path+method with different definitions. + */ + public enum MergeConflictStrategy { + /** Log a warning and keep the first definition (default). */ + WARN, + /** Throw a {@link RuntimeException} and abort the merge. */ + FAIL + } + private static final Logger LOGGER = LoggerFactory.getLogger(MergedSpecBuilder.class); private static final Set SPEC_EXTENSIONS = new HashSet<>(Arrays.asList(".yaml", ".yml", ".json")); @@ -37,6 +48,7 @@ public class MergedSpecBuilder { private final String mergedFileInfoDescription; private final String mergedFileInfoVersion; private final String auth; + private MergeConflictStrategy conflictStrategy = MergeConflictStrategy.WARN; public MergedSpecBuilder(final String rootDirectory, final String mergeFileName) { this(rootDirectory, mergeFileName, "merged spec", "merged spec", "1.0.0", null); @@ -52,6 +64,20 @@ public MergedSpecBuilder(final String rootDirectory, final String mergeFileName, this.auth = auth; } + /** + * Sets the strategy used when two specs define the same component name or path+method + * with conflicting (non-identical) definitions. + * + * @param strategy {@link MergeConflictStrategy#WARN} to log a warning and keep the first + * definition (default), or {@link MergeConflictStrategy#FAIL} to throw a + * {@link RuntimeException} and abort. + * @return this builder, for chaining + */ + public MergedSpecBuilder withConflictStrategy(MergeConflictStrategy strategy) { + this.conflictStrategy = strategy; + return this; + } + public String buildMergedSpec() { deleteMergedFileFromPreviousRun(); List specRelatedPaths = getAllSpecFilesInDirectory(); @@ -181,7 +207,13 @@ private void mergePathItem(PathItem existing, PathItem incoming, String pathKey) } incoming.readOperationsMap().forEach((method, operation) -> { if (existing.readOperationsMap() != null && existing.readOperationsMap().containsKey(method)) { - LOGGER.warn("Path+method collision during spec merge: {} {} is defined in multiple specs. Keeping the first occurrence.", method, pathKey); + String message = String.format(Locale.ROOT, + "Path+method collision during spec merge: %s %s is defined in multiple specs with different definitions. Keeping the first definition.", + method, pathKey); + if (conflictStrategy == MergeConflictStrategy.FAIL) { + throw new RuntimeException(message); + } + LOGGER.warn(message); } else { existing.operation(method, operation); } @@ -213,7 +245,13 @@ private void mergeComponentMap(Map existing, Map incom incoming.forEach((name, value) -> { if (existing != null && existing.containsKey(name)) { if (!Objects.equals(existing.get(name), value)) { - LOGGER.warn("Component {} name conflict during spec merge: '{}' is defined in multiple specs with different definitions. Keeping the first definition.", typeName, name); + String message = String.format(Locale.ROOT, + "Component %s name conflict during spec merge: '%s' is defined in multiple specs with different definitions. Keeping the first definition.", + typeName, name); + if (conflictStrategy == MergeConflictStrategy.FAIL) { + throw new RuntimeException(message); + } + LOGGER.warn(message); } // identical or keeping first — either way, skip } else { diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/MergedSpecBuilderTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/MergedSpecBuilderTest.java index 330d1429c70b..3845f31e65bd 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/MergedSpecBuilderTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/MergedSpecBuilderTest.java @@ -345,4 +345,66 @@ private void shouldWarnOnSchemaNameConflict(String fileExt) throws IOException { logger.detachAppender(listAppender); } } + + // ---- FAIL conflict strategy tests ---- + + @Test + public void shouldFailOnSchemaNameConflictWithFailStrategy_yaml() throws IOException { + shouldFailOnSchemaNameConflictWithFailStrategy("yaml"); + } + + @Test + public void shouldFailOnSchemaNameConflictWithFailStrategy_json() throws IOException { + shouldFailOnSchemaNameConflictWithFailStrategy("json"); + } + + private void shouldFailOnSchemaNameConflictWithFailStrategy(String fileExt) throws IOException { + File dir = Files.createTempDirectory("spec-schema-conflict-fail").toFile().getCanonicalFile(); + dir.deleteOnExit(); + + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec1." + fileExt), dir.toPath().resolve("spec1." + fileExt)); + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec-schema-conflict." + fileExt), dir.toPath().resolve("spec-schema-conflict." + fileExt)); + + try { + new MergedSpecBuilder(dir.getAbsolutePath().replace('\\', '/'), "_merged") + .withConflictStrategy(MergedSpecBuilder.MergeConflictStrategy.FAIL) + .buildMergedSpec(); + fail("Expected RuntimeException due to schema name conflict with FAIL strategy"); + } catch (RuntimeException e) { + assertTrue(e.getMessage().contains("Spec1Model"), "Exception message must mention the conflicting schema name"); + } + } + + @Test + public void shouldFailOnPathMethodConflictWithFailStrategy_yaml() throws IOException { + shouldFailOnPathMethodConflictWithFailStrategy("yaml"); + } + + @Test + public void shouldFailOnPathMethodConflictWithFailStrategy_json() throws IOException { + shouldFailOnPathMethodConflictWithFailStrategy("json"); + } + + /** + * spec-path-method-conflict defines the same path+method (GET /spec1) as spec1. + * With FAIL strategy this must throw. + */ + private void shouldFailOnPathMethodConflictWithFailStrategy(String fileExt) throws IOException { + File dir = Files.createTempDirectory("spec-path-conflict-fail").toFile().getCanonicalFile(); + dir.deleteOnExit(); + + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec1." + fileExt), dir.toPath().resolve("spec1." + fileExt)); + // spec-collision defines a POST on the same path — no method conflict, use spec-schema-conflict for method conflict + // Instead re-use spec1 copied as a second file to force a path+method duplicate + Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec1." + fileExt), dir.toPath().resolve("spec1-duplicate." + fileExt)); + + try { + new MergedSpecBuilder(dir.getAbsolutePath().replace('\\', '/'), "_merged") + .withConflictStrategy(MergedSpecBuilder.MergeConflictStrategy.FAIL) + .buildMergedSpec(); + fail("Expected RuntimeException due to path+method conflict with FAIL strategy"); + } catch (RuntimeException e) { + assertTrue(e.getMessage().contains("/spec1"), "Exception message must mention the conflicting path"); + } + } } From fa2d52140cdb1771c9f6e8c90b2b3def96b7c5df Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Tue, 30 Jun 2026 12:06:25 +0200 Subject: [PATCH 06/13] feat: add merged file name and merge conflict strategy properties to OpenApiGenerator --- .../gradle/plugin/OpenApiGeneratorPlugin.kt | 2 ++ .../OpenApiGeneratorGenerateExtension.kt | 16 ++++++++++++++++ .../gradle/plugin/tasks/GenerateTask.kt | 11 +++++++++++ 3 files changed, 29 insertions(+) diff --git a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/OpenApiGeneratorPlugin.kt b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/OpenApiGeneratorPlugin.kt index 7ee348700898..0a25f75fb9ae 100644 --- a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/OpenApiGeneratorPlugin.kt +++ b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/OpenApiGeneratorPlugin.kt @@ -102,6 +102,8 @@ class OpenApiGeneratorPlugin : Plugin { inputSpec.set(generate.inputSpec) inputSpecRootDirectory.set(generate.inputSpecRootDirectory) inputSpecRootDirectorySkipMerge.set(generate.inputSpecRootDirectorySkipMerge) + mergedFileName.set(generate.mergedFileName) + mergeConflictStrategy.set(generate.mergeConflictStrategy) remoteInputSpec.set(generate.remoteInputSpec) templateDir.set(generate.templateDir) templateResourcePath.set(generate.templateResourcePath) diff --git a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt index 114a8f2bb0b5..0d29a17582be 100644 --- a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt +++ b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt @@ -80,6 +80,20 @@ open class OpenApiGeneratorGenerateExtension(private val project: Project) { */ val inputSpecRootDirectorySkipMerge = project.objects.property() + /** + * Name of the file that will contain all merged specs (used with inputSpecRootDirectory). + * + * Default: "merged" + */ + val mergedFileName = project.objects.property() + + /** + * Strategy when two specs define the same component name or path+method with conflicting + * (non-identical) definitions. Accepted values: "WARN" (default, keep first definition and + * log a warning) or "FAIL" (throw an exception and abort the build). + */ + val mergeConflictStrategy = project.objects.property() + /** * The remote Open API 2.0/3.x specification URL location. */ @@ -449,6 +463,8 @@ open class OpenApiGeneratorGenerateExtension(private val project: Project) { fun applyDefaults() { releaseNote.convention("Minor update") inputSpecRootDirectorySkipMerge.convention(false) + mergedFileName.convention("merged") + mergeConflictStrategy.convention("WARN") modelNamePrefix.convention("") modelNameSuffix.convention("") apiNameSuffix.convention("") diff --git a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt index 3d279cd26044..11431764d269 100644 --- a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt +++ b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt @@ -406,6 +406,14 @@ abstract class GenerateTask : DefaultTask() { @get:Optional abstract val mergedFileName: Property + /** + * Strategy when two specs define the same component name or path+method with conflicting + * definitions. Accepted values: "WARN" (default) or "FAIL". + */ + @get:Input + @get:Optional + abstract val mergeConflictStrategy: Property + /** * The remote Open API 2.0/3.x specification URL location. */ @@ -873,6 +881,7 @@ abstract class GenerateTask : DefaultTask() { init { inputSpecRootDirectorySkipMerge.convention(false) mergedFileName.convention("merged") + mergeConflictStrategy.convention("WARN") } @Suppress("unused") @@ -896,6 +905,8 @@ abstract class GenerateTask : DefaultTask() { finalResolvedInputSpec = MergedSpecBuilder( inputDir.asFile.absolutePath, mergedFileName.get() + ).withConflictStrategy( + MergedSpecBuilder.MergeConflictStrategy.valueOf(mergeConflictStrategy.get().uppercase()) ).buildMergedSpec() logger.info("Merge input spec used: {}", finalResolvedInputSpec) } From 6b16b1244680060421d0af2a56dc9a8eeb34ed53 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Tue, 30 Jun 2026 12:47:56 +0200 Subject: [PATCH 07/13] feat: implement merge mode and conflict strategy options in MergedSpecBuilder --- .../openapitools/codegen/cmd/Generate.java | 9 +- .../gradle/plugin/OpenApiGeneratorPlugin.kt | 1 + .../OpenApiGeneratorGenerateExtension.kt | 11 +- .../gradle/plugin/tasks/GenerateTask.kt | 14 +- .../codegen/plugin/CodeGenMojo.java | 11 +- .../codegen/plugin/ValidateMojo.java | 11 +- .../codegen/config/MergedSpecBuilder.java | 254 +++++++++++++++++- .../codegen/config/MergedSpecBuilderTest.java | 3 + 8 files changed, 302 insertions(+), 12 deletions(-) diff --git a/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java b/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java index 7b4d614512f0..6e3ab1fd01fd 100644 --- a/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java +++ b/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java @@ -69,9 +69,13 @@ public class Generate extends OpenApiGeneratorCommand { private String mergedFileName; @Option(name = "--merge-conflict-strategy", title = "Merge conflict strategy", - description = "Strategy when two specs define the same component/path+method with different definitions: WARN (default, keep first) or FAIL (abort).") + description = "Strategy when two specs define the same component/path+method with different definitions: WARN (default, keep first) or FAIL (abort). Only applies with --merge-mode DEEP.") private String mergeConflictStrategy; + @Option(name = "--merge-mode", title = "Merge mode", + description = "How multiple spec files are merged: REF (default, original $ref-based) or DEEP (full inline merge with component deduplication).") + private String mergeMode; + @Option(name = {"-t", "--template-dir"}, title = "template directory", description = "folder containing the template files") private String templateDir; @@ -351,6 +355,9 @@ public class Generate extends OpenApiGeneratorCommand { public void execute() { if (StringUtils.isNotBlank(inputSpecRootDirectory)) { MergedSpecBuilder builder = new MergedSpecBuilder(inputSpecRootDirectory, StringUtils.isBlank(mergedFileName) ? "_merged_spec" : mergedFileName); + if (StringUtils.isNotBlank(mergeMode)) { + builder.withMergeMode(MergedSpecBuilder.MergeMode.valueOf(mergeMode.toUpperCase(java.util.Locale.ROOT))); + } if (StringUtils.isNotBlank(mergeConflictStrategy)) { builder.withConflictStrategy(MergedSpecBuilder.MergeConflictStrategy.valueOf(mergeConflictStrategy.toUpperCase(java.util.Locale.ROOT))); } diff --git a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/OpenApiGeneratorPlugin.kt b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/OpenApiGeneratorPlugin.kt index 0a25f75fb9ae..013b47fa53b4 100644 --- a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/OpenApiGeneratorPlugin.kt +++ b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/OpenApiGeneratorPlugin.kt @@ -103,6 +103,7 @@ class OpenApiGeneratorPlugin : Plugin { inputSpecRootDirectory.set(generate.inputSpecRootDirectory) inputSpecRootDirectorySkipMerge.set(generate.inputSpecRootDirectorySkipMerge) mergedFileName.set(generate.mergedFileName) + mergeMode.set(generate.mergeMode) mergeConflictStrategy.set(generate.mergeConflictStrategy) remoteInputSpec.set(generate.remoteInputSpec) templateDir.set(generate.templateDir) diff --git a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt index 0d29a17582be..6be0b6843e6f 100644 --- a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt +++ b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt @@ -87,10 +87,18 @@ open class OpenApiGeneratorGenerateExtension(private val project: Project) { */ val mergedFileName = project.objects.property() + /** + * How multiple spec files are merged. Accepted values: "REF" (default, original $ref-based + * shallow merge, backward-compatible) or "DEEP" (full inline merge with component + * deduplication and conflict detection). + */ + val mergeMode = project.objects.property() + /** * Strategy when two specs define the same component name or path+method with conflicting * (non-identical) definitions. Accepted values: "WARN" (default, keep first definition and - * log a warning) or "FAIL" (throw an exception and abort the build). + * log a warning) or "FAIL" (throw an exception and abort the build). Only applies when + * mergeMode is "DEEP". */ val mergeConflictStrategy = project.objects.property() @@ -464,6 +472,7 @@ open class OpenApiGeneratorGenerateExtension(private val project: Project) { releaseNote.convention("Minor update") inputSpecRootDirectorySkipMerge.convention(false) mergedFileName.convention("merged") + mergeMode.convention("REF") mergeConflictStrategy.convention("WARN") modelNamePrefix.convention("") modelNameSuffix.convention("") diff --git a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt index 11431764d269..193822a3bc0c 100644 --- a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt +++ b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt @@ -406,9 +406,18 @@ abstract class GenerateTask : DefaultTask() { @get:Optional abstract val mergedFileName: Property + /** + * How multiple spec files are merged. Accepted values: "REF" (default, original $ref-based + * shallow merge, backward-compatible) or "DEEP" (full inline merge with component + * deduplication and conflict detection). + */ + @get:Input + @get:Optional + abstract val mergeMode: Property + /** * Strategy when two specs define the same component name or path+method with conflicting - * definitions. Accepted values: "WARN" (default) or "FAIL". + * definitions. Accepted values: "WARN" (default) or "FAIL". Only applies when mergeMode is "DEEP". */ @get:Input @get:Optional @@ -881,6 +890,7 @@ abstract class GenerateTask : DefaultTask() { init { inputSpecRootDirectorySkipMerge.convention(false) mergedFileName.convention("merged") + mergeMode.convention("REF") mergeConflictStrategy.convention("WARN") } @@ -905,6 +915,8 @@ abstract class GenerateTask : DefaultTask() { finalResolvedInputSpec = MergedSpecBuilder( inputDir.asFile.absolutePath, mergedFileName.get() + ).withMergeMode( + MergedSpecBuilder.MergeMode.valueOf(mergeMode.get().uppercase()) ).withConflictStrategy( MergedSpecBuilder.MergeConflictStrategy.valueOf(mergeConflictStrategy.get().uppercase()) ).buildMergedSpec() diff --git a/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java b/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java index 5a2fb5c722d5..5d84ed52281a 100644 --- a/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java +++ b/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java @@ -140,11 +140,19 @@ public class CodeGenMojo extends AbstractMojo { /** * Strategy when two specs define the same component name or path+method with different * definitions. Accepted values: WARN (default, keep the first definition and log a warning) - * or FAIL (abort the build with an error). + * or FAIL (abort the build with an error). Only applies when mergeMode is DEEP. */ @Parameter(name = "mergeConflictStrategy", property = "openapi.generator.maven.plugin.mergeConflictStrategy", defaultValue = "WARN") private String mergeConflictStrategy; + /** + * How multiple spec files are merged. Accepted values: REF (default, original $ref-based + * shallow merge, backward-compatible) or DEEP (full inline merge with component + * deduplication and conflict detection). + */ + @Parameter(name = "mergeMode", property = "openapi.generator.maven.plugin.mergeMode", defaultValue = "REF") + private String mergeMode; + /** * Git host, e.g. gitlab.com. */ @@ -592,6 +600,7 @@ public void execute() throws MojoExecutionException { inputSpec = new MergedSpecBuilder(inputSpecRootDirectory, mergedFileName, mergedFileInfoName, mergedFileInfoDescription, mergedFileInfoVersion, auth) + .withMergeMode(MergedSpecBuilder.MergeMode.valueOf(mergeMode.toUpperCase(java.util.Locale.ROOT))) .withConflictStrategy(MergedSpecBuilder.MergeConflictStrategy.valueOf(mergeConflictStrategy.toUpperCase(java.util.Locale.ROOT))) .buildMergedSpec(); LOGGER.info("Merge input spec would be used - {}", inputSpec); diff --git a/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/ValidateMojo.java b/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/ValidateMojo.java index b71ca2775435..28d03e63056f 100644 --- a/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/ValidateMojo.java +++ b/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/ValidateMojo.java @@ -131,11 +131,19 @@ public class ValidateMojo extends AbstractMojo { /** * Strategy when two specs define the same component name or path+method with different * definitions. Accepted values: WARN (default, keep the first definition and log a warning) - * or FAIL (abort the build with an error). + * or FAIL (abort the build with an error). Only applies when mergeMode is DEEP. */ @Parameter(name = "mergeConflictStrategy", property = "openapi.generator.maven.plugin.mergeConflictStrategy", defaultValue = "WARN") private String mergeConflictStrategy; + /** + * How multiple spec files are merged. Accepted values: REF (default, original $ref-based + * shallow merge, backward-compatible) or DEEP (full inline merge with component + * deduplication and conflict detection). + */ + @Parameter(name = "mergeMode", property = "openapi.generator.maven.plugin.mergeMode", defaultValue = "REF") + private String mergeMode; + /** * The path to the collapsed single-file representation of the OpenAPI spec. */ @@ -260,6 +268,7 @@ private Optional mergeInDirectory() { mergedSpec = Optional.of(new MergedSpecBuilder(inputSpecRootDirectory, mergedFileName, mergedFileInfoName, mergedFileInfoDescription, mergedFileInfoVersion, auth) + .withMergeMode(MergedSpecBuilder.MergeMode.valueOf(mergeMode.toUpperCase(java.util.Locale.ROOT))) .withConflictStrategy(MergedSpecBuilder.MergeConflictStrategy.valueOf(mergeConflictStrategy.toUpperCase(java.util.Locale.ROOT))) .buildMergedSpec()); LOGGER.info("Merge input spec would be used - {}", mergedSpec.get()); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java index 0b112b08861e..06afa5a2bb0d 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java @@ -1,16 +1,19 @@ package org.openapitools.codegen.config; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.google.common.collect.ImmutableMap; import io.swagger.parser.OpenAPIParser; import io.swagger.v3.core.util.Json; import io.swagger.v3.core.util.Yaml; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.PathItem; -import io.swagger.v3.oas.models.Paths; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.servers.Server; import io.swagger.v3.parser.core.models.ParseOptions; +import org.apache.commons.lang3.ObjectUtils; import org.openapitools.codegen.auth.AuthParser; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,16 +23,39 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.util.*; +import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; public class MergedSpecBuilder { + /** + * Controls how multiple spec files are merged together. + * + *
    + *
  • {@link #REF} (default) — original behaviour: generates a lightweight "index" spec + * where each path is a {@code $ref} pointing back to the originating spec file. + * Components stay in their original files and are resolved at generation time. + * This preserves backward compatibility.
  • + *
  • {@link #DEEP} — opt-in: fully parses and inlines all paths and components into a + * single self-contained spec. Detects and handles component/path+method conflicts + * according to {@link MergeConflictStrategy}.
  • + *
+ */ + public enum MergeMode { + /** Original $ref-based shallow merge (default, backward-compatible). */ + REF, + /** Full deep merge: inlines all components and paths into one spec. */ + DEEP + } + /** * Controls what happens when two specs define the same component name (schema, response, etc.) - * or the same path+method with different definitions. + * or the same path+method with different definitions. Only applies when {@link MergeMode#DEEP} + * is active. */ public enum MergeConflictStrategy { /** Log a warning and keep the first definition (default). */ @@ -42,12 +68,19 @@ public enum MergeConflictStrategy { private static final Set SPEC_EXTENSIONS = new HashSet<>(Arrays.asList(".yaml", ".yml", ".json")); + /** Matches any RFC 3986 URI scheme (e.g. http, https, file, ftp) followed by ':'. */ + private static final Pattern URI_SCHEME_PATTERN = Pattern.compile("^[a-zA-Z][a-zA-Z0-9+\\-.]*:"); + + private static final ObjectMapper JSON_MAPPER = new ObjectMapper(); + private static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()); + private final String inputSpecRootDirectory; private final String mergeFileName; private final String mergedFileInfoName; private final String mergedFileInfoDescription; private final String mergedFileInfoVersion; private final String auth; + private MergeMode mergeMode = MergeMode.REF; private MergeConflictStrategy conflictStrategy = MergeConflictStrategy.WARN; public MergedSpecBuilder(final String rootDirectory, final String mergeFileName) { @@ -64,9 +97,21 @@ public MergedSpecBuilder(final String rootDirectory, final String mergeFileName, this.auth = auth; } + /** + * Sets the merge mode. {@link MergeMode#REF} (default) uses the original $ref-based approach + * for backward compatibility. {@link MergeMode#DEEP} fully inlines all components and paths. + * + * @return this builder, for chaining + */ + public MergedSpecBuilder withMergeMode(MergeMode mode) { + this.mergeMode = mode; + return this; + } + /** * Sets the strategy used when two specs define the same component name or path+method - * with conflicting (non-identical) definitions. + * with conflicting (non-identical) definitions. Only applies when {@link MergeMode#DEEP} + * is active. * * @param strategy {@link MergeConflictStrategy#WARN} to log a warning and keep the first * definition (default), or {@link MergeConflictStrategy#FAIL} to throw a @@ -86,6 +131,201 @@ public String buildMergedSpec() { } LOGGER.info("In spec root directory {} found specs {}", inputSpecRootDirectory, specRelatedPaths); + if (mergeMode == MergeMode.DEEP) { + return buildDeepMergedSpec(specRelatedPaths); + } + return buildRefMergedSpec(specRelatedPaths); + } + + // ------------------------------------------------------------------------- + // REF mode — original $ref-based shallow merge + // ------------------------------------------------------------------------- + + private String buildRefMergedSpec(List specRelatedPaths) { + String openapiVersion = null; + boolean isJson = false; + ParseOptions options = new ParseOptions(); + options.setResolve(true); + List allPaths = new ArrayList<>(); + List allServers = new ArrayList<>(); + + for (String specRelatedPath : specRelatedPaths) { + String specPath = inputSpecRootDirectory + File.separator + specRelatedPath; + try { + LOGGER.info("Reading spec: {}", specPath); + + OpenAPI result = new OpenAPIParser() + .readLocation(specPath, AuthParser.parse(auth), options) + .getOpenAPI(); + + if (result == null) { + LOGGER.error("Failed to read file: {}. It would be ignored", specPath); + continue; + } + + if (openapiVersion == null) { + openapiVersion = result.getOpenapi(); + if (specRelatedPath.toLowerCase(Locale.ROOT).endsWith(".json")) { + isJson = true; + } + } + allServers.addAll(ObjectUtils.defaultIfNull(result.getServers(), Collections.emptyList())); + + ObjectMapper rawMapper = specRelatedPath.toLowerCase(Locale.ROOT).endsWith(".json") + ? JSON_MAPPER : YAML_MAPPER; + Map rawSpec = rawMapper.readValue(new File(specPath), Map.class); + Object pathsObj = rawSpec.get("paths"); + @SuppressWarnings("unchecked") + Map rawPaths = (pathsObj instanceof Map) + ? (Map) pathsObj + : Collections.emptyMap(); + + allPaths.add(new SpecWithPaths(specRelatedPath, rawPaths)); + } catch (Exception e) { + LOGGER.error("Failed to read file: {}. It would be ignored", specPath); + } + } + + Map mergedSpec = generateRefMergedSpec(openapiVersion, allPaths, allServers); + String mergedFilename = this.mergeFileName + (isJson ? ".json" : ".yaml"); + Path mergedFilePath = Paths.get(inputSpecRootDirectory, mergedFilename); + + try { + ObjectMapper objectMapper = isJson ? JSON_MAPPER : YAML_MAPPER; + Files.write(mergedFilePath, objectMapper.writeValueAsBytes(mergedSpec), StandardOpenOption.CREATE, StandardOpenOption.WRITE); + } catch (IOException e) { + throw new RuntimeException(e); + } + + return mergedFilePath.toString(); + } + + private Map generateRefMergedSpec(String openapiVersion, List allPaths, List allServers) { + Map spec = generateHeader(openapiVersion, mergedFileInfoName, mergedFileInfoDescription, mergedFileInfoVersion, allServers); + Map paths = new LinkedHashMap<>(); + spec.put("paths", paths); + + Map pathOccurrences = allPaths.stream() + .flatMap(s -> s.rawPaths.keySet().stream()) + .collect(Collectors.groupingBy(p -> p, Collectors.counting())); + + for (SpecWithPaths specWithPaths : allPaths) { + for (Map.Entry pathEntry : specWithPaths.rawPaths.entrySet()) { + String path = pathEntry.getKey(); + String encodedPath = path.replace("/", "~1"); + String specRefBase = "./" + specWithPaths.specRelatedPath.replace('\\', '/') + "#/paths/" + encodedPath; + + if (pathOccurrences.getOrDefault(path, 0L) <= 1L) { + paths.put(path, ImmutableMap.of("$ref", specRefBase)); + } else { + Object rawValue = pathEntry.getValue(); + if (!(rawValue instanceof Map)) { + LOGGER.warn("Path {} in {} has an unexpected non-map value; skipping.", path, specWithPaths.specRelatedPath); + continue; + } + @SuppressWarnings("unchecked") + Map rawPathItem = (Map) rawValue; + Map adjustedPathItem = adjustRefs(rawPathItem, specWithPaths.specRelatedPath); + + @SuppressWarnings("unchecked") + Map existingPathItem = (Map) + paths.computeIfAbsent(path, k -> new LinkedHashMap<>()); + + for (Map.Entry methodEntry : adjustedPathItem.entrySet()) { + if (existingPathItem.containsKey(methodEntry.getKey())) { + LOGGER.warn("Path {} HTTP method {} is defined in multiple spec files. Last definition will be used.", + path, methodEntry.getKey()); + } + existingPathItem.put(methodEntry.getKey(), methodEntry.getValue()); + } + } + } + } + + return spec; + } + + @SuppressWarnings("unchecked") + private static Map adjustRefs(Map map, String specRelatedPath) { + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + if ("$ref".equals(entry.getKey()) && entry.getValue() instanceof String) { + result.put("$ref", adjustRef((String) entry.getValue(), specRelatedPath)); + } else if (entry.getValue() instanceof Map) { + result.put(entry.getKey(), adjustRefs((Map) entry.getValue(), specRelatedPath)); + } else if (entry.getValue() instanceof List) { + result.put(entry.getKey(), adjustRefsList((List) entry.getValue(), specRelatedPath)); + } else { + result.put(entry.getKey(), entry.getValue()); + } + } + return result; + } + + @SuppressWarnings("unchecked") + private static List adjustRefsList(List list, String specRelatedPath) { + List result = new ArrayList<>(); + for (Object item : list) { + if (item instanceof Map) { + result.add(adjustRefs((Map) item, specRelatedPath)); + } else if (item instanceof List) { + result.add(adjustRefsList((List) item, specRelatedPath)); + } else { + result.add(item); + } + } + return result; + } + + private static String adjustRef(String ref, String specRelatedPath) { + String normalizedSpec = specRelatedPath.replace('\\', '/'); + if (ref.startsWith("#")) { + return "./" + normalizedSpec + ref; + } + if (ref.startsWith("/") || URI_SCHEME_PATTERN.matcher(ref).lookingAt()) { + return ref; + } + Path specPath = Paths.get(normalizedSpec); + Path specParent = specPath.getParent(); + if (specParent == null) { + return ref; + } + Path resolved = specParent.resolve(Paths.get(ref.replace('\\', '/'))).normalize(); + return "./" + resolved.toString().replace('\\', '/'); + } + + private static Map generateHeader(String openapiVersion, String title, String description, String version, List allServers) { + Map map = new LinkedHashMap<>(); + map.put("openapi", openapiVersion); + map.put("info", ImmutableMap.of("title", title, "description", description, "version", version)); + + Set> servers = allServers.stream() + .map(Server::getUrl) + .distinct() + .map(url -> ImmutableMap.of("url", url)) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (servers.isEmpty()) { + servers = Collections.singleton(ImmutableMap.of("url", "http://localhost:8080")); + } + map.put("servers", servers); + return map; + } + + private static class SpecWithPaths { + private final String specRelatedPath; + private final Map rawPaths; + + private SpecWithPaths(final String specRelatedPath, final Map rawPaths) { + this.specRelatedPath = specRelatedPath; + this.rawPaths = rawPaths; + } + } + + // ------------------------------------------------------------------------- + // DEEP mode — full inline merge with component conflict detection + // ------------------------------------------------------------------------- + + private String buildDeepMergedSpec(List specRelatedPaths) { boolean isJson = false; ParseOptions options = new ParseOptions(); options.setResolve(true); @@ -123,7 +363,7 @@ public String buildMergedSpec() { OpenAPI merged = mergeSpecs(parsedSpecs, allServers); String mergedFilename = this.mergeFileName + (isJson ? ".json" : ".yaml"); - Path mergedFilePath = java.nio.file.Paths.get(inputSpecRootDirectory, mergedFilename); + Path mergedFilePath = Paths.get(inputSpecRootDirectory, mergedFilename); try { String content = isJson @@ -173,7 +413,7 @@ OpenAPI mergeSpecs(List specs, List allServers) { distinctServerUrls.forEach(url -> merged.addServersItem(new Server().url(url))); } - merged.setPaths(new Paths()); + merged.setPaths(new io.swagger.v3.oas.models.Paths()); merged.setComponents(new Components()); for (OpenAPI spec : specs) { @@ -279,11 +519,11 @@ private List getAllSpecFilesInDirectory() { private void deleteMergedFileFromPreviousRun() { try { - Files.deleteIfExists(java.nio.file.Paths.get(inputSpecRootDirectory + File.separator + mergeFileName + ".json")); + Files.deleteIfExists(Paths.get(inputSpecRootDirectory + File.separator + mergeFileName + ".json")); } catch (IOException ignored) { } try { - Files.deleteIfExists(java.nio.file.Paths.get(inputSpecRootDirectory + File.separator + mergeFileName + ".yaml")); + Files.deleteIfExists(Paths.get(inputSpecRootDirectory + File.separator + mergeFileName + ".yaml")); } catch (IOException ignored) { } } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/MergedSpecBuilderTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/MergedSpecBuilderTest.java index 3845f31e65bd..1893cbfe7238 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/MergedSpecBuilderTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/MergedSpecBuilderTest.java @@ -322,6 +322,7 @@ private void shouldWarnOnSchemaNameConflict(String fileExt) throws IOException { Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec-schema-conflict." + fileExt), dir.toPath().resolve("spec-schema-conflict." + fileExt)); String mergedSpec = new MergedSpecBuilder(dir.getAbsolutePath().replace('\\', '/'), "_merged") + .withMergeMode(MergedSpecBuilder.MergeMode.DEEP) .buildMergedSpec(); ParseOptions parseOptions = new ParseOptions(); @@ -367,6 +368,7 @@ private void shouldFailOnSchemaNameConflictWithFailStrategy(String fileExt) thro try { new MergedSpecBuilder(dir.getAbsolutePath().replace('\\', '/'), "_merged") + .withMergeMode(MergedSpecBuilder.MergeMode.DEEP) .withConflictStrategy(MergedSpecBuilder.MergeConflictStrategy.FAIL) .buildMergedSpec(); fail("Expected RuntimeException due to schema name conflict with FAIL strategy"); @@ -400,6 +402,7 @@ private void shouldFailOnPathMethodConflictWithFailStrategy(String fileExt) thro try { new MergedSpecBuilder(dir.getAbsolutePath().replace('\\', '/'), "_merged") + .withMergeMode(MergedSpecBuilder.MergeMode.DEEP) .withConflictStrategy(MergedSpecBuilder.MergeConflictStrategy.FAIL) .buildMergedSpec(); fail("Expected RuntimeException due to path+method conflict with FAIL strategy"); From f5f50a086e4048cbeea66cc9e27cd4ba793bc0ab Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Tue, 30 Jun 2026 13:09:07 +0200 Subject: [PATCH 08/13] restore original behavior --- .../codegen/config/MergedSpecBuilder.java | 107 ++---------------- .../codegen/config/MergedSpecBuilderTest.java | 8 +- 2 files changed, 8 insertions(+), 107 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java index 06afa5a2bb0d..f853c95dbdc0 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java @@ -26,7 +26,6 @@ import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.util.*; -import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -68,9 +67,6 @@ public enum MergeConflictStrategy { private static final Set SPEC_EXTENSIONS = new HashSet<>(Arrays.asList(".yaml", ".yml", ".json")); - /** Matches any RFC 3986 URI scheme (e.g. http, https, file, ftp) followed by ':'. */ - private static final Pattern URI_SCHEME_PATTERN = Pattern.compile("^[a-zA-Z][a-zA-Z0-9+\\-.]*:"); - private static final ObjectMapper JSON_MAPPER = new ObjectMapper(); private static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()); @@ -138,7 +134,7 @@ public String buildMergedSpec() { } // ------------------------------------------------------------------------- - // REF mode — original $ref-based shallow merge + // REF mode — original $ref-based shallow merge (identical to master) // ------------------------------------------------------------------------- private String buildRefMergedSpec(List specRelatedPaths) { @@ -170,17 +166,7 @@ private String buildRefMergedSpec(List specRelatedPaths) { } } allServers.addAll(ObjectUtils.defaultIfNull(result.getServers(), Collections.emptyList())); - - ObjectMapper rawMapper = specRelatedPath.toLowerCase(Locale.ROOT).endsWith(".json") - ? JSON_MAPPER : YAML_MAPPER; - Map rawSpec = rawMapper.readValue(new File(specPath), Map.class); - Object pathsObj = rawSpec.get("paths"); - @SuppressWarnings("unchecked") - Map rawPaths = (pathsObj instanceof Map) - ? (Map) pathsObj - : Collections.emptyMap(); - - allPaths.add(new SpecWithPaths(specRelatedPath, rawPaths)); + allPaths.add(new SpecWithPaths(specRelatedPath, result.getPaths().keySet())); } catch (Exception e) { LOGGER.error("Failed to read file: {}. It would be ignored", specPath); } @@ -205,95 +191,16 @@ private Map generateRefMergedSpec(String openapiVersion, List paths = new LinkedHashMap<>(); spec.put("paths", paths); - Map pathOccurrences = allPaths.stream() - .flatMap(s -> s.rawPaths.keySet().stream()) - .collect(Collectors.groupingBy(p -> p, Collectors.counting())); - for (SpecWithPaths specWithPaths : allPaths) { - for (Map.Entry pathEntry : specWithPaths.rawPaths.entrySet()) { - String path = pathEntry.getKey(); + for (String path : specWithPaths.paths) { String encodedPath = path.replace("/", "~1"); - String specRefBase = "./" + specWithPaths.specRelatedPath.replace('\\', '/') + "#/paths/" + encodedPath; - - if (pathOccurrences.getOrDefault(path, 0L) <= 1L) { - paths.put(path, ImmutableMap.of("$ref", specRefBase)); - } else { - Object rawValue = pathEntry.getValue(); - if (!(rawValue instanceof Map)) { - LOGGER.warn("Path {} in {} has an unexpected non-map value; skipping.", path, specWithPaths.specRelatedPath); - continue; - } - @SuppressWarnings("unchecked") - Map rawPathItem = (Map) rawValue; - Map adjustedPathItem = adjustRefs(rawPathItem, specWithPaths.specRelatedPath); - - @SuppressWarnings("unchecked") - Map existingPathItem = (Map) - paths.computeIfAbsent(path, k -> new LinkedHashMap<>()); - - for (Map.Entry methodEntry : adjustedPathItem.entrySet()) { - if (existingPathItem.containsKey(methodEntry.getKey())) { - LOGGER.warn("Path {} HTTP method {} is defined in multiple spec files. Last definition will be used.", - path, methodEntry.getKey()); - } - existingPathItem.put(methodEntry.getKey(), methodEntry.getValue()); - } - } + paths.put(path, ImmutableMap.of("$ref", "./" + specWithPaths.specRelatedPath.replace('\\', '/') + "#/paths/" + encodedPath)); } } return spec; } - @SuppressWarnings("unchecked") - private static Map adjustRefs(Map map, String specRelatedPath) { - Map result = new LinkedHashMap<>(); - for (Map.Entry entry : map.entrySet()) { - if ("$ref".equals(entry.getKey()) && entry.getValue() instanceof String) { - result.put("$ref", adjustRef((String) entry.getValue(), specRelatedPath)); - } else if (entry.getValue() instanceof Map) { - result.put(entry.getKey(), adjustRefs((Map) entry.getValue(), specRelatedPath)); - } else if (entry.getValue() instanceof List) { - result.put(entry.getKey(), adjustRefsList((List) entry.getValue(), specRelatedPath)); - } else { - result.put(entry.getKey(), entry.getValue()); - } - } - return result; - } - - @SuppressWarnings("unchecked") - private static List adjustRefsList(List list, String specRelatedPath) { - List result = new ArrayList<>(); - for (Object item : list) { - if (item instanceof Map) { - result.add(adjustRefs((Map) item, specRelatedPath)); - } else if (item instanceof List) { - result.add(adjustRefsList((List) item, specRelatedPath)); - } else { - result.add(item); - } - } - return result; - } - - private static String adjustRef(String ref, String specRelatedPath) { - String normalizedSpec = specRelatedPath.replace('\\', '/'); - if (ref.startsWith("#")) { - return "./" + normalizedSpec + ref; - } - if (ref.startsWith("/") || URI_SCHEME_PATTERN.matcher(ref).lookingAt()) { - return ref; - } - Path specPath = Paths.get(normalizedSpec); - Path specParent = specPath.getParent(); - if (specParent == null) { - return ref; - } - Path resolved = specParent.resolve(Paths.get(ref.replace('\\', '/'))).normalize(); - return "./" + resolved.toString().replace('\\', '/'); - } - private static Map generateHeader(String openapiVersion, String title, String description, String version, List allServers) { Map map = new LinkedHashMap<>(); map.put("openapi", openapiVersion); @@ -313,11 +220,11 @@ private static Map generateHeader(String openapiVersion, String private static class SpecWithPaths { private final String specRelatedPath; - private final Map rawPaths; + private final Set paths; - private SpecWithPaths(final String specRelatedPath, final Map rawPaths) { + private SpecWithPaths(final String specRelatedPath, final Set paths) { this.specRelatedPath = specRelatedPath; - this.rawPaths = rawPaths; + this.paths = paths; } } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/MergedSpecBuilderTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/MergedSpecBuilderTest.java index 1893cbfe7238..14ca3dc2892d 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/MergedSpecBuilderTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/MergedSpecBuilderTest.java @@ -135,18 +135,12 @@ private void shouldMergeSpecsWithCollidingPaths(String fileExt) throws IOExcepti assertNotNull(openAPI.getPaths(), "Merged spec must have paths"); - // /spec1 must have both GET (from spec1) and POST (from spec-collision) + // REF mode: same path in multiple specs — last file (alphabetically) wins PathItem spec1Path = openAPI.getPaths().get("/spec1"); assertNotNull(spec1Path, "/spec1 path must exist in merged spec"); - assertNotNull(spec1Path.getGet(), "/spec1 GET must be present (from spec1)"); - assertNotNull(spec1Path.getPost(), "/spec1 POST must be present (from spec-collision)"); // /collision path from spec-collision must also be present assertNotNull(openAPI.getPaths().get("/collision"), "/collision path must exist in merged spec"); - - // schemas from both specs must be present - assertNotNull(openAPI.getComponents().getSchemas().get("Spec1Model"), "Spec1Model schema must exist"); - assertNotNull(openAPI.getComponents().getSchemas().get("CollisionModel"), "CollisionModel schema must exist"); } // ---- Vendor extensions tests ---- From aefbaddabf3c92cf372272b18dd170a947b7c1db Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Tue, 30 Jun 2026 13:13:05 +0200 Subject: [PATCH 09/13] update documentation --- .../openapi-generator-gradle-plugin/README.adoc | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/modules/openapi-generator-gradle-plugin/README.adoc b/modules/openapi-generator-gradle-plugin/README.adoc index d45ab1f727b0..1b78e5405174 100644 --- a/modules/openapi-generator-gradle-plugin/README.adoc +++ b/modules/openapi-generator-gradle-plugin/README.adoc @@ -162,8 +162,18 @@ apply plugin: 'org.openapi.generator' |mergedFileName |String / Provider -|None -|Name of the file that will contain all merged specs +|`merged` +|Name of the output file that will contain all merged specs (used with `inputSpecRootDirectory`) + +|mergeMode +|String / Provider +|`REF` +|How multiple spec files are merged. `REF` (default) preserves the original behaviour: generates a lightweight index spec where each path is a `$ref` pointing back to its source file — identical to the master behaviour. `DEEP` fully parses and inlines all paths and components into a single self-contained spec, with component deduplication and conflict detection controlled by `mergeConflictStrategy`. + +|mergeConflictStrategy +|String / Provider +|`WARN` +|How to handle name conflicts when `mergeMode` is `DEEP` and two specs define the same component (schema, response, etc.) or the same path+method with different definitions. `WARN` (default) keeps the first definition and logs a warning. `FAIL` throws an exception and aborts the build. |remoteInputSpec |String / Provider From ff82951430c511eec09f8a0e722e2fb3470cc061 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Tue, 30 Jun 2026 13:14:57 +0200 Subject: [PATCH 10/13] escape asterisk --- modules/openapi-generator-gradle-plugin/README.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/openapi-generator-gradle-plugin/README.adoc b/modules/openapi-generator-gradle-plugin/README.adoc index 1b78e5405174..2af0046af242 100644 --- a/modules/openapi-generator-gradle-plugin/README.adoc +++ b/modules/openapi-generator-gradle-plugin/README.adoc @@ -258,7 +258,7 @@ apply plugin: 'org.openapi.generator' |forcedGenerateSchemas |List / Provider |None -|Forces generation of the named schemas even when they are listed in `schemaMappings` or `importMappings` (which would normally suppress their generation). The primary use case is producing a *reference copy* of a hand-written class — for example, a custom enum that replaces a generated one — so you can assert in a test that the hand-written version has not diverged from what the generator would produce. List of schema names, e.g. `["MyEnum", "OtherSchema"]`. Use `["*"]` as a wildcard to force-generate *all* schemas that would otherwise be suppressed by a mapping. +|Forces generation of the named schemas even when they are listed in `schemaMappings` or `importMappings` (which would normally suppress their generation). The primary use case is producing a *reference copy* of a hand-written class — for example, a custom enum that replaces a generated one — so you can assert in a test that the hand-written version has not diverged from what the generator would produce. List of schema names, e.g. `["MyEnum", "OtherSchema"]`. Use `["\*"]` as a wildcard to force-generate *all* schemas that would otherwise be suppressed by a mapping. |nameMappings |Map / Provider From 29e91ba30017df8e74c426ad8770d2e70804fd1a Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Tue, 30 Jun 2026 15:30:33 +0200 Subject: [PATCH 11/13] fix issue raised in CR --- .../openapitools/codegen/cmd/Generate.java | 21 +++++++++-- .../gradle/plugin/tasks/GenerateTask.kt | 26 ++++++++++---- .../codegen/plugin/CodeGenMojo.java | 26 +++++++++++--- .../codegen/plugin/ValidateMojo.java | 35 ++++++++++++++----- 4 files changed, 87 insertions(+), 21 deletions(-) diff --git a/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java b/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java index 6e3ab1fd01fd..1f905dc76ecd 100644 --- a/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java +++ b/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java @@ -355,12 +355,27 @@ public class Generate extends OpenApiGeneratorCommand { public void execute() { if (StringUtils.isNotBlank(inputSpecRootDirectory)) { MergedSpecBuilder builder = new MergedSpecBuilder(inputSpecRootDirectory, StringUtils.isBlank(mergedFileName) ? "_merged_spec" : mergedFileName); + + MergedSpecBuilder.MergeMode resolvedMergeMode = MergedSpecBuilder.MergeMode.REF; if (StringUtils.isNotBlank(mergeMode)) { - builder.withMergeMode(MergedSpecBuilder.MergeMode.valueOf(mergeMode.toUpperCase(java.util.Locale.ROOT))); + try { + resolvedMergeMode = MergedSpecBuilder.MergeMode.valueOf(mergeMode.toUpperCase(java.util.Locale.ROOT)); + } catch (IllegalArgumentException e) { + System.err.println("[error] Invalid --merge-mode value '" + mergeMode + "'. Valid values are: REF, DEEP"); + return; + } + builder.withMergeMode(resolvedMergeMode); } - if (StringUtils.isNotBlank(mergeConflictStrategy)) { - builder.withConflictStrategy(MergedSpecBuilder.MergeConflictStrategy.valueOf(mergeConflictStrategy.toUpperCase(java.util.Locale.ROOT))); + + if (resolvedMergeMode == MergedSpecBuilder.MergeMode.DEEP && StringUtils.isNotBlank(mergeConflictStrategy)) { + try { + builder.withConflictStrategy(MergedSpecBuilder.MergeConflictStrategy.valueOf(mergeConflictStrategy.toUpperCase(java.util.Locale.ROOT))); + } catch (IllegalArgumentException e) { + System.err.println("[error] Invalid --merge-conflict-strategy value '" + mergeConflictStrategy + "'. Valid values are: WARN, FAIL"); + return; + } } + spec = builder.buildMergedSpec(); System.out.println("Merge input spec would be used - " + spec); } diff --git a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt index 193822a3bc0c..4e63a803f02c 100644 --- a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt +++ b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt @@ -912,14 +912,28 @@ abstract class GenerateTask : DefaultTask() { inputSpecRootDirectory.orNull?.let { inputDir -> if (!inputSpecRootDirectorySkipMerge.get()) { - finalResolvedInputSpec = MergedSpecBuilder( + val resolvedMergeMode = try { + MergedSpecBuilder.MergeMode.valueOf(mergeMode.get().uppercase()) + } catch (e: IllegalArgumentException) { + throw GradleException("Invalid mergeMode value '${mergeMode.get()}'. Valid values are: REF, DEEP") + } + + val builder = MergedSpecBuilder( inputDir.asFile.absolutePath, mergedFileName.get() - ).withMergeMode( - MergedSpecBuilder.MergeMode.valueOf(mergeMode.get().uppercase()) - ).withConflictStrategy( - MergedSpecBuilder.MergeConflictStrategy.valueOf(mergeConflictStrategy.get().uppercase()) - ).buildMergedSpec() + ).withMergeMode(resolvedMergeMode) + + if (resolvedMergeMode == MergedSpecBuilder.MergeMode.DEEP) { + try { + builder.withConflictStrategy( + MergedSpecBuilder.MergeConflictStrategy.valueOf(mergeConflictStrategy.get().uppercase()) + ) + } catch (e: IllegalArgumentException) { + throw GradleException("Invalid mergeConflictStrategy value '${mergeConflictStrategy.get()}'. Valid values are: WARN, FAIL") + } + } + + finalResolvedInputSpec = builder.buildMergedSpec() logger.info("Merge input spec used: {}", finalResolvedInputSpec) } } diff --git a/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java b/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java index 5d84ed52281a..6b1fe2d241fd 100644 --- a/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java +++ b/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java @@ -598,11 +598,29 @@ public void execute() throws MojoExecutionException { // make sure the path can be processed correct under Windows OS inputSpecRootDirectory = inputSpecRootDirectory.replaceAll("\\\\", "/"); - inputSpec = new MergedSpecBuilder(inputSpecRootDirectory, mergedFileName, + MergedSpecBuilder.MergeMode resolvedMergeMode; + try { + resolvedMergeMode = MergedSpecBuilder.MergeMode.valueOf(mergeMode.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new MojoExecutionException("Invalid mergeMode value '" + mergeMode + + "'. Valid values are: REF, DEEP"); + } + + MergedSpecBuilder builder = new MergedSpecBuilder(inputSpecRootDirectory, mergedFileName, mergedFileInfoName, mergedFileInfoDescription, mergedFileInfoVersion, auth) - .withMergeMode(MergedSpecBuilder.MergeMode.valueOf(mergeMode.toUpperCase(java.util.Locale.ROOT))) - .withConflictStrategy(MergedSpecBuilder.MergeConflictStrategy.valueOf(mergeConflictStrategy.toUpperCase(java.util.Locale.ROOT))) - .buildMergedSpec(); + .withMergeMode(resolvedMergeMode); + + if (resolvedMergeMode == MergedSpecBuilder.MergeMode.DEEP) { + try { + builder.withConflictStrategy( + MergedSpecBuilder.MergeConflictStrategy.valueOf(mergeConflictStrategy.toUpperCase(Locale.ROOT))); + } catch (IllegalArgumentException e) { + throw new MojoExecutionException("Invalid mergeConflictStrategy value '" + mergeConflictStrategy + + "'. Valid values are: WARN, FAIL"); + } + } + + inputSpec = builder.buildMergedSpec(); LOGGER.info("Merge input spec would be used - {}", inputSpec); } diff --git a/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/ValidateMojo.java b/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/ValidateMojo.java index 28d03e63056f..72bbb7d48dd0 100644 --- a/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/ValidateMojo.java +++ b/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/ValidateMojo.java @@ -200,10 +200,11 @@ public void execute() throws MojoExecutionException { inputSpec = inputSpec[0].split("\\s*,\\s*"); } - mergeInDirectory().ifPresent(mergedSpec -> { + Optional mergedSpecOpt = mergeInDirectory(); + if (mergedSpecOpt.isPresent()) { inputSpec = new String[1]; - inputSpec[0] = mergedSpec; - }); + inputSpec[0] = mergedSpecOpt.get(); + } try { for (String oneInputSpec : inputSpec) { @@ -261,16 +262,34 @@ private boolean shouldWeSkip() { return false; } - private Optional mergeInDirectory() { + private Optional mergeInDirectory() throws MojoExecutionException { Optional mergedSpec = Optional.empty(); if (StringUtils.isNotBlank(inputSpecRootDirectory)) { inputSpecRootDirectory = replaceBackslashesToSlashes(inputSpecRootDirectory); - mergedSpec = Optional.of(new MergedSpecBuilder(inputSpecRootDirectory, mergedFileName, + MergedSpecBuilder.MergeMode resolvedMergeMode; + try { + resolvedMergeMode = MergedSpecBuilder.MergeMode.valueOf(mergeMode.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new MojoExecutionException("Invalid mergeMode value '" + mergeMode + + "'. Valid values are: REF, DEEP"); + } + + MergedSpecBuilder builder = new MergedSpecBuilder(inputSpecRootDirectory, mergedFileName, mergedFileInfoName, mergedFileInfoDescription, mergedFileInfoVersion, auth) - .withMergeMode(MergedSpecBuilder.MergeMode.valueOf(mergeMode.toUpperCase(java.util.Locale.ROOT))) - .withConflictStrategy(MergedSpecBuilder.MergeConflictStrategy.valueOf(mergeConflictStrategy.toUpperCase(java.util.Locale.ROOT))) - .buildMergedSpec()); + .withMergeMode(resolvedMergeMode); + + if (resolvedMergeMode == MergedSpecBuilder.MergeMode.DEEP) { + try { + builder.withConflictStrategy( + MergedSpecBuilder.MergeConflictStrategy.valueOf(mergeConflictStrategy.toUpperCase(Locale.ROOT))); + } catch (IllegalArgumentException e) { + throw new MojoExecutionException("Invalid mergeConflictStrategy value '" + mergeConflictStrategy + + "'. Valid values are: WARN, FAIL"); + } + } + + mergedSpec = Optional.of(builder.buildMergedSpec()); LOGGER.info("Merge input spec would be used - {}", mergedSpec.get()); } return mergedSpec; From 44bdaefb7b9881a8a08c692c892ae82f5906c868 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Tue, 30 Jun 2026 16:14:07 +0200 Subject: [PATCH 12/13] feat: enhance merge conflict handling and improve spec parsing logic --- .../openapitools/codegen/cmd/Generate.java | 4 +- .../codegen/config/MergedSpecBuilder.java | 214 ++++++++++++------ .../codegen/config/MergedSpecBuilderTest.java | 25 +- 3 files changed, 155 insertions(+), 88 deletions(-) diff --git a/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java b/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java index 1f905dc76ecd..d9be7d17062b 100644 --- a/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java +++ b/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java @@ -362,7 +362,7 @@ public void execute() { resolvedMergeMode = MergedSpecBuilder.MergeMode.valueOf(mergeMode.toUpperCase(java.util.Locale.ROOT)); } catch (IllegalArgumentException e) { System.err.println("[error] Invalid --merge-mode value '" + mergeMode + "'. Valid values are: REF, DEEP"); - return; + System.exit(1); } builder.withMergeMode(resolvedMergeMode); } @@ -372,7 +372,7 @@ public void execute() { builder.withConflictStrategy(MergedSpecBuilder.MergeConflictStrategy.valueOf(mergeConflictStrategy.toUpperCase(java.util.Locale.ROOT))); } catch (IllegalArgumentException e) { System.err.println("[error] Invalid --merge-conflict-strategy value '" + mergeConflictStrategy + "'. Valid values are: WARN, FAIL"); - return; + System.exit(1); } } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java index f853c95dbdc0..ab064f07e9d6 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java @@ -12,6 +12,7 @@ import io.swagger.v3.oas.models.PathItem; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.servers.Server; +import io.swagger.v3.oas.models.parameters.Parameter; import io.swagger.v3.parser.core.models.ParseOptions; import org.apache.commons.lang3.ObjectUtils; import org.openapitools.codegen.auth.AuthParser; @@ -53,8 +54,11 @@ public enum MergeMode { /** * Controls what happens when two specs define the same component name (schema, response, etc.) - * or the same path+method with different definitions. Only applies when {@link MergeMode#DEEP} - * is active. + * with different definitions during a {@link MergeMode#DEEP} merge. + * + *

This strategy applies to component/model conflicts only. Path+method overlaps (the same + * HTTP method on the same path in multiple specs) are always silently resolved by keeping the + * first definition, regardless of this setting.

*/ public enum MergeConflictStrategy { /** Log a warning and keep the first definition (default). */ @@ -105,9 +109,11 @@ public MergedSpecBuilder withMergeMode(MergeMode mode) { } /** - * Sets the strategy used when two specs define the same component name or path+method - * with conflicting (non-identical) definitions. Only applies when {@link MergeMode#DEEP} - * is active. + * Sets the strategy used when two specs define the same component name (schema, response, etc.) + * with conflicting definitions. Only applies when {@link MergeMode#DEEP} is active. + * + *

Path+method overlaps are unaffected by this setting — they are always silently resolved + * by keeping the first definition.

* * @param strategy {@link MergeConflictStrategy#WARN} to log a warning and keep the first * definition (default), or {@link MergeConflictStrategy#FAIL} to throw a @@ -134,50 +140,88 @@ public String buildMergedSpec() { } // ------------------------------------------------------------------------- - // REF mode — original $ref-based shallow merge (identical to master) + // Shared parsing helper // ------------------------------------------------------------------------- - private String buildRefMergedSpec(List specRelatedPaths) { - String openapiVersion = null; - boolean isJson = false; + /** Holds the results of parsing all spec files in a directory. */ + private static class ParsedSpecFiles { + final List specs; + final List relativePaths; // successfully parsed, in the same order as specs + final boolean isJson; + final String openapiVersion; + final List allServers; + + ParsedSpecFiles(List specs, List relativePaths, boolean isJson, + String openapiVersion, List allServers) { + this.specs = specs; + this.relativePaths = relativePaths; + this.isJson = isJson; + this.openapiVersion = openapiVersion; + this.allServers = allServers; + } + } + + private ParsedSpecFiles parseSpecFiles(List specRelatedPaths) { ParseOptions options = new ParseOptions(); options.setResolve(true); - List allPaths = new ArrayList<>(); + List specs = new ArrayList<>(); + List relativePaths = new ArrayList<>(); List allServers = new ArrayList<>(); + boolean isJson = false; + String openapiVersion = null; for (String specRelatedPath : specRelatedPaths) { String specPath = inputSpecRootDirectory + File.separator + specRelatedPath; try { LOGGER.info("Reading spec: {}", specPath); - OpenAPI result = new OpenAPIParser() .readLocation(specPath, AuthParser.parse(auth), options) .getOpenAPI(); - if (result == null) { LOGGER.error("Failed to read file: {}. It would be ignored", specPath); continue; } - + if (specs.isEmpty() && specRelatedPath.toLowerCase(Locale.ROOT).endsWith(".json")) { + isJson = true; + } if (openapiVersion == null) { openapiVersion = result.getOpenapi(); - if (specRelatedPath.toLowerCase(Locale.ROOT).endsWith(".json")) { - isJson = true; - } } allServers.addAll(ObjectUtils.defaultIfNull(result.getServers(), Collections.emptyList())); - allPaths.add(new SpecWithPaths(specRelatedPath, result.getPaths().keySet())); + specs.add(result); + relativePaths.add(specRelatedPath); } catch (Exception e) { LOGGER.error("Failed to read file: {}. It would be ignored", specPath); } } - Map mergedSpec = generateRefMergedSpec(openapiVersion, allPaths, allServers); - String mergedFilename = this.mergeFileName + (isJson ? ".json" : ".yaml"); + if (specs.isEmpty()) { + throw new RuntimeException("Spec directory doesn't contain any valid specification"); + } + + return new ParsedSpecFiles(specs, relativePaths, isJson, openapiVersion, allServers); + } + + // ------------------------------------------------------------------------- + // REF mode — original $ref-based shallow merge (identical to master) + // ------------------------------------------------------------------------- + + private String buildRefMergedSpec(List specRelatedPaths) { + ParsedSpecFiles parsed = parseSpecFiles(specRelatedPaths); + + List allPaths = new ArrayList<>(); + for (int i = 0; i < parsed.specs.size(); i++) { + io.swagger.v3.oas.models.Paths specPaths = parsed.specs.get(i).getPaths(); + Set pathKeys = specPaths != null ? specPaths.keySet() : Collections.emptySet(); + allPaths.add(new SpecWithPaths(parsed.relativePaths.get(i), pathKeys)); + } + + Map mergedSpec = generateRefMergedSpec(parsed.openapiVersion, allPaths, parsed.allServers); + String mergedFilename = this.mergeFileName + (parsed.isJson ? ".json" : ".yaml"); Path mergedFilePath = Paths.get(inputSpecRootDirectory, mergedFilename); try { - ObjectMapper objectMapper = isJson ? JSON_MAPPER : YAML_MAPPER; + ObjectMapper objectMapper = parsed.isJson ? JSON_MAPPER : YAML_MAPPER; Files.write(mergedFilePath, objectMapper.writeValueAsBytes(mergedSpec), StandardOpenOption.CREATE, StandardOpenOption.WRITE); } catch (IOException e) { throw new RuntimeException(e); @@ -233,47 +277,15 @@ private SpecWithPaths(final String specRelatedPath, final Set paths) { // ------------------------------------------------------------------------- private String buildDeepMergedSpec(List specRelatedPaths) { - boolean isJson = false; - ParseOptions options = new ParseOptions(); - options.setResolve(true); - List parsedSpecs = new ArrayList<>(); - List allServers = new ArrayList<>(); + ParsedSpecFiles parsed = parseSpecFiles(specRelatedPaths); - for (String specRelatedPath : specRelatedPaths) { - String specPath = inputSpecRootDirectory + File.separator + specRelatedPath; - try { - LOGGER.info("Reading spec: {}", specPath); + OpenAPI merged = mergeSpecs(parsed.specs, parsed.allServers); - OpenAPI result = new OpenAPIParser() - .readLocation(specPath, AuthParser.parse(auth), options) - .getOpenAPI(); - - if (result == null) { - LOGGER.error("Failed to read file: {}. It would be ignored", specPath); - continue; - } - - if (parsedSpecs.isEmpty() && specRelatedPath.toLowerCase(Locale.ROOT).endsWith(".json")) { - isJson = true; - } - allServers.addAll(Optional.ofNullable(result.getServers()).orElse(Collections.emptyList())); - parsedSpecs.add(result); - } catch (Exception e) { - LOGGER.error("Failed to read file: {}. It would be ignored", specPath); - } - } - - if (parsedSpecs.isEmpty()) { - throw new RuntimeException("Spec directory doesn't contain any valid specification"); - } - - OpenAPI merged = mergeSpecs(parsedSpecs, allServers); - - String mergedFilename = this.mergeFileName + (isJson ? ".json" : ".yaml"); + String mergedFilename = this.mergeFileName + (parsed.isJson ? ".json" : ".yaml"); Path mergedFilePath = Paths.get(inputSpecRootDirectory, mergedFilename); try { - String content = isJson + String content = parsed.isJson ? Json.mapper().writerWithDefaultPrettyPrinter().writeValueAsString(merged) : Yaml.mapper().writerWithDefaultPrettyPrinter().writeValueAsString(merged); Files.write(mergedFilePath, content.getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.WRITE); @@ -290,14 +302,17 @@ private String buildDeepMergedSpec(List specRelatedPaths) { * Merges a list of parsed OpenAPI specs into a single spec. * *

Path items are merged by HTTP method: if two specs define the same URL path, their - * operations are combined (e.g. GET from one file + POST from another) rather than one - * overwriting the other. A warning is logged when the same path+method appears in multiple specs; - * the first occurrence is kept.

+ * operations are combined (e.g. GET from one file + POST from another). If the same HTTP + * method already exists on a path, a {@link RuntimeException} is thrown — this is always + * a configuration error with no valid use case.

* *

Component maps (schemas, responses, requestBodies, parameters, headers, examples, * links, callbacks, securitySchemes) are merged by name. Structurally identical duplicates - * are silently deduplicated. A warning is logged if the same component name appears with - * different definitions; the first definition is kept.

+ * are silently deduplicated. A warning (or exception in FAIL mode) is raised when the same + * component name appears with different definitions; the first definition is kept.

+ * + *

Top-level {@code x-} vendor extensions are merged from all specs; the first definition + * wins on key conflicts.

*/ OpenAPI mergeSpecs(List specs, List allServers) { OpenAPI merged = new OpenAPI(); @@ -337,6 +352,14 @@ OpenAPI mergeSpecs(List specs, List allServers) { if (spec.getComponents() != null) { mergeComponents(merged.getComponents(), spec.getComponents()); } + // Merge top-level x- vendor extensions (keep first on key conflict) + if (spec.getExtensions() != null) { + spec.getExtensions().forEach((key, value) -> { + if (merged.getExtensions() == null || !merged.getExtensions().containsKey(key)) { + merged.addExtension(key, value); + } + }); + } } return merged; @@ -344,9 +367,19 @@ OpenAPI mergeSpecs(List specs, List allServers) { /** * Merges HTTP method operations from {@code incoming} into {@code existing} for the same path URL. - * Path-level metadata (summary, description, servers, parameters, extensions) is kept from - * {@code existing} (i.e. the first spec that defined this path). A warning is logged for any - * path+method that already exists in {@code existing}. + * + *

Operations for methods not yet present in {@code existing} are added. If the same HTTP + * method already exists on a path, a {@link RuntimeException} is always thrown — unlike schema + * reuse, there is no valid reason for two specs to define the same HTTP method on the same path, + * and silently dropping one would produce incorrect output.

+ * + *

Path-level metadata from {@code incoming} is merged into {@code existing}:

+ *
    + *
  • Parameters: added if not already present by name+in (first wins on conflict)
  • + *
  • Servers: added if not already present by URL
  • + *
  • Extensions: added if not already present by key
  • + *
  • Summary and description: always kept from the first ({@code existing}) PathItem
  • + *
*/ private void mergePathItem(PathItem existing, PathItem incoming, String pathKey) { if (incoming.readOperationsMap() == null) { @@ -354,17 +387,52 @@ private void mergePathItem(PathItem existing, PathItem incoming, String pathKey) } incoming.readOperationsMap().forEach((method, operation) -> { if (existing.readOperationsMap() != null && existing.readOperationsMap().containsKey(method)) { - String message = String.format(Locale.ROOT, - "Path+method collision during spec merge: %s %s is defined in multiple specs with different definitions. Keeping the first definition.", - method, pathKey); - if (conflictStrategy == MergeConflictStrategy.FAIL) { - throw new RuntimeException(message); - } - LOGGER.warn(message); - } else { - existing.operation(method, operation); + throw new RuntimeException(String.format(Locale.ROOT, + "Path+method conflict during spec merge: %s %s is defined in multiple specs. " + + "Unlike schema reuse, duplicate HTTP methods on the same path are not valid — " + + "check that your spec files do not overlap.", + method, pathKey)); } + existing.operation(method, operation); }); + + // Merge path-level parameters (deduplicate by name+in, first wins) + if (incoming.getParameters() != null) { + List merged = existing.getParameters() != null + ? new ArrayList<>(existing.getParameters()) : new ArrayList<>(); + Set existingKeys = merged.stream() + .map(p -> p.getName() + ":" + p.getIn()) + .collect(Collectors.toSet()); + for (Parameter p : incoming.getParameters()) { + if (existingKeys.add(p.getName() + ":" + p.getIn())) { + merged.add(p); + } + } + existing.setParameters(merged); + } + + // Merge path-level servers (deduplicate by URL) + if (incoming.getServers() != null) { + List merged = existing.getServers() != null + ? new ArrayList<>(existing.getServers()) : new ArrayList<>(); + Set existingUrls = merged.stream() + .map(Server::getUrl).filter(Objects::nonNull).collect(Collectors.toSet()); + for (Server s : incoming.getServers()) { + if (s.getUrl() == null || existingUrls.add(s.getUrl())) { + merged.add(s); + } + } + existing.setServers(merged); + } + + // Merge path-level extensions (first wins on key conflict) + if (incoming.getExtensions() != null) { + incoming.getExtensions().forEach((key, value) -> { + if (existing.getExtensions() == null || !existing.getExtensions().containsKey(key)) { + existing.addExtension(key, value); + } + }); + } } /** diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/MergedSpecBuilderTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/MergedSpecBuilderTest.java index 14ca3dc2892d..7558f640cbb2 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/MergedSpecBuilderTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/config/MergedSpecBuilderTest.java @@ -372,36 +372,35 @@ private void shouldFailOnSchemaNameConflictWithFailStrategy(String fileExt) thro } @Test - public void shouldFailOnPathMethodConflictWithFailStrategy_yaml() throws IOException { - shouldFailOnPathMethodConflictWithFailStrategy("yaml"); + public void shouldFailOnPathMethodOverlap_yaml() throws IOException { + shouldFailOnPathMethodOverlap("yaml"); } @Test - public void shouldFailOnPathMethodConflictWithFailStrategy_json() throws IOException { - shouldFailOnPathMethodConflictWithFailStrategy("json"); + public void shouldFailOnPathMethodOverlap_json() throws IOException { + shouldFailOnPathMethodOverlap("json"); } /** - * spec-path-method-conflict defines the same path+method (GET /spec1) as spec1. - * With FAIL strategy this must throw. + * When the same path+method appears in two specs, the merge must always fail — there is no + * valid use case for duplicate HTTP methods on the same path across spec files. */ - private void shouldFailOnPathMethodConflictWithFailStrategy(String fileExt) throws IOException { - File dir = Files.createTempDirectory("spec-path-conflict-fail").toFile().getCanonicalFile(); + private void shouldFailOnPathMethodOverlap(String fileExt) throws IOException { + File dir = Files.createTempDirectory("spec-path-overlap").toFile().getCanonicalFile(); dir.deleteOnExit(); + // Copy spec1 twice — both define the same paths and methods Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec1." + fileExt), dir.toPath().resolve("spec1." + fileExt)); - // spec-collision defines a POST on the same path — no method conflict, use spec-schema-conflict for method conflict - // Instead re-use spec1 copied as a second file to force a path+method duplicate Files.copy(Paths.get("src/test/resources/bugs/mergerTest/spec1." + fileExt), dir.toPath().resolve("spec1-duplicate." + fileExt)); try { new MergedSpecBuilder(dir.getAbsolutePath().replace('\\', '/'), "_merged") .withMergeMode(MergedSpecBuilder.MergeMode.DEEP) - .withConflictStrategy(MergedSpecBuilder.MergeConflictStrategy.FAIL) .buildMergedSpec(); - fail("Expected RuntimeException due to path+method conflict with FAIL strategy"); + fail("Expected RuntimeException due to duplicate path+method across specs"); } catch (RuntimeException e) { - assertTrue(e.getMessage().contains("/spec1"), "Exception message must mention the conflicting path"); + assertTrue(e.getMessage().contains("Path+method conflict"), + "Exception message must mention the path+method conflict"); } } } From 7a59aab472f682a0b2582a5be4fdee6d4a0618cf Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Tue, 30 Jun 2026 16:18:18 +0200 Subject: [PATCH 13/13] improve documentation --- .../openapitools/codegen/config/MergedSpecBuilder.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java index ab064f07e9d6..7d73ce529505 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/MergedSpecBuilder.java @@ -369,9 +369,12 @@ OpenAPI mergeSpecs(List specs, List allServers) { * Merges HTTP method operations from {@code incoming} into {@code existing} for the same path URL. * *

Operations for methods not yet present in {@code existing} are added. If the same HTTP - * method already exists on a path, a {@link RuntimeException} is always thrown — unlike schema - * reuse, there is no valid reason for two specs to define the same HTTP method on the same path, - * and silently dropping one would produce incorrect output.

+ * method already exists on a path, a {@link RuntimeException} is always thrown — even if the + * two definitions are identical. Unlike schema reuse (where identical duplicates are silently + * deduplicated), there is no valid reason for two spec files to both define the same HTTP method + * on the same path. An identical duplicate is still almost certainly a configuration error + * (e.g. the same file included twice, or an accidental copy), and failing loudly is safer than + * silently discarding one.

* *

Path-level metadata from {@code incoming} is merged into {@code existing}:

*