com.github.victools.jsonschema.plugin.maven.SchemaGeneratorMojo Maven / Gradle / Ivy
The newest version!
/*
* Copyright 2020 VicTools.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.github.victools.jsonschema.plugin.maven;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.victools.jsonschema.generator.Module;
import com.github.victools.jsonschema.generator.OptionPreset;
import com.github.victools.jsonschema.generator.SchemaGenerator;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfig;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
import com.github.victools.jsonschema.generator.SchemaVersion;
import com.github.victools.jsonschema.generator.impl.Util;
import com.github.victools.jsonschema.module.jackson.JacksonModule;
import com.github.victools.jsonschema.module.jackson.JacksonOption;
import com.github.victools.jsonschema.module.jakarta.validation.JakartaValidationModule;
import com.github.victools.jsonschema.module.jakarta.validation.JakartaValidationOption;
import com.github.victools.jsonschema.module.javax.validation.JavaxValidationModule;
import com.github.victools.jsonschema.module.javax.validation.JavaxValidationOption;
import com.github.victools.jsonschema.module.swagger15.SwaggerModule;
import com.github.victools.jsonschema.module.swagger15.SwaggerOption;
import com.github.victools.jsonschema.module.swagger2.Swagger2Module;
import io.github.classgraph.ClassGraph;
import io.github.classgraph.ClassInfo;
import io.github.classgraph.ClassInfoList;
import io.github.classgraph.ScanResult;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.lang.reflect.Array;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Modifier;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.LifecyclePhase;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.plugins.annotations.ResolutionScope;
import org.apache.maven.project.MavenProject;
/**
* Maven plugin for the victools/jsonschema-generator.
*/
@Mojo(name = "generate",
defaultPhase = LifecyclePhase.COMPILE,
requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME,
requiresDependencyCollection = ResolutionScope.COMPILE_PLUS_RUNTIME,
threadSafe = true)
public class SchemaGeneratorMojo extends AbstractMojo {
/**
* Full name or glob pattern of the classes for which the JSON schema will be generated.
*/
@Parameter(property = "classNames")
private String[] classNames;
/**
* Full name or glob pattern of the packages for which a JSON schema will be generated for each contained class.
*/
@Parameter(property = "packageNames")
private String[] packageNames;
/**
* Full name or glob pattern of the classes NOT to generate a JSON schema for.
*/
@Parameter(property = "excludeClassNames")
private String[] excludeClassNames;
/**
* Full name of annotations for whose annotated classes the JSON schema will be generated.
*/
@Parameter(property = "annotations")
private List annotations = new ArrayList<>();
/**
* Flag indicating whether abstract classes should be ignored, even if they are matching the classname and/or package pattern.
*
* @since 4.37.0
*/
@Parameter(property = "skipAbstractTypes", defaultValue = "false")
private boolean skipAbstractTypes;
/**
* Flag indicating whether interfaces should be ignored, even if they are matching the classname and/or package pattern.
*
* @since 4.37.0
*/
@Parameter(property = "skipInterfaces", defaultValue = "false")
private boolean skipInterfaces;
/**
* The classpath to look for classes to generate schema files.
*/
@Parameter(property = "classpath", defaultValue = "WITH_RUNTIME_DEPENDENCIES")
private ClasspathType classpath;
/**
* The directory path where the schema files are generated.
*
* By default, this is: {@code src/main/resources}
*/
@Parameter(property = "schemaFilePath")
private File schemaFilePath;
/**
* The name of the file in which the generated schema is written. Allowing for two placeholders:
*
* {0}
- containing the simple class name of the class for which the schema was generated
* {1}
- containing the package path of the class for which the schema was generated
*
* The default name is: {0}-schema.json
*/
@Parameter(property = "schemaFileName", defaultValue = "{0}-schema.json")
private String schemaFileName;
/**
* The schema version to be used: DRAFT_6, DRAFT_7, DRAFT_2019_09 or DRAFT_2020_12.
*/
@Parameter(property = "schemaVersion", defaultValue = "DRAFT_7")
private SchemaVersion schemaVersion;
/**
* The options for the generator.
*/
@Parameter
private GeneratorOptions options;
/**
* Selection of Modules that need to be activated during generation.
*/
@Parameter
private GeneratorModule[] modules;
/**
* Variable to control whether the build shall abort if no classes matching the pattern are found.
*/
@Parameter(property = "failIfNoClassesMatch", defaultValue = "true")
private boolean failIfNoClassesMatch;
/**
* The Maven project.
*/
@Parameter(defaultValue = "${project}", required = true, readonly = true)
MavenProject project;
/**
* The generator to be used for all schema generations.
*/
private SchemaGenerator generator;
/**
* The classloader used for loading generator modules and classes.
*/
private URLClassLoader classLoader;
/**
* The list of all the classes on the classpath.
*/
private List allTypes;
/**
* Invoke the schema generator.
*
* @throws MojoExecutionException An exception in case of errors and unexpected behavior
*/
@Override
public synchronized void execute() throws MojoExecutionException {
// trigger initialization of the generator instance
this.getGenerator();
for (String className : Util.nullSafe(this.classNames)) {
this.getLog().info("Generating JSON Schema for " + className + " ");
this.generateSchema(className, false);
}
for (String packageName : Util.nullSafe(this.packageNames)) {
this.getLog().info("Generating JSON Schema for " + packageName + " ");
this.generateSchema(packageName, true);
}
if (Util.isNullOrEmpty(this.classNames) && Util.isNullOrEmpty(this.packageNames) && !Util.isNullOrEmpty(this.annotations)) {
this.getLog().info("Generating JSON Schema for all annotated classes");
this.generateSchema("**/*", false);
}
}
/**
* Generate the JSON schema for the given className.
*
* @param classOrPackageName The name or glob pattern of the class or package
* @param targetPackage whether the given name or glob pattern refers to a package
* @throws MojoExecutionException In case of problems
*/
private void generateSchema(String classOrPackageName, boolean targetPackage) throws MojoExecutionException {
Predicate filter = GlobHandler.createClassOrPackageNameFilter(classOrPackageName, targetPackage);
List matchingClasses = this.getAllClassNames().stream()
.filter(entry -> filter.test(entry.getAbsolutePathToMatch()))
.sorted()
.collect(Collectors.toList());
for (PotentialSchemaClass potentialTarget : matchingClasses) {
if (potentialTarget.isAlreadyGenerated()) {
this.getLog().info("- Skipping already generated " + potentialTarget.getFullClassName());
} else {
this.generateSchema(potentialTarget);
potentialTarget.setAlreadyGenerated();
}
}
if (matchingClasses.isEmpty()) {
this.logForNoClassesMatchingFilter(classOrPackageName);
}
}
/**
* Generate the JSON schema for the indicated type matching a class name or package pattern. Considering further config flags potentially skipping
* the schema file generation.
*
* @param potentialTarget class to produce JSON schema file for
* @throws MojoExecutionException In case of problems
*/
private void generateSchema(PotentialSchemaClass potentialTarget) throws MojoExecutionException {
// Load the class for which the schema will be generated
Class> schemaClass = this.loadClass(potentialTarget.getFullClassName());
if (this.skipInterfaces && schemaClass.isInterface()) {
this.getLog().info("- Skipping interface " + potentialTarget.getFullClassName());
} else if (this.skipAbstractTypes && this.isAbstractClass(schemaClass)) {
this.getLog().info("- Skipping abstract type " + potentialTarget.getFullClassName());
} else {
this.generateSchema(schemaClass);
}
}
/**
* Generate the JSON schema for the given className.
*
* @param schemaClass The class for which the schema is to be generated
* @throws MojoExecutionException In case of problems
*/
private void generateSchema(Class> schemaClass) throws MojoExecutionException {
JsonNode jsonSchema = getGenerator().generateSchema(schemaClass);
File file = getSchemaFile(schemaClass);
this.getLog().info("- Writing schema to file: " + file);
this.writeToFile(jsonSchema, file);
}
private void logForNoClassesMatchingFilter(String classOrPackageName) throws MojoExecutionException {
StringBuilder message = new StringBuilder("No matching class found for \"")
.append(classOrPackageName)
.append("\" on classpath");
if (!Util.isNullOrEmpty(this.excludeClassNames)) {
message.append(" that wasn't excluded");
}
if (this.failIfNoClassesMatch) {
message.append(".\nYou can change this error to a warning by setting: false ");
throw new MojoExecutionException(message.toString());
}
this.getLog().warn(message.toString());
}
/**
* Get all the names of classes on the classpath.
*
* @return A set of classes as found on the classpath, that are not explicitly excluded
*/
private List getAllClassNames() {
if (this.allTypes != null) {
return this.allTypes;
}
ClassGraph classGraph = new ClassGraph()
.overrideClasspath(classpath.getClasspathElements(this.project))
.enableClassInfo();
boolean considerAnnotations = this.annotations != null && !this.annotations.isEmpty();
if (considerAnnotations) {
classGraph.enableAnnotationInfo();
}
ClassInfoList.ClassInfoFilter filter = createClassInfoFilter(considerAnnotations);
try (ScanResult scanResult = classGraph.scan()) {
Stream allTypesStream;
if (considerAnnotations) {
allTypesStream = this.annotations.stream()
.flatMap(a -> scanResult.getClassesWithAnnotation(a.className).filter(filter).stream())
.distinct();
} else {
allTypesStream = scanResult.getAllClasses().filter(filter).stream();
}
this.allTypes = allTypesStream
.map(PotentialSchemaClass::new)
.collect(Collectors.toList());
}
return this.allTypes;
}
/**
* Based on the plugin configuration, create a filter instance that determines whether a given classpath element should be considered.
*
* @param considerAnnotations whether the plugin configuration includes looking up types by certain annotations
* @return filter instance to apply on a ClassInfoList containing possibly eligible classpath elements
*/
private ClassInfoList.ClassInfoFilter createClassInfoFilter(boolean considerAnnotations) {
Set> exclusions = Util.nullSafe(this.excludeClassNames).stream()
.map(excludeEntry -> GlobHandler.createClassOrPackageNameFilter(excludeEntry, false))
.collect(Collectors.toSet());
Set> inclusions;
if (considerAnnotations) {
inclusions = Collections.singleton(input -> true);
} else {
inclusions = new HashSet<>();
Util.nullSafe(this.classNames).stream()
.map(className -> GlobHandler.createClassOrPackageNameFilter(className, false))
.forEach(inclusions::add);
Util.nullSafe(this.packageNames).stream()
.map(packageName -> GlobHandler.createClassOrPackageNameFilter(packageName, true))
.forEach(inclusions::add);
}
return element -> {
String classPathEntry = element.getName().replaceAll("\\.", "/");
if (exclusions.stream().anyMatch(exclude -> exclude.test(classPathEntry))) {
this.getLog().debug(" Excluding: " + element.getName());
return false;
}
if (inclusions.stream().anyMatch(include -> include.test(classPathEntry))) {
this.getLog().debug(" Including: " + element.getName());
return true;
}
return false;
};
}
/**
* Return the file in which the schema has to be written.
*
*
* The path is determined based on the {@link #schemaFilePath} parameter.
*
* The name of the file is determined based on the {@link #schemaFileName} parameter, which allows for two placeholders:
*
* {0}
- containing the simple name of the class the schema was generated for
* {1}
- containing the package path of the class the schema was generated for
*
*
* The default path is: {@code src/main/resources}
*
* The default name is: {0}-schema.json
*
* @param mainType targeted class for which the schema is being generated
* @return The full path name of the schema file
*/
private File getSchemaFile(Class> mainType) {
// At first find the root location where the schema files are written
File directory;
if (this.schemaFilePath == null) {
directory = new File("src" + File.separator + "main" + File.separator + "resources");
this.getLog().debug("- No 'schemaFilePath' configured. Applying default: " + directory);
} else {
directory = this.schemaFilePath;
}
// Then build the full qualified file name.
String fileName = MessageFormat.format(this.schemaFileName,
// placeholder {0}
mainType.getSimpleName(),
// placeholder {1}
mainType.getPackage().getName().replace('.', File.separatorChar));
File schemaFile = new File(directory, fileName);
// Make sure the directory is available
try {
Files.createDirectories(schemaFile.getParentFile().toPath());
} catch (IOException e) {
this.getLog().warn("Failed to ensure existence of " + schemaFile.getParent(), e);
}
return schemaFile;
}
/**
* Get the JSON Schema generator. Create it when required.
*
* Configuring it the specified options and adding the required modules.
*
* @return The configured generator
* @throws MojoExecutionException Error exception
*/
private SchemaGenerator getGenerator() throws MojoExecutionException {
if (this.generator == null) {
this.getLog().debug("Initializing Schema Generator");
// Start with the generator builder
SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(this.schemaVersion, this.getOptionPreset());
// Add options when required
this.setOptions(configBuilder);
// Register the modules when specified
this.setModules(configBuilder);
// And construct the generator
SchemaGeneratorConfig config = configBuilder.build();
this.generator = new SchemaGenerator(config);
}
return this.generator;
}
/**
* Determine the standard option preset of the generator. Take it from the configuration or set the default. The default is: PLAIN_JSON
*
* @return The OptionPreset
*/
private OptionPreset getOptionPreset() {
if (this.options != null && this.options.preset != null) {
return this.options.preset.getPreset();
}
this.getLog().debug("- No 'options/preset' configured. Applying default: PLAIN_JSON");
return OptionPreset.PLAIN_JSON;
}
/**
* Set the generator options form the configuration.
*
* @param configBuilder The configbuilder on which the options are set
*/
private void setOptions(SchemaGeneratorConfigBuilder configBuilder) {
if (this.options != null) {
// Enable all the configured options
Util.nullSafe(this.options.enabled).forEach(configBuilder::with);
// Disable all the configured options
Util.nullSafe(this.options.disabled).forEach(configBuilder::without);
}
}
/**
* Configure all the modules on the generator.
*
* @param configBuilder The builder on which the modules are added.
* @throws MojoExecutionException Invalid module name or className configured
*/
@SuppressWarnings("unchecked")
private void setModules(SchemaGeneratorConfigBuilder configBuilder) throws MojoExecutionException {
for (GeneratorModule module : Util.nullSafe(this.modules)) {
if (!Util.isNullOrEmpty(module.className)) {
this.addCustomModule(module.className, configBuilder);
} else if (!Util.isNullOrEmpty(module.name)) {
this.addStandardModule(module, configBuilder);
}
}
}
/**
* Instantiate and apply the custom module with the given class name to the config builder.
*
* @param moduleClassName Class name of the custom module to add.
* @param configBuilder The builder on which the module is added.
* @throws MojoExecutionException When failing to instantiate the indicated module class.
*/
private void addCustomModule(String moduleClassName, SchemaGeneratorConfigBuilder configBuilder) throws MojoExecutionException {
this.getLog().debug("- Adding custom Module " + moduleClassName);
try {
Class extends Module> moduleClass = (Class extends Module>) this.loadClass(moduleClassName);
Module moduleInstance = moduleClass.getConstructor().newInstance();
configBuilder.with(moduleInstance);
} catch (ClassCastException | InstantiationException
| IllegalAccessException | NoSuchMethodException
| InvocationTargetException e) {
throw new MojoExecutionException("Error: Can not instantiate custom module " + moduleClassName, e);
}
}
/**
* Instantiate and apply the standard module with the given name to the config builder.
*
* @param module Record in the modules section from the pom containing at least a name.
* @param configBuilder The builder on which the module is added.
* @throws MojoExecutionException When an invalid module name or option is specified.
*/
private void addStandardModule(GeneratorModule module, SchemaGeneratorConfigBuilder configBuilder) throws MojoExecutionException {
switch (module.name) {
case "Jackson":
this.getLog().debug("- Adding Jackson Module");
this.addStandardModuleWithOptions(module, configBuilder, JacksonModule::new, JacksonOption.class);
break;
case "JakartaValidation":
this.getLog().debug("- Adding Jakarta Validation Module");
this.addStandardModuleWithOptions(module, configBuilder, JakartaValidationModule::new, JakartaValidationOption.class);
break;
case "JavaxValidation":
this.getLog().debug("- Adding Javax Validation Module");
this.addStandardModuleWithOptions(module, configBuilder, JavaxValidationModule::new, JavaxValidationOption.class);
break;
case "Swagger15":
this.getLog().debug("- Adding Swagger 1.5 Module");
this.addStandardModuleWithOptions(module, configBuilder, SwaggerModule::new, SwaggerOption.class);
break;
case "Swagger2":
this.getLog().debug("- Adding Swagger 2.x Module");
configBuilder.with(new Swagger2Module());
break;
default:
throw new MojoExecutionException("Error: Module does not have a name in "
+ "['Jackson', 'JakartaValidation', 'JavaxValidation', 'Swagger15', 'Swagger2'] or does not have a custom classname.");
}
}
/**
* Add a standard module to the generator configuration.
*
* @param type of option enum the standard module expects in its constructor
* @param configBuilder builder on which the standard module should be added
* @param module record in the modules section from the pom
* @param moduleConstructor module constructor expecting an array of options
* @param optionType enum type for the module options (e.g., JacksonOption or JakartaValidationOption)
* @throws MojoExecutionException in case of problems
*/
private > void addStandardModuleWithOptions(GeneratorModule module, SchemaGeneratorConfigBuilder configBuilder,
Function moduleConstructor, Class optionType) throws MojoExecutionException {
Stream.Builder optionStream = Stream.builder();
for (String optionName : Util.nullSafe(module.options)) {
try {
optionStream.add(Enum.valueOf(optionType, optionName));
} catch (IllegalArgumentException e) {
throw new MojoExecutionException("Error: Unknown " + module.name + " option " + optionName, e);
}
}
T[] options = optionStream.build().toArray(count -> (T[]) Array.newInstance(optionType, count));
configBuilder.with(moduleConstructor.apply(options));
}
/**
* Construct the classloader based on the project classpath.
*
* @return The classloader
*/
private URLClassLoader getClassLoader() {
if (this.classLoader == null) {
// fix the classpath such that the classloader can get classes from any possible dependency
// this does not affect filtering, as the classgraph library uses its own classloader and allows for caching
List urls = ClasspathType.WITH_ALL_DEPENDENCIES_AND_TESTS.getUrls(this.project);
this.classLoader = new URLClassLoader(urls.toArray(new URL[0]),
Thread.currentThread().getContextClassLoader());
}
return this.classLoader;
}
/**
* Load a class from the plugin classpath enriched with the project dependencies.
*
* @param className Name of the class to be loaded
* @return The loaded class
* @throws MojoExecutionException In case of unexpected behavior
*/
private Class> loadClass(String className) throws MojoExecutionException {
try {
return this.getClassLoader().loadClass(className);
} catch (ClassNotFoundException e) {
throw new MojoExecutionException("Error loading class " + className, e);
}
}
/**
* Write generated schema to a file.
*
* @param jsonSchema Generated schema to be written
* @param file The file to write to
* @throws MojoExecutionException In case of problems when writing the targeted file
*/
private void writeToFile(JsonNode jsonSchema, File file) throws MojoExecutionException {
ObjectMapper mapper = getGenerator().getConfig().getObjectMapper();
try (FileOutputStream outputStream = new FileOutputStream(file);
PrintWriter writer = new PrintWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) {
writer.print(mapper.writeValueAsString(jsonSchema));
} catch (IOException e) {
throw new MojoExecutionException("Error: Can not write to file " + file, e);
}
}
/**
* Check whether a given class is deemed abstract but not an interface.
*
* @param targetClass type to check
* @return whether the indicated type represents an abstract non-interface class
*/
private boolean isAbstractClass(Class> targetClass) {
return Modifier.isAbstract(targetClass.getModifiers()) && !targetClass.isInterface();
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy