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); + } +}