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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions buildSrc/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* 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<String> 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")
Expand All @@ -92,23 +92,37 @@ 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);
info.setVersion(infoVersion);
info.setLicense(createLicense());
openApi.setInfo(info);

Files.createDirectories(output.toAbsolutePath().getParent());
Files.writeString(output, Yaml.pretty(openApi));
}

private List<String> specFiles() throws IOException {
try (Stream<Path> 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();
Expand All @@ -118,4 +132,3 @@ private License createLicense() {
}

}

Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading