diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts
index 6dc29e4..1826384 100644
--- a/buildSrc/build.gradle.kts
+++ b/buildSrc/build.gradle.kts
@@ -25,6 +25,7 @@ dependencies {
implementation(libs.plugin.publish)
implementation(libs.plugin.swagger)
implementation(libs.plugin.openapi.generator)
+ implementation(libs.openapi.generator)
implementation(libs.swagger.parser)
implementation(libs.jetbrains.annotations)
implementation(libs.jackson.core)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 6c1f948..a008b10 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -9,7 +9,7 @@ jetbrainsAnnotation = "26.1.0"
jakarta-ws-rs = "4.0.0"
jupiter = "6.1.3"
mockito = "5.24.0"
-openapi-generator = "7.19.0"
+openapi-generator = "7.26.0"
swagger = "2.2.42"
swagger-parser = "2.1.48"
diff --git a/plugins/edc-build/src/main/java/org/eclipse/edc/plugins/edcbuild/plugins/MergeOpenApiSpecTask.java b/plugins/edc-build/src/main/java/org/eclipse/edc/plugins/edcbuild/plugins/MergeOpenApiSpecTask.java
index 416b286..1d21542 100644
--- a/plugins/edc-build/src/main/java/org/eclipse/edc/plugins/edcbuild/plugins/MergeOpenApiSpecTask.java
+++ b/plugins/edc-build/src/main/java/org/eclipse/edc/plugins/edcbuild/plugins/MergeOpenApiSpecTask.java
@@ -20,54 +20,54 @@
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.parser.core.models.ParseOptions;
import org.eclipse.edc.plugins.edcbuild.extensions.BuildExtension;
-import org.gradle.api.file.Directory;
-import org.gradle.api.model.ObjectFactory;
-import org.gradle.api.tasks.CacheableTask;
+import org.gradle.api.DefaultTask;
+import org.gradle.api.GradleException;
import org.gradle.api.tasks.TaskAction;
import org.gradle.api.tasks.options.Option;
-import org.jetbrains.annotations.NotNull;
-import org.openapitools.generator.gradle.plugin.tasks.GenerateTask;
+import org.gradle.work.DisableCachingByDefault;
+import org.openapitools.codegen.config.MergedSpecBuilder;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
-import javax.inject.Inject;
+import java.util.List;
+import java.util.stream.Stream;
import static org.eclipse.edc.plugins.edcbuild.conventions.ConventionFunctions.requireExtension;
+import static org.openapitools.codegen.config.MergedSpecBuilder.MergeConflictStrategy.WARN;
+import static org.openapitools.codegen.config.MergedSpecBuilder.MergeMode.DEEP;
/**
- * Customization of the {@link GenerateTask}, which allows to pass in the input and output directories via command line.
+ * Merges the partial OpenAPI specs found in a directory into a single spec, with the input and output passed in via
+ * command line.
+ *
+ * The specs are merged in file name order, operations declared on the same path by different specs are combined (e.g.
+ * GET from one module and PUT from another), and a conflicting definition (same component name, or same method on the
+ * same path) keeps the first one, with a warning.
*/
-@CacheableTask
-public class MergeOpenApiSpecTask extends GenerateTask {
+@DisableCachingByDefault(because = "the input directory is passed in via command line")
+public abstract class MergeOpenApiSpecTask extends DefaultTask {
public static final String NAME = "mergeOpenApiSpec";
- private final Directory buildOpenapiFolder = getProject().getLayout().getBuildDirectory().get().dir("openapi");
+ private static final List SPEC_EXTENSIONS = List.of(".yaml", ".yml", ".json");
+
+ private Path inputDir;
+ private Path workDir = getProject().getLayout().getBuildDirectory().get().dir("openapi").dir("generated").getAsFile().toPath();
+ private Path output = getProject().getLayout().getBuildDirectory().get().dir("openapi").file("merged.yaml").getAsFile().toPath();
private String infoTitle = getProject().getName();
private String infoDescription = "API documentation";
private String infoVersion = "0.0.0";
- private Path output = buildOpenapiFolder.file("merged.yaml").getAsFile().toPath();
-
- @Inject
- public MergeOpenApiSpecTask(@NotNull ObjectFactory objectFactory) {
- super(objectFactory);
- getInputSpecRootDirectorySkipMerge().set(false);
- getSkipValidateSpec().set(true);
- getGeneratorName().set("openapi-yaml");
- getSkipOperationExample().set(false);
- getOutputDir().set(buildOpenapiFolder.dir("generated").getAsFile().getAbsolutePath());
- }
@Option(option = "inputDir", description = "Input directory where to look for partial specs. Required")
public void setInputDir(String inputDir) {
- getInputSpecRootDirectory().set(inputDir);
+ this.inputDir = Path.of(inputDir);
}
- @Option(option = "outputDir", description = "Output directory where the merged spec file will be stored. Optional.")
+ @Option(option = "outputDir", description = "Working directory for the intermediate merged spec. Optional.")
public void setOutputDir(String outputDirectory) {
- getOutputDir().set(outputDirectory);
+ this.workDir = Path.of(outputDirectory);
}
@Option(option = "output", description = "Output merged file path")
@@ -92,13 +92,16 @@ public void setInfoVersion(String infoVersion) {
@TaskAction
public void task() throws IOException {
- super.doWork();
+ if (inputDir == null) {
+ throw new GradleException("The --inputDir option is required");
+ }
+
+ var merged = new MergedSpecBuilder(specFiles(), workDir.toString(), "merged", infoTitle, infoDescription, infoVersion, null)
+ .withMergeMode(DEEP)
+ .withConflictStrategy(WARN)
+ .buildMergedSpec();
- var outputPath = Path.of(getOutputDir().get());
- var spec = outputPath.resolve("openapi").resolve("openapi.yaml");
- var parser = new OpenAPIParser();
- var swaggerParseResult = parser.readLocation(spec.toString(), Collections.emptyList(), new ParseOptions());
- var openApi = swaggerParseResult.getOpenAPI();
+ var openApi = new OpenAPIParser().readLocation(merged, Collections.emptyList(), new ParseOptions()).getOpenAPI();
var info = new Info();
info.setTitle(infoTitle);
info.setDescription(infoDescription);
@@ -106,9 +109,20 @@ public void task() throws IOException {
info.setLicense(createLicense());
openApi.setInfo(info);
+ Files.createDirectories(output.toAbsolutePath().getParent());
Files.writeString(output, Yaml.pretty(openApi));
}
+ private List specFiles() throws IOException {
+ try (Stream files = Files.walk(inputDir)) {
+ return files.filter(Files::isRegularFile)
+ .filter(file -> SPEC_EXTENSIONS.stream().anyMatch(extension -> file.toString().endsWith(extension)))
+ .map(file -> file.toAbsolutePath().toString())
+ .sorted()
+ .toList();
+ }
+ }
+
private License createLicense() {
var pom = requireExtension(getProject(), BuildExtension.class).getPom();
var license = new License();
@@ -118,4 +132,3 @@ private License createLicense() {
}
}
-
diff --git a/plugins/edc-build/src/test/java/org/eclipse/edc/plugins/edcbuild/plugins/MergeOpenApiSpecTaskTest.java b/plugins/edc-build/src/test/java/org/eclipse/edc/plugins/edcbuild/plugins/MergeOpenApiSpecTaskTest.java
new file mode 100644
index 0000000..fd43ef4
--- /dev/null
+++ b/plugins/edc-build/src/test/java/org/eclipse/edc/plugins/edcbuild/plugins/MergeOpenApiSpecTaskTest.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright (c) 2026 Metaform Systems, Inc.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Apache License, Version 2.0 which is available at
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * Contributors:
+ * Metaform Systems, Inc. - initial API and implementation
+ *
+ */
+
+package org.eclipse.edc.plugins.edcbuild.plugins;
+
+import io.swagger.parser.OpenAPIParser;
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.parser.core.models.ParseOptions;
+import org.eclipse.edc.plugins.edcbuild.extensions.BuildExtension;
+import org.gradle.api.Project;
+import org.gradle.testfixtures.ProjectBuilder;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static java.util.Collections.emptyList;
+import static org.assertj.core.api.Assertions.assertThat;
+
+class MergeOpenApiSpecTaskTest {
+
+ @TempDir
+ private Path tempDir;
+ private Path specs;
+ private MergeOpenApiSpecTask task;
+
+ @BeforeEach
+ void setUp() throws IOException {
+ Project project = ProjectBuilder.builder().withProjectDir(tempDir.resolve("project").toFile()).build();
+ project.getExtensions().create("edcBuild", BuildExtension.class);
+ project.getPlugins().apply(OpenApiMergerPlugin.class);
+ task = (MergeOpenApiSpecTask) project.getTasks().getByName(MergeOpenApiSpecTask.NAME);
+ specs = Files.createDirectories(tempDir.resolve("specs"));
+ }
+
+ @Test
+ void shouldCombineOperationsDeclaredOnTheSamePathByDifferentSpecs() throws IOException {
+ Files.writeString(specs.resolve("a-selector.yaml"), spec("get", "getDataPlanes", "DataPlane"));
+ Files.writeString(specs.resolve("b-registration.yaml"), spec("put", "registerDataPlane", "Registration"));
+
+ var merged = merge();
+
+ var dataPlanes = merged.getPaths().get("/v4/dataplanes");
+ assertThat(dataPlanes.getGet().getOperationId()).isEqualTo("getDataPlanes");
+ assertThat(dataPlanes.getPut().getOperationId()).isEqualTo("registerDataPlane");
+ assertThat(merged.getComponents().getSchemas()).containsKeys("DataPlane", "Registration");
+ }
+
+ @Test
+ void shouldSetInfo() throws IOException {
+ Files.writeString(specs.resolve("selector.yaml"), spec("get", "getDataPlanes", "DataPlane"));
+ task.setInfoTitle("management-api");
+ task.setInfoDescription("REST API documentation for the management-api");
+ task.setInfoVersion("4.0.0");
+
+ var merged = merge();
+
+ assertThat(merged.getInfo().getTitle()).isEqualTo("management-api");
+ assertThat(merged.getInfo().getDescription()).isEqualTo("REST API documentation for the management-api");
+ assertThat(merged.getInfo().getVersion()).isEqualTo("4.0.0");
+ assertThat(merged.getInfo().getLicense().getName()).isEqualTo("The Apache License, Version 2.0");
+ }
+
+ private OpenAPI merge() throws IOException {
+ var output = tempDir.resolve("out").resolve("merged.yaml");
+ task.setInputDir(specs.toString());
+ task.setOutput(output.toString());
+
+ task.task();
+
+ return new OpenAPIParser().readLocation(output.toString(), emptyList(), new ParseOptions()).getOpenAPI();
+ }
+
+ private String spec(String method, String operationId, String schema) {
+ return """
+ openapi: 3.0.1
+ paths:
+ /v4/dataplanes:
+ %s:
+ operationId: %s
+ responses:
+ "200":
+ description: ok
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/%s'
+ components:
+ schemas:
+ %s:
+ type: object
+ """.formatted(method, operationId, schema, schema);
+ }
+}