Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ enum ConfigName {
INCLUDE("include"),
EXCLUDE("exclude"),
ARTIFACT_ID_FILTER("artifact-id-filter"),
INCLUDE_GAVS("include-gavs"),
EXCLUDE_GAVS("exclude-gavs"),
VALIDATE_SPEC("validateSpec"),
DEFAULT_SECURITY_SCHEME("default-security-scheme"),
Expand All @@ -49,6 +50,7 @@ enum ConfigName {
API_NAME_SUFFIX("api-name-suffix"),
MODEL_NAME_SUFFIX("model-name-suffix"),
MODEL_NAME_PREFIX("model-name-prefix"),
GAV_SPEC_FILES("gav-spec-files"),

//global & spec configs
SKIP_FORM_MODEL("skip-form-model"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,20 @@ public interface GlobalCodegenConfig extends CommonItemConfig {
@WithName("exclude-gavs")
Optional<List<String>> excludeGavs();

/**
* Option to specify GAVs for which generation should be executed only.
*
* Depending on the GAV Provider default behavior differs:
* <ul>
* <li>for {@link io.quarkiverse.openapi.generator.deployment.codegen.YamlOrJsonGAVCoordinateOpenApiSpecInputProvider}, all
* suitable GAVs will be considered for generation if config value is not given</li>
* <li>for {@link io.quarkiverse.openapi.generator.deployment.codegen.JarOrZipGAVCoordinateOpenApiSpecInputProvider}, only
* specified GAVs will be considered for generation if config value is available</li>
* </ul>
*/
@WithName("include-gavs")
Optional<List<String>> includeGavs();

/**
* Create security for the referenced security scheme
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.quarkiverse.openapi.generator.deployment;

import java.util.List;
import java.util.Optional;

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

/**
* List of OpenAPI spec files in GAV to be generated
*/
@WithName("gav-spec-files")
@WithDefault("openapi.yaml")
Optional<List<String>> gavSpecFiles();

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package io.quarkiverse.openapi.generator.deployment.codegen;

import static io.quarkiverse.openapi.generator.deployment.CodegenConfig.*;
import static io.quarkiverse.openapi.generator.deployment.CodegenConfig.ConfigName.*;

import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.function.Predicate;

import org.jboss.logging.Logger;

import io.quarkus.bootstrap.prebuild.CodeGenException;
import io.quarkus.deployment.CodeGenContext;
import io.quarkus.maven.dependency.ResolvedDependency;
import io.smallrye.config.common.utils.StringUtil;

abstract class AbstractGAVCoordinateOpenApiSpecInputProvider implements OpenApiSpecInputProvider {
private static final Logger LOG = Logger.getLogger(AbstractGAVCoordinateOpenApiSpecInputProvider.class);

@Override
public List<SpecInputModel> read(CodeGenContext context) throws CodeGenException {
if (!context.config().getOptionalValue(getGlobalConfigName(GAV_SCANNING), Boolean.class)
.orElse(true)) {
LOG.debug("GAV scanning is disabled.");
return List.of();
}

List<String> gavsToExclude = context.config().getOptionalValues(getGlobalConfigName(EXCLUDE_GAVS), String.class)
.orElse(List.of());
String artifactIdFilter = context.config().getOptionalValue(getGlobalConfigName(ARTIFACT_ID_FILTER), String.class)
.filter(Predicate.not(String::isBlank))
.orElse(".*openapi.*");

List<ResolvedDependency> dependencies = context.applicationModel().getDependencies().stream()
.filter(rd -> getSupportedExtensions().contains(rd.getType().toLowerCase()))
.filter(rd -> rd.getArtifactId().matches(artifactIdFilter))
.filter(rd -> !gavsToExclude.contains(rd.getKey().toGacString()))
.filter(rd -> specificGAVSpecInputProviderFilter(context, rd.getKey().toGacString()))
.toList();

if (dependencies.isEmpty()) {
LOG.debug("No suitable GAV dependencies found. ArtifactIdFilter was %s and gavsToExclude were %s."
.formatted(artifactIdFilter, gavsToExclude));
return List.of();
}

var inputModels = new ArrayList<SpecInputModel>();
for (ResolvedDependency dependency : dependencies) {
var gacString = StringUtil.replaceNonAlphanumericByUnderscores(dependency.getKey().toGacString());
var path = dependency.getResolvedPaths().stream().findFirst()
.orElseThrow(() -> new CodeGenException("Could not find maven path of %s.".formatted(gacString)));
addInputModels(context, gacString, path, inputModels);
}
return inputModels;
}

protected abstract Set<String> getSupportedExtensions();

/**
* Adds input models to the provided list based on the given context, GAC string, and path.
* This method is implemented by subclasses to generate or retrieve the appropriate
* {@code SpecInputModel} instances that will be processed during code generation.
*
* @param context the code generation context, providing access to configuration and utilities
* @param gacString the GAC (Group, Artifact, Classifier) string representing the dependency identifier
* @param path the path to the file or directory containing the input specification(s)
* @param inputModels the list to which the generated {@code SpecInputModel} instances are added
* @throws CodeGenException if an error occurs while processing the input specifications
*/
protected abstract void addInputModels(CodeGenContext context,
String gacString,
Path path,
List<SpecInputModel> inputModels) throws CodeGenException;

/**
* Filters dependencies based on specific criteria defined in the implementing class.
* This method is invoked as part of the dependency resolution process to determine
* whether a dependency identified by its GAC string should be included for further processing.
*
* @param context the code generation context, providing access to configuration and other utilities
* @param gacString the GAC (Group, Artifact, Classifier) string representing the dependency identifier
* @return true if the dependency matches the filter criteria and should be included; false otherwise
*/
protected abstract boolean specificGAVSpecInputProviderFilter(CodeGenContext context, String gacString);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package io.quarkiverse.openapi.generator.deployment.codegen;

import static io.quarkiverse.openapi.generator.deployment.CodegenConfig.*;
import static io.quarkiverse.openapi.generator.deployment.CodegenConfig.ConfigName.*;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

import io.quarkus.bootstrap.prebuild.CodeGenException;
import io.quarkus.deployment.CodeGenContext;

/**
* Provides OpenAPI specification input from Maven GAV (GroupId:ArtifactId:Version) dependencies
* packaged as JAR or ZIP files.
* <p>
* This provider extends the {@link AbstractGAVCoordinateOpenApiSpecInputProvider} and is responsible for
* scanning application dependencies to identify JAR or ZIP files that contain OpenAPI specifications
* (e.g., `openapi.yaml`).
* </p>
*
* <h2>Supported File Types</h2>
* <p>
* The provider specifically supports dependencies packaged as:
* </p>
* <ul>
* <li>JAR files</li>
* <li>ZIP files</li>
* </ul>
*
* <h2>Scanning Behavior</h2>
* <p>
* The provider performs the following steps:
* </p>
* <ol>
* <li>Checks if GAV scanning is enabled via configuration (enabled by default)</li>
* <li>Filters dependencies by artifact type (jar/zip)</li>
* <li>Applies artifact ID filtering using a regex pattern</li>
* <li>Excludes specific GAVs based on configuration</li>
* <li>Includes specific GAVs based on configuration if not available no GAVs are used</li>
* <li>Creates {@link ZippedSpecInputModel} instances for each matching dependency and openAPI specification file</li>
* </ol>
*
* <h2>Configuration</h2>
* <p>
* The provider respects the following configuration properties:
* </p>
* <ul>
* <li>{@code quarkus.openapi-generator.codegen.gav-scanning} - Enable/disable GAV scanning</li>
* <li>{@code quarkus.openapi-generator.codegen.artifact-id-filter} - Regex pattern for artifact ID filtering</li>
* <li>{@code quarkus.openapi-generator.codegen.exclude-gavs} - List of GAV coordinates to exclude
* (format: groupId:artifactId:classifier)</li>
* <li>{@code quarkus.openapi-generator.codegen.spec.com_sample_customer_service_openapi.gav-spec-files} - List of
* openAPI specification files in com.sample:customer-service-openapi:jar</li>
* </ul>
*
* <h2>Example Usage</h2>
*
* <pre>
* # application.properties
* quarkus.openapi-generator.codegen.gav-scanning=true
* quarkus.openapi-generator.codegen.artifact-id-filter=.*api.*
* quarkus.openapi-generator.codegen.exclude-gavs=com.example:old-api
* quarkus.openapi-generator.codegen.spec.com_sample_customer_service_api.gav-spec-files=customer.yaml,another.yaml
* </pre>
*
* @see AbstractGAVCoordinateOpenApiSpecInputProvider
* @see ZippedSpecInputModel
* @see CodeGenContext
*/
public class JarOrZipGAVCoordinateOpenApiSpecInputProvider extends AbstractGAVCoordinateOpenApiSpecInputProvider {
private static final Set<String> SUPPORTED_EXTENSIONS = Set.of("jar", "zip");

@Override
protected void addInputModels(CodeGenContext context,
String gacString,
Path path,
List<SpecInputModel> inputModels) throws CodeGenException {
List<String> rootFilesOfSpecOfDependency = context.config()
.getOptionalValues(getSpecConfigName(GAV_SPEC_FILES, Paths.get(gacString)), String.class)
.orElse(List.of("openapi.yaml"));
for (String rootFileOfSpecForDependency : rootFilesOfSpecOfDependency) {
try {
inputModels.add(new ZippedSpecInputModel(
gacString,
rootFileOfSpecForDependency,
Files.newInputStream(path)));
} catch (IOException e) {
throw new CodeGenException(
"Could not open input stream of %s from %s.".formatted(gacString, path.toString()),
e);
}
}
}

@Override
protected Set<String> getSupportedExtensions() {
return SUPPORTED_EXTENSIONS;
}

@Override
protected boolean specificGAVSpecInputProviderFilter(final CodeGenContext context, final String gacString) {
return context.config().getOptionalValues(getGlobalConfigName(INCLUDE_GAVS), String.class)
.orElse(List.of()) // default to empty list to disable all if not specified
.stream().collect(Collectors.toSet())
.contains(gacString);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.quarkiverse.openapi.generator.deployment.codegen;

import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
Expand All @@ -13,6 +14,8 @@
import java.util.List;
import java.util.ServiceLoader;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

import org.eclipse.microprofile.config.Config;
import org.eclipse.microprofile.config.spi.ConfigSource;
Expand Down Expand Up @@ -68,24 +71,39 @@ public boolean trigger(CodeGenContext context) throws CodeGenException {
throw new CodeGenException("SpecInputModel from provider " + provider + " is null");
}
try {
final Path openApiFilePath = Paths.get(outDir.toString(), inputModel.getFileName());
Files.createDirectories(openApiFilePath.getParent());
try (ReadableByteChannel inChannel = Channels.newChannel(inputModel.getInputStream());
FileChannel outChannel = FileChannel.open(openApiFilePath, StandardOpenOption.WRITE,
StandardOpenOption.CREATE)) {
outChannel.transferFrom(inChannel, 0, Integer.MAX_VALUE);
LOGGER.debug("Saved OpenAPI spec input model in {}", openApiFilePath);

OpenApiGeneratorOptions options = new OpenApiGeneratorOptions(
this.mergeConfig(context, inputModel),
openApiFilePath,
outDir,
context.workDir().resolve("classes").resolve("templates"),
isRestEasyReactive);

this.generate(options);
generated = true;
final Path openApiFilePath;
if (inputModel instanceof ZippedSpecInputModel zippedSpecInputModel) {
final Path pathToExtract = Paths.get(outDir.toString(), inputModel.getFileName());
if (!Files.exists(pathToExtract)) {
// only extract GAV at first iteration. if exists reuse it
Files.createDirectories(pathToExtract);
extractZip(inputModel.getInputStream(), pathToExtract);
}
openApiFilePath = Paths.get(pathToExtract.toString(), zippedSpecInputModel.getRootFileOfSpec());
if (!Files.exists(openApiFilePath)) {
throw new CodeGenException(
String.format("Could not locate openAPI specification file %s in extracted content",
openApiFilePath));
}
} else {
openApiFilePath = Paths.get(outDir.toString(), inputModel.getFileName());
Files.createDirectories(openApiFilePath.getParent());
try (ReadableByteChannel inChannel = Channels.newChannel(inputModel.getInputStream());
FileChannel outChannel = FileChannel.open(openApiFilePath, StandardOpenOption.WRITE,
StandardOpenOption.CREATE)) {
outChannel.transferFrom(inChannel, 0, Integer.MAX_VALUE);
LOGGER.debug("Saved OpenAPI spec input model in {}", openApiFilePath);
}
}
OpenApiGeneratorOptions options = new OpenApiGeneratorOptions(
this.mergeConfig(context, inputModel),
openApiFilePath,
outDir,
context.workDir().resolve("classes").resolve("templates"),
isRestEasyReactive);

this.generate(options);
generated = true;
} catch (IOException e) {
throw new UncheckedIOException("Failed to save InputStream from provider " + provider + " into location ",
e);
Expand All @@ -103,6 +121,39 @@ private Config mergeConfig(CodeGenContext context, SpecInputModel inputModel) {
.withSources(sources).build();
}

private void extractZip(InputStream inputStream, Path outputDir) throws IOException {
// Open the JAR/ZIP file as a ZipInputStream
try (ZipInputStream zipInputStream = new ZipInputStream(inputStream)) {
ZipEntry entry;
// Iterate through each entry in the ZIP
while ((entry = zipInputStream.getNextEntry()) != null) {
String entryName = entry.getName();
Path entryPath = outputDir.resolve(entryName);
if (entry.isDirectory() ||
SUPPORTED_EXTENSIONS_WITH_LEADING_DOT.stream().noneMatch(entryName::endsWith)) {
continue;
}
// If the ZIP file contains entries like `../../malicious_file`
if (!entryPath.toAbsolutePath().normalize().startsWith(outputDir.toAbsolutePath().normalize())) {
throw new IOException("Invalid ZIP entry: " + entryName);
}
// If it's a file, create parent directories first
if (!Files.exists(entryPath.getParent())) {
Files.createDirectories(entryPath.getParent());
}
// Write the file
try (var outStream = Files.newOutputStream(entryPath,
StandardOpenOption.CREATE,
StandardOpenOption.WRITE,
StandardOpenOption.TRUNCATE_EXISTING)) {
zipInputStream.transferTo(outStream);
}
// Close the current ZIP entry
zipInputStream.closeEntry();
}
}
}

@Override
public boolean shouldRun(Path sourceDir, Config config) {
return !this.providers.isEmpty();
Expand Down
Loading