All Downloads are FREE. Search and download functionalities are using the official Maven repository.

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 moduleClass = (Class) 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