Skip to content

Commit 8e7ccf8

Browse files
author
Michael Sonnleitner
committed
Load openapi specifications from jar maven dependency (GAV) of project #1357
- Added support for zipped (jar/zip) OpenAPI specifications. - Introduced `ZippedSpecInputModel` and `JarOrZipGAVCoordinateOpenApiSpecInputProvider`. - Updated `SpecItemConfig` and `CodegenConfig` to allow specification of multiple GAV-based files. - Reworked tests to include parameterized testing for multiple GAV sources. - Simplified YAML-based GAV provider to inherit from `AbstractGAVCoordinateOpenApiSpecInputProvider`.
1 parent 3d7a51d commit 8e7ccf8

File tree

18 files changed

+377
-158
lines changed

18 files changed

+377
-158
lines changed

client/deployment/src/main/java/io/quarkiverse/openapi/generator/deployment/CodegenConfig.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ enum ConfigName {
4949
API_NAME_SUFFIX("api-name-suffix"),
5050
MODEL_NAME_SUFFIX("model-name-suffix"),
5151
MODEL_NAME_PREFIX("model-name-prefix"),
52+
GAV_SPEC_FILES("gav-spec-files"),
5253

5354
//global & spec configs
5455
SKIP_FORM_MODEL("skip-form-model"),

client/deployment/src/main/java/io/quarkiverse/openapi/generator/deployment/SpecItemConfig.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.quarkiverse.openapi.generator.deployment;
22

3+
import java.util.List;
34
import java.util.Optional;
45

56
import io.smallrye.config.WithDefault;
@@ -79,4 +80,11 @@ public interface SpecItemConfig extends CommonItemConfig {
7980
@WithDefault("false")
8081
Optional<Boolean> useDynamicUrl();
8182

83+
/**
84+
* List of OpenAPI spec files in GAV to be generated
85+
*/
86+
@WithName("gav-spec-files")
87+
@WithDefault("openapi.yaml")
88+
Optional<List<String>> gavSpecFiles();
89+
8290
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package io.quarkiverse.openapi.generator.deployment.codegen;
2+
3+
import static io.quarkiverse.openapi.generator.deployment.CodegenConfig.*;
4+
import static io.quarkiverse.openapi.generator.deployment.CodegenConfig.ConfigName.*;
5+
6+
import java.nio.file.Path;
7+
import java.util.ArrayList;
8+
import java.util.List;
9+
import java.util.Set;
10+
import java.util.function.Predicate;
11+
12+
import org.jboss.logging.Logger;
13+
14+
import io.quarkus.bootstrap.prebuild.CodeGenException;
15+
import io.quarkus.deployment.CodeGenContext;
16+
import io.quarkus.maven.dependency.ResolvedDependency;
17+
import io.smallrye.config.common.utils.StringUtil;
18+
19+
abstract class AbstractGAVCoordinateOpenApiSpecInputProvider implements OpenApiSpecInputProvider {
20+
private static final Logger LOG = Logger.getLogger(AbstractGAVCoordinateOpenApiSpecInputProvider.class);
21+
22+
@Override
23+
public List<SpecInputModel> read(CodeGenContext context) throws CodeGenException {
24+
if (!context.config().getOptionalValue(getGlobalConfigName(GAV_SCANNING), Boolean.class)
25+
.orElse(true)) {
26+
LOG.debug("GAV scanning is disabled.");
27+
return List.of();
28+
}
29+
30+
List<String> gavsToExclude = context.config().getOptionalValues(getGlobalConfigName(EXCLUDE_GAVS), String.class)
31+
.orElse(List.of());
32+
String artifactIdFilter = context.config().getOptionalValue(getGlobalConfigName(ARTIFACT_ID_FILTER), String.class)
33+
.filter(Predicate.not(String::isBlank))
34+
.orElse(".*openapi.*");
35+
36+
List<ResolvedDependency> dependencies = context.applicationModel().getDependencies().stream()
37+
.filter(rd -> getSupportedExtensions().contains(rd.getType().toLowerCase()))
38+
.filter(rd -> rd.getArtifactId().matches(artifactIdFilter))
39+
.filter(rd -> !gavsToExclude.contains(rd.getKey().toGacString()))
40+
.toList();
41+
42+
if (dependencies.isEmpty()) {
43+
LOG.debug("No suitable GAV dependencies found. ArtifactIdFilter was %s and gavsToExclude were %s."
44+
.formatted(artifactIdFilter, gavsToExclude));
45+
return List.of();
46+
}
47+
48+
var inputModels = new ArrayList<SpecInputModel>();
49+
for (ResolvedDependency dependency : dependencies) {
50+
var gacString = StringUtil.replaceNonAlphanumericByUnderscores(dependency.getKey().toGacString());
51+
var path = dependency.getResolvedPaths().stream().findFirst()
52+
.orElseThrow(() -> new CodeGenException("Could not find maven path of %s.".formatted(gacString)));
53+
addInputModels(context, gacString, path, inputModels);
54+
}
55+
return inputModels;
56+
}
57+
58+
protected abstract Set<String> getSupportedExtensions();
59+
60+
protected abstract void addInputModels(CodeGenContext context,
61+
String gacString,
62+
Path path,
63+
List<SpecInputModel> inputModels) throws CodeGenException;
64+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package io.quarkiverse.openapi.generator.deployment.codegen;
2+
3+
import static io.quarkiverse.openapi.generator.deployment.CodegenConfig.*;
4+
import static io.quarkiverse.openapi.generator.deployment.CodegenConfig.ConfigName.*;
5+
6+
import java.io.IOException;
7+
import java.nio.file.Files;
8+
import java.nio.file.Path;
9+
import java.nio.file.Paths;
10+
import java.util.List;
11+
import java.util.Set;
12+
13+
import io.quarkus.bootstrap.prebuild.CodeGenException;
14+
import io.quarkus.deployment.CodeGenContext;
15+
16+
/**
17+
* Provides OpenAPI specification input from Maven GAV (GroupId:ArtifactId:Version) dependencies
18+
* packaged as JAR or ZIP files.
19+
* <p>
20+
* This provider extends the {@link AbstractGAVCoordinateOpenApiSpecInputProvider} and is responsible for
21+
* scanning application dependencies to identify JAR or ZIP files that contain OpenAPI specifications
22+
* (e.g., `openapi.yaml`).
23+
* </p>
24+
*
25+
* <h2>Supported File Types</h2>
26+
* <p>
27+
* The provider specifically supports dependencies packaged as:
28+
* </p>
29+
* <ul>
30+
* <li>JAR files</li>
31+
* <li>ZIP files</li>
32+
* </ul>
33+
*
34+
* <h2>Scanning Behavior</h2>
35+
* <p>
36+
* The provider performs the following steps:
37+
* </p>
38+
* <ol>
39+
* <li>Checks if GAV scanning is enabled via configuration (enabled by default)</li>
40+
* <li>Filters dependencies by artifact type (jar/zip)</li>
41+
* <li>Applies artifact ID filtering using a regex pattern</li>
42+
* <li>Excludes specific GAVs based on configuration</li>
43+
* <li>Creates {@link ZippedSpecInputModel} instances for each matching dependency and openAPI specification file</li>
44+
* </ol>
45+
*
46+
* <h2>Configuration</h2>
47+
* <p>
48+
* The provider respects the following configuration properties:
49+
* </p>
50+
* <ul>
51+
* <li>{@code quarkus.openapi-generator.codegen.gav-scanning} - Enable/disable GAV scanning</li>
52+
* <li>{@code quarkus.openapi-generator.codegen.artifact-id-filter} - Regex pattern for artifact ID filtering</li>
53+
* <li>{@code quarkus.openapi-generator.codegen.exclude-gavs} - List of GAV coordinates to exclude
54+
* (format: groupId:artifactId:classifier)</li>
55+
* <li>{@code quarkus.openapi-generator.codegen.spec.com_sample_customer_service_openapi.gav-spec-files} - List of
56+
* openAPI specification files in com.sample:customer-service-openapi:jar</li>
57+
* </ul>
58+
*
59+
* <h2>Example Usage</h2>
60+
*
61+
* <pre>
62+
* # application.properties
63+
* quarkus.openapi-generator.codegen.gav-scanning=true
64+
* quarkus.openapi-generator.codegen.artifact-id-filter=.*api.*
65+
* quarkus.openapi-generator.codegen.exclude-gavs=com.example:old-api
66+
* quarkus.openapi-generator.codegen.spec.com_sample_customer_service_api.gav-spec-files=customer.yaml,another.yaml
67+
* </pre>
68+
*
69+
* @see AbstractGAVCoordinateOpenApiSpecInputProvider
70+
* @see ZippedSpecInputModel
71+
* @see CodeGenContext
72+
*/
73+
public class JarOrZipGAVCoordinateOpenApiSpecInputProvider extends AbstractGAVCoordinateOpenApiSpecInputProvider {
74+
private static final Set<String> SUPPORTED_EXTENSIONS = Set.of("jar", "zip");
75+
76+
@Override
77+
protected void addInputModels(CodeGenContext context,
78+
String gacString,
79+
Path path,
80+
List<SpecInputModel> inputModels) throws CodeGenException {
81+
List<String> rootFilesOfSpecOfDependency = context.config()
82+
.getOptionalValues(getSpecConfigName(GAV_SPEC_FILES, Paths.get(gacString)), String.class)
83+
.orElse(List.of("openapi.yaml"));
84+
for (String rootFileOfSpecForDependency : rootFilesOfSpecOfDependency) {
85+
try {
86+
inputModels.add(new ZippedSpecInputModel(
87+
gacString,
88+
rootFileOfSpecForDependency,
89+
Files.newInputStream(path)));
90+
} catch (IOException e) {
91+
throw new CodeGenException(
92+
"Could not open input stream of %s from %s.".formatted(gacString, path.toString()),
93+
e);
94+
}
95+
}
96+
}
97+
98+
protected Set<String> getSupportedExtensions() {
99+
return SUPPORTED_EXTENSIONS;
100+
}
101+
}

client/deployment/src/main/java/io/quarkiverse/openapi/generator/deployment/codegen/OpenApiGeneratorStreamCodeGen.java

Lines changed: 67 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.quarkiverse.openapi.generator.deployment.codegen;
22

33
import java.io.IOException;
4+
import java.io.InputStream;
45
import java.io.UncheckedIOException;
56
import java.nio.channels.Channels;
67
import java.nio.channels.FileChannel;
@@ -13,6 +14,8 @@
1314
import java.util.List;
1415
import java.util.ServiceLoader;
1516
import java.util.stream.Collectors;
17+
import java.util.zip.ZipEntry;
18+
import java.util.zip.ZipInputStream;
1619

1720
import org.eclipse.microprofile.config.Config;
1821
import org.eclipse.microprofile.config.spi.ConfigSource;
@@ -68,24 +71,38 @@ public boolean trigger(CodeGenContext context) throws CodeGenException {
6871
throw new CodeGenException("SpecInputModel from provider " + provider + " is null");
6972
}
7073
try {
71-
final Path openApiFilePath = Paths.get(outDir.toString(), inputModel.getFileName());
72-
Files.createDirectories(openApiFilePath.getParent());
73-
try (ReadableByteChannel inChannel = Channels.newChannel(inputModel.getInputStream());
74-
FileChannel outChannel = FileChannel.open(openApiFilePath, StandardOpenOption.WRITE,
75-
StandardOpenOption.CREATE)) {
76-
outChannel.transferFrom(inChannel, 0, Integer.MAX_VALUE);
77-
LOGGER.debug("Saved OpenAPI spec input model in {}", openApiFilePath);
78-
79-
OpenApiGeneratorOptions options = new OpenApiGeneratorOptions(
80-
this.mergeConfig(context, inputModel),
81-
openApiFilePath,
82-
outDir,
83-
context.workDir().resolve("classes").resolve("templates"),
84-
isRestEasyReactive);
85-
86-
this.generate(options);
87-
generated = true;
74+
final Path openApiFilePath;
75+
if (inputModel instanceof ZippedSpecInputModel zippedSpecInputModel) {
76+
final Path pathToExtract = Paths.get(outDir.toString(), inputModel.getFileName());
77+
if (!Files.exists(pathToExtract)) {
78+
// only extract GAV at first iteration. if exists reuse it
79+
Files.createDirectories(pathToExtract);
80+
extractZip(inputModel.getInputStream(), pathToExtract);
81+
}
82+
openApiFilePath = Paths.get(pathToExtract.toString(), zippedSpecInputModel.getRootFileOfSpec());
83+
if (!Files.exists(openApiFilePath)) {
84+
throw new CodeGenException(
85+
String.format("Could not locate openAPI specification file %s in extracted content", openApiFilePath));
86+
}
87+
} else {
88+
openApiFilePath = Paths.get(outDir.toString(), inputModel.getFileName());
89+
Files.createDirectories(openApiFilePath.getParent());
90+
try (ReadableByteChannel inChannel = Channels.newChannel(inputModel.getInputStream());
91+
FileChannel outChannel = FileChannel.open(openApiFilePath, StandardOpenOption.WRITE,
92+
StandardOpenOption.CREATE)) {
93+
outChannel.transferFrom(inChannel, 0, Integer.MAX_VALUE);
94+
LOGGER.debug("Saved OpenAPI spec input model in {}", openApiFilePath);
95+
}
8896
}
97+
OpenApiGeneratorOptions options = new OpenApiGeneratorOptions(
98+
this.mergeConfig(context, inputModel),
99+
openApiFilePath,
100+
outDir,
101+
context.workDir().resolve("classes").resolve("templates"),
102+
isRestEasyReactive);
103+
104+
this.generate(options);
105+
generated = true;
89106
} catch (IOException e) {
90107
throw new UncheckedIOException("Failed to save InputStream from provider " + provider + " into location ",
91108
e);
@@ -103,6 +120,39 @@ private Config mergeConfig(CodeGenContext context, SpecInputModel inputModel) {
103120
.withSources(sources).build();
104121
}
105122

123+
private void extractZip(InputStream inputStream, Path outputDir) throws IOException {
124+
// Open the JAR/ZIP file as a ZipInputStream
125+
try (ZipInputStream zipInputStream = new ZipInputStream(inputStream)) {
126+
ZipEntry entry;
127+
// Iterate through each entry in the ZIP
128+
while ((entry = zipInputStream.getNextEntry()) != null) {
129+
String entryName = entry.getName();
130+
Path entryPath = outputDir.resolve(entryName);
131+
if (entry.isDirectory() ||
132+
SUPPORTED_EXTENSIONS_WITH_LEADING_DOT.stream().noneMatch(entryName::endsWith)) {
133+
continue;
134+
}
135+
// If the ZIP file contains entries like `../../malicious_file`
136+
if (!entryPath.toAbsolutePath().normalize().startsWith(outputDir.toAbsolutePath().normalize())) {
137+
throw new IOException("Invalid ZIP entry: " + entryName);
138+
}
139+
// If it's a file, create parent directories first
140+
if (!Files.exists(entryPath.getParent())) {
141+
Files.createDirectories(entryPath.getParent());
142+
}
143+
// Write the file
144+
try (var outStream = Files.newOutputStream(entryPath,
145+
StandardOpenOption.CREATE,
146+
StandardOpenOption.WRITE,
147+
StandardOpenOption.TRUNCATE_EXISTING)) {
148+
zipInputStream.transferTo(outStream);
149+
}
150+
// Close the current ZIP entry
151+
zipInputStream.closeEntry();
152+
}
153+
}
154+
}
155+
106156
@Override
107157
public boolean shouldRun(Path sourceDir, Config config) {
108158
return !this.providers.isEmpty();

0 commit comments

Comments
 (0)