001/*
002 * Copyright 2017-2022 original authors
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * https://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package io.micronaut.maven;
017
018import org.apache.maven.artifact.Artifact;
019import org.apache.maven.plugin.MojoExecutionException;
020import org.apache.maven.plugins.annotations.LifecyclePhase;
021import org.apache.maven.plugins.annotations.Mojo;
022import org.apache.maven.plugins.annotations.Parameter;
023import org.apache.maven.plugins.annotations.ResolutionScope;
024import org.apache.maven.project.MavenProject;
025
026import javax.inject.Inject;
027import java.io.File;
028import java.io.IOException;
029import java.nio.charset.StandardCharsets;
030import java.nio.file.Files;
031import java.nio.file.Path;
032import java.nio.file.StandardOpenOption;
033import java.util.ArrayList;
034import java.util.Collections;
035import java.util.List;
036import java.util.jar.JarEntry;
037import java.util.jar.JarFile;
038import java.util.regex.Pattern;
039
040import static io.micronaut.maven.RunMojo.THIS_PLUGIN;
041
042/**
043 * Import beans from project dependencies by generating factories annotated with
044 * <code>@Import</code> containing the list of packages.
045 *
046 * @author Auke Schrijnen
047 * @since 4.5.0
048 */
049@Mojo(name = ImportFactoryMojo.MOJO_NAME, defaultPhase = LifecyclePhase.GENERATE_SOURCES, threadSafe = true, requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME)
050public class ImportFactoryMojo extends AbstractMicronautMojo {
051
052    /**
053     * Name of the import factory mojo.
054     */
055    public static final String MOJO_NAME = "generate-import-factory";
056
057    private static final String MICRONAUT_IMPORTFACTORY_PREFIX = "micronaut.importfactory";
058
059    /**
060     * Reference to the Maven project on which the plugin is invoked.
061     */
062    private final MavenProject project;
063
064    /**
065     * Whether to enable or disable the generation of the import factory.
066     *
067     * @since 4.5.0
068     */
069    @Parameter(property = MICRONAUT_IMPORTFACTORY_PREFIX + ".enabled", defaultValue = "false")
070    private boolean enabled;
071
072    /**
073     * The output directory to which all the sources will be generated.
074     *
075     * @since 4.5.0
076     */
077    @Parameter(defaultValue = "${project.build.directory}/generated-sources/importfactory", property = MICRONAUT_IMPORTFACTORY_PREFIX + ".outputDirectory", required = true)
078    private File outputDirectory;
079
080    /**
081     * Add the output directory to the project as a source root in order to let the generated java
082     * classes be compiled and included in the project artifact.
083     *
084     * @since 4.5.0
085     */
086    @Parameter(defaultValue = "true", property = MICRONAUT_IMPORTFACTORY_PREFIX + ".addCompileSourceRoot")
087    private boolean addCompileSourceRoot;
088
089    /**
090     * Regexp pattern which allows including certain dependencies.
091     *
092     * @since 4.5.0
093     */
094    @Parameter(defaultValue = "^.*:.*$", property = MICRONAUT_IMPORTFACTORY_PREFIX + ".includeDependenciesFilter", required = true)
095    private String includeDependenciesFilter;
096
097    /**
098     * Regexp pattern which allows excluding certain dependencies.
099     *
100     * @since 4.5.0
101     */
102    @Parameter(defaultValue = "^$", property = MICRONAUT_IMPORTFACTORY_PREFIX + ".excludeDependenciesFilter", required = true)
103    private String excludeDependenciesFilter;
104
105    /**
106     * Regexp pattern which allows including certain packages.
107     *
108     * @since 4.5.0
109     */
110    @Parameter(defaultValue = "^.*$", property = MICRONAUT_IMPORTFACTORY_PREFIX + ".includePackagesFilter", required = true)
111    private String includePackagesFilter;
112
113    /**
114     * Regexp pattern which allows excluding certain packages.
115     *
116     * @since 4.5.0
117     */
118    @Parameter(defaultValue = "^$", property = MICRONAUT_IMPORTFACTORY_PREFIX + ".excludePackagesFilter", required = true)
119    private String excludePackagesFilter;
120
121    /**
122     * The package name which is used for the generated import factories. When not specified a factory
123     * is generated for each package within that package in order to access package protected fields.
124     *
125     * @since 4.5.0
126     */
127    @Parameter(property = MICRONAUT_IMPORTFACTORY_PREFIX + ".targetPackage")
128    private String targetPackage;
129
130    @Inject
131    public ImportFactoryMojo(MavenProject project) {
132        this.project = project;
133    }
134
135    @Override
136    public void execute() throws MojoExecutionException {
137        if (!enabled) {
138            getLog().debug(this.getClass().getSimpleName() + " is disabled");
139            return;
140        }
141
142        if (addCompileSourceRoot) {
143            project.addCompileSourceRoot(outputDirectory.getPath());
144        }
145
146        List<Artifact> dependencies = getFilteredDependencies();
147
148        if (dependencies.isEmpty()) {
149            getLog().warn("No matching dependencies.");
150            return;
151        }
152
153        getLog().info("Found " + dependencies.size() + " matching dependencies:");
154        dependencies.forEach(
155            dependency -> getLog().info(getIdentifier(dependency) + " at " + dependency.getFile()));
156
157        List<String> packages = getFilteredPackages(dependencies);
158
159        if (packages.isEmpty()) {
160            getLog().warn("No matching packages.");
161            return;
162        }
163
164        getLog().info("Found " + packages.size() + " matching packages:");
165        packages.forEach(getLog()::info);
166
167        if (targetPackage == null || targetPackage.isEmpty()) {
168            for (String packageName : packages) {
169                try {
170                    generateImportFactory(packageName, Collections.singletonList(packageName));
171                } catch (IOException e) {
172                    throw new MojoExecutionException("Error creating factory for " + packageName, e);
173                }
174            }
175        } else {
176            try {
177                generateImportFactory(targetPackage, packages);
178            } catch (IOException e) {
179                throw new MojoExecutionException("Error creating factory for " + targetPackage, e);
180            }
181        }
182    }
183
184    /**
185     * Get the list of matching packages from the given list of dependencies.
186     *
187     * @param dependencies the Maven dependencies
188     * @return a list of matching packages
189     * @throws MojoExecutionException when the artifacts can't be read
190     */
191    private List<String> getFilteredPackages(List<Artifact> dependencies) throws MojoExecutionException {
192        var packages = new ArrayList<String>();
193        for (Artifact dependency : dependencies) {
194            packages.addAll(getPackages(dependency));
195        }
196
197        var includePackages = Pattern.compile(includePackagesFilter);
198        var excludePackages = Pattern.compile(excludePackagesFilter);
199
200        return packages.stream()
201            .filter(includePackages.asMatchPredicate())
202            .filter(excludePackages.asMatchPredicate().negate())
203            .sorted()
204            .toList();
205    }
206
207    /**
208     * Get the list of matching dependencies.
209     *
210     * @return the matching dependencies
211     */
212    private List<Artifact> getFilteredDependencies() {
213        var includeDependency = Pattern.compile(includeDependenciesFilter);
214        var excludeDependency = Pattern.compile(excludeDependenciesFilter);
215
216        return project.getArtifacts().stream()
217            .filter(dependency -> includeDependency.matcher(getIdentifier(dependency)).matches())
218            .filter(dependency -> !excludeDependency.matcher(getIdentifier(dependency)).matches())
219            .toList();
220    }
221
222    /**
223     * Get the packages from the given artifact.
224     *
225     * @param artifact the Maven artifact
226     * @return the list of packages
227     * @throws MojoExecutionException when the artifacts can't be read
228     */
229    private List<String> getPackages(Artifact artifact) throws MojoExecutionException {
230        try (var file = new JarFile(artifact.getFile(), false)) {
231            return file.stream()
232                .filter(entry -> entry.getName().endsWith(".class") && entry.getName().contains("/"))
233                .map(this::getPackageName).distinct().sorted().toList();
234        } catch (IOException e) {
235            throw new MojoExecutionException("Unable to read " + artifact.getFile(), e);
236        }
237    }
238
239    /**
240     * Get the identifier for the given Maven artifact.
241     *
242     * @param artifact the Maven artifact
243     * @return the identifier for the artifact
244     */
245    private String getIdentifier(Artifact artifact) {
246        return artifact.getGroupId() + ":" + artifact.getArtifactId();
247    }
248
249    /**
250     * Get the Java package name from the given Jar entry.
251     *
252     * @param entry the Jar entry
253     * @return the Java package name
254     */
255    private String getPackageName(JarEntry entry) {
256        String entryName = entry.getName();
257        // remove .class from the end and change format to use periods instead of forward slashes
258        return entryName.substring(0, entryName.lastIndexOf('/')).replace('/', '.');
259    }
260
261    /**
262     * Generate the import factory with the given packages in the given package.
263     *
264     * @param packageName the package in which the factory is generated
265     * @param packages the list of the packages which are imported
266     * @throws IOException when the writing of the factory class fails
267     */
268    private void generateImportFactory(String packageName, List<String> packages) throws IOException {
269        Path factoryPath = outputDirectory.toPath().resolve(packageName.replace('.', '/'))
270            .resolve("ImportFactory.java");
271
272        var code = new ArrayList<String>();
273        code.add("package " + packageName + ";");
274        code.add("");
275        code.add("import io.micronaut.context.annotation.Factory;");
276        code.add("import io.micronaut.context.annotation.Import;");
277        code.add("import jakarta.annotation.Generated;");
278        code.add("");
279        code.add("/** Factory which allows Micronaut to import beans from the specified packages. */");
280        code.add("@Generated(\"%s\")".formatted(THIS_PLUGIN));
281        code.add("@Factory");
282        code.add("@Import(");
283        code.add("    packages = {");
284        for (String name : packages) {
285            code.add("      \"" + name + "\",");
286        }
287        code.add("    })");
288        code.add("public class ImportFactory { }\n");
289
290        Files.createDirectories(factoryPath.getParent());
291        Files.write(factoryPath, code, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
292    }
293
294}