diff --git a/pom.xml b/pom.xml index 7a8def860..88e651829 100644 --- a/pom.xml +++ b/pom.xml @@ -28,7 +28,7 @@ under the License. maven-dependency-plugin - 3.8.2-SNAPSHOT + 3.9.0-SNAPSHOT maven-plugin Apache Maven Dependency Plugin @@ -277,6 +277,18 @@ under the License. ${slf4jVersion} + + + org.apache.velocity + velocity-engine-core + 2.4.1 + + + org.apache.velocity.tools + velocity-tools-generic + 3.1 + + org.apache.maven.resolver diff --git a/src/main/java/org/apache/maven/plugins/dependency/fromDependencies/RenderDependenciesMojo.java b/src/main/java/org/apache/maven/plugins/dependency/fromDependencies/RenderDependenciesMojo.java new file mode 100644 index 000000000..6a4291480 --- /dev/null +++ b/src/main/java/org/apache/maven/plugins/dependency/fromDependencies/RenderDependenciesMojo.java @@ -0,0 +1,278 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.maven.plugins.dependency.fromDependencies; + +import javax.inject.Inject; + +import java.io.File; +import java.io.IOException; +import java.io.StringWriter; +import java.io.Writer; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.Properties; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.apache.maven.artifact.Artifact; +import org.apache.maven.artifact.handler.manager.ArtifactHandlerManager; +import org.apache.maven.execution.MavenSession; +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.plugins.dependency.utils.ResolverUtil; +import org.apache.maven.project.MavenProject; +import org.apache.maven.project.MavenProjectHelper; +import org.apache.maven.project.ProjectBuilder; +import org.apache.maven.shared.artifact.filter.collection.ArtifactsFilter; +import org.apache.velocity.Template; +import org.apache.velocity.VelocityContext; +import org.apache.velocity.app.VelocityEngine; +import org.apache.velocity.tools.generic.CollectionTool; +import org.sonatype.plexus.build.incremental.BuildContext; + +import static java.util.Optional.ofNullable; + +/** + * This goal renders dependencies based on a velocity template. + * + * @since 3.9.0 + */ +@Mojo( + name = "render-dependencies", + requiresDependencyResolution = ResolutionScope.TEST, + defaultPhase = LifecyclePhase.GENERATE_SOURCES, + threadSafe = true) +public class RenderDependenciesMojo extends AbstractDependencyFilterMojo { + /** + * Encoding to write the rendered template. + * @since 3.9.0 + */ + @Parameter(property = "outputEncoding", defaultValue = "${project.reporting.outputEncoding}") + private String outputEncoding; + + /** + * The file to write the rendered template string. If undefined, it just prints the classpath as [INFO]. + * @since 3.9.0 + */ + @Parameter(property = "mdep.outputFile") + private File outputFile; + + /** + * If not null or empty it will attach the artifact with this classifier. + * @since 3.9.0 + */ + @Parameter(property = "mdep.classifier", defaultValue = "template") + private String classifier; + + /** + * Extension to use for the attached file if classifier is not null/empty. + * @since 3.9.0 + */ + @Parameter(property = "mdep.extension", defaultValue = "txt") + private String extension; + + /** + * Velocity template to use to render the output file. + * It can be inline or a file path. + * @since 3.9.0 + */ + @Parameter(property = "mdep.template", required = true) + private String template; + + private final MavenProjectHelper projectHelper; + + @Inject + protected RenderDependenciesMojo( + MavenSession session, + BuildContext buildContext, + MavenProject project, + ResolverUtil resolverUtil, + ProjectBuilder projectBuilder, + ArtifactHandlerManager artifactHandlerManager, + MavenProjectHelper projectHelper) { + super(session, buildContext, project, resolverUtil, projectBuilder, artifactHandlerManager); + this.projectHelper = projectHelper; + } + + /** + * Main entry into mojo. + * + * @throws MojoExecutionException with a message if an error occurs + */ + @Override + protected void doExecute() throws MojoExecutionException { + // sort them to ease template work and ensure it is deterministic + final List artifacts = + ofNullable(getResolvedDependencies(true)).orElseGet(Collections::emptySet).stream() + .sorted(Comparator.comparing(Artifact::getGroupId) + .thenComparing(Artifact::getArtifactId) + .thenComparing(Artifact::getBaseVersion) + .thenComparing(orEmpty(Artifact::getClassifier)) + .thenComparing(orEmpty(Artifact::getType))) + .collect(Collectors.toList()); + + if (artifacts.isEmpty()) { + getLog().warn("No dependencies found."); + } + + final String rendered = render(artifacts); + + if (outputFile == null) { + getLog().info(rendered); + } else { + store(rendered, outputFile); + } + if (classifier != null && !classifier.isEmpty()) { + attachFile(rendered); + } + } + + /** + * Do render the template. + * @param artifacts input. + * @return the template rendered. + */ + private String render(final List artifacts) { + final Path templatePath = getTemplatePath(); + final boolean fromFile = templatePath != null && Files.exists(templatePath); + + final Properties props = new Properties(); + props.setProperty("runtime.strict_mode.enable", "true"); + if (fromFile) { + props.setProperty( + "resource.loader.file.path", + templatePath.toAbsolutePath().getParent().toString()); + } + + final VelocityEngine ve = new VelocityEngine(props); + ve.init(); + + final VelocityContext context = new VelocityContext(); + context.put("artifacts", artifacts); + context.put("sorter", new CollectionTool()); + + // Merge template + context + final StringWriter writer = new StringWriter(); + try (StringWriter ignored = writer) { + if (fromFile) { + final Template template = + ve.getTemplate(templatePath.getFileName().toString()); + template.merge(context, writer); + } else { + ve.evaluate(context, writer, "tpl-" + Math.abs(hashCode()), template); + } + } catch (final IOException e) { + // no-op, not possible + } + + return writer.toString(); + } + + private Path getTemplatePath() { + try { + return Paths.get(template); + } catch (final RuntimeException re) { + return null; + } + } + + /** + * Trivial null protection impl for comparing callback. + * @param getter nominal getter. + * @return a comparer of getter defaulting on empty if getter value is null. + */ + private Comparator orEmpty(final Function getter) { + return Comparator.comparing(a -> ofNullable(getter.apply(a)).orElse("")); + } + + /** + * @param content the rendered template + * @throws MojoExecutionException in case of an error + */ + protected void attachFile(final String content) throws MojoExecutionException { + final File attachedFile; + if (outputFile == null) { + attachedFile = new File(getProject().getBuild().getDirectory(), classifier); + store(content, attachedFile); + } else { // already written + attachedFile = outputFile; + } + projectHelper.attachArtifact(getProject(), extension, classifier, attachedFile); + } + + /** + * Stores the specified string into that file. + * + * @param content the string to write into the file + */ + private void store(final String content, final File out) throws MojoExecutionException { + // make sure the parent path exists. + final Path parent = out.toPath().getParent(); + if (parent != null) { + try { + Files.createDirectories(parent); + } catch (final IOException e) { + throw new MojoExecutionException(e); + } + } + + final String encoding = Objects.toString(outputEncoding, StandardCharsets.UTF_8.name()); + try (Writer w = Files.newBufferedWriter(out.toPath(), Charset.forName(encoding))) { + w.write(content); + getLog().info("Wrote file '" + out + "'."); + } catch (final IOException ex) { + throw new MojoExecutionException("Error while writing to file '" + out, ex); + } + } + + @Override + protected ArtifactsFilter getMarkedArtifactFilter() { + return null; + } + + public void setExtension(final String extension) { + this.extension = extension; + } + + public void setOutputEncoding(final String outputEncoding) { + this.outputEncoding = outputEncoding; + } + + public void setOutputFile(final File outputFile) { + this.outputFile = outputFile; + } + + public void setClassifier(final String classifier) { + this.classifier = classifier; + } + + public void setTemplate(final String template) { + this.template = template; + } +} diff --git a/src/site/apt/examples/render-dependencies.apt.vm b/src/site/apt/examples/render-dependencies.apt.vm new file mode 100644 index 000000000..9e2aaae62 --- /dev/null +++ b/src/site/apt/examples/render-dependencies.apt.vm @@ -0,0 +1,77 @@ +~~ Licensed to the Apache Software Foundation (ASF) under one +~~ or more contributor license agreements. See the NOTICE file +~~ distributed with this work for additional information +~~ regarding copyright ownership. The ASF licenses this file +~~ to you 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. + + ------ + Render a Velocity template + ------ + Allan Ramirez + Brian Fox + Stephen Connolly + ------ + 2025-09-17 + ------ + +Render a Velocity template + + You can use <<>> mojo to a render a velocity template, + with the <> (dependencies) as context: + ++---+ + + [...] + + + + org.apache.maven.plugins + maven-dependency-plugin + ${project.version} + + + copy + process-resources + + render-dependencies + + + + + + + + + + [...] + ++---+ + + Then after executing <<>>, the template will be rendered. + By default it is printed in the console but you can set <> to store it somewhere. + + The resolution uses exactly the same mechanism than for <> mojo. diff --git a/src/site/apt/index.apt.vm b/src/site/apt/index.apt.vm index 468888d24..b44c07f56 100644 --- a/src/site/apt/index.apt.vm +++ b/src/site/apt/index.apt.vm @@ -106,6 +106,9 @@ ${project.name} *{{{./unpack-dependencies-mojo.html}dependency:unpack-dependencies}} like copy-dependencies but unpacks. + *{{{./render-dependencies-mojo.html}dependency:render-dependencies}} like + build-classpath but with a custom Velocity template. + [] * Usage @@ -152,6 +155,8 @@ ${project.name} * {{{./examples/tree-mojo.html}Tree Mojo}} + * {{{./examples/render-dependencies.html}Render Dependencies}} + [] * Resources diff --git a/src/site/site.xml b/src/site/site.xml index fe973276c..969c6875a 100644 --- a/src/site/site.xml +++ b/src/site/site.xml @@ -43,6 +43,7 @@ under the License. + diff --git a/src/test/java/org/apache/maven/plugins/dependency/fromDependencies/TestRenderDependenciesMojo.java b/src/test/java/org/apache/maven/plugins/dependency/fromDependencies/TestRenderDependenciesMojo.java new file mode 100644 index 000000000..c42da77d8 --- /dev/null +++ b/src/test/java/org/apache/maven/plugins/dependency/fromDependencies/TestRenderDependenciesMojo.java @@ -0,0 +1,153 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.maven.plugins.dependency.fromDependencies; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Set; + +import org.apache.maven.artifact.Artifact; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.plugins.dependency.AbstractDependencyMojoTestCase; +import org.apache.maven.plugins.dependency.testUtils.stubs.DependencyProjectStub; +import org.apache.maven.project.MavenProject; + +import static org.assertj.core.api.Assertions.assertThat; + +public class TestRenderDependenciesMojo extends AbstractDependencyMojoTestCase { + private RenderDependenciesMojo mojo; + + @Override + protected String getTestDirectoryName() { + return "render-dependencies"; + } + + @Override + protected boolean shouldCreateFiles() { + return true; + } + + @Override + protected void setUp() throws Exception { + super.setUp(); + + final MavenProject project = new DependencyProjectStub(); + getContainer().addComponent(project, MavenProject.class.getName()); + + final MavenSession session = newMavenSession(project); + getContainer().addComponent(session, MavenSession.class.getName()); + + final File testPom = new File( + getBasedir(), "target/test-classes/unit/" + getTestDirectoryName() + "-test/plugin-config.xml"); + mojo = (RenderDependenciesMojo) lookupMojo(getTestDirectoryName(), testPom); + } + + /** + * Tests the rendering. + * Note that this is a real life example of using the mojo to generate a CRD for a SparkApplication. + * It is useful when combined with JIB for example since several versions of the CRD do not support wildcard for + * the classpath(s). + */ + public void testRender() throws Exception { + final File rendered = new File(testDir, "render-dependencies.testRender.txt"); + + setupProject(); + + mojo.setTemplate("deps:\n" + + " jars:\n" + + "#foreach($dep in $sorter.sort($artifacts, [\"artifactId:asc\"]))\n" + + "#set($type = $dep.type)\n" + + "#if(!$type || $type.trim().isEmpty())\n" + + " #set($type = \"jar\")\n" + + "#end\n" + + "#set($classifierSuffix = \"\")\n" + + "#if($dep.classifier && !$dep.classifier.trim().isEmpty())\n" + + " #set($classifierSuffix = \"-$dep.classifier\")\n" + + "#end\n" + + " - local:///opt/test/libs/$dep.artifactId-$dep.baseVersion$classifierSuffix.$type\n" + + "#end"); + mojo.setOutputFile(rendered); + mojo.execute(); + + assertThat(rendered) + .content() + .isEqualTo("deps:\n" + + " jars:\n" + + " - local:///opt/test/libs/compile-1.0.jar\n" + + " - local:///opt/test/libs/provided-1.0.jar\n" + + " - local:///opt/test/libs/release-1.0.jar\n" + + " - local:///opt/test/libs/runtime-1.0.jar\n" + + " - local:///opt/test/libs/snapshot-2.0-SNAPSHOT.jar\n" + + " - local:///opt/test/libs/system-1.0.jar\n" + + " - local:///opt/test/libs/test-1.0.jar\n"); + } + + /** + * Tests the rendering with a file template. + */ + public void testRenderFromFile() throws Exception { + final File rendered = new File(testDir, "render-dependencies.testRenderFromFile.txt"); + final File template = new File(testDir, "render-dependencies.testRenderFromFile.template.vm"); + Files.write( + template.toPath(), + ("deps:\n" + + " jars:\n" + + "#foreach($dep in $sorter.sort($artifacts, [\"artifactId:asc\"]))\n" + + "#set($type = $dep.type)\n" + + "#if(!$type || $type.trim().isEmpty())\n" + + " #set($type = \"jar\")\n" + + "#end\n" + + "#set($classifierSuffix = \"\")\n" + + "#if($dep.classifier && !$dep.classifier.trim().isEmpty())\n" + + " #set($classifierSuffix = \"-$dep.classifier\")\n" + + "#end\n" + + " - local:///opt/test/libs/$dep.artifactId-$dep.baseVersion$classifierSuffix.$type\n" + + "#end") + .getBytes(StandardCharsets.UTF_8)); + + setupProject(); + + mojo.setTemplate(template.getAbsolutePath()); + mojo.setOutputFile(rendered); + mojo.execute(); + + assertThat(rendered) + .content() + .isEqualTo("deps:\n" + + " jars:\n" + + " - local:///opt/test/libs/compile-1.0.jar\n" + + " - local:///opt/test/libs/provided-1.0.jar\n" + + " - local:///opt/test/libs/release-1.0.jar\n" + + " - local:///opt/test/libs/runtime-1.0.jar\n" + + " - local:///opt/test/libs/snapshot-2.0-SNAPSHOT.jar\n" + + " - local:///opt/test/libs/system-1.0.jar\n" + + " - local:///opt/test/libs/test-1.0.jar\n"); + } + + private void setupProject() throws IOException { + final MavenProject project = mojo.getProject(); + final Set artifacts = stubFactory.getScopedArtifacts(); + final Set directArtifacts = stubFactory.getReleaseAndSnapshotArtifacts(); + artifacts.addAll(directArtifacts); + project.setArtifacts(artifacts); + project.setDependencyArtifacts(directArtifacts); + } +} diff --git a/src/test/resources/unit/render-dependencies-test/plugin-config.xml b/src/test/resources/unit/render-dependencies-test/plugin-config.xml new file mode 100644 index 000000000..1a046caeb --- /dev/null +++ b/src/test/resources/unit/render-dependencies-test/plugin-config.xml @@ -0,0 +1,37 @@ + + + + + + maven-dependency-plugin + + + + + + + + org.apache.maven + maven-artifact + 2.0.4 + + + \ No newline at end of file