001/* 002 * JDrupes Builder 003 * Copyright (C) 2025, 2026 Michael N. Lipp 004 * 005 * This program is free software: you can redistribute it and/or modify 006 * it under the terms of the GNU Affero General Public License as 007 * published by the Free Software Foundation, either version 3 of the 008 * License, or (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, 011 * but WITHOUT ANY WARRANTY; without even the implied warranty of 012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 013 * GNU Affero General Public License for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License 016 * along with this program. If not, see <https://www.gnu.org/licenses/>. 017 */ 018 019package org.jdrupes.builder.mvnrepo; 020 021import com.google.common.flogger.FluentLogger; 022import static com.google.common.flogger.LazyArgs.lazy; 023import java.net.URI; 024import java.util.ArrayList; 025import java.util.Arrays; 026import java.util.Collection; 027import java.util.Collections; 028import java.util.EnumSet; 029import java.util.List; 030import java.util.stream.Collectors; 031import java.util.stream.Stream; 032import org.apache.maven.model.DependencyManagement; 033import org.apache.maven.model.Model; 034import org.apache.maven.model.building.DefaultModelBuilderFactory; 035import org.apache.maven.model.building.DefaultModelBuildingRequest; 036import org.apache.maven.model.building.ModelBuildingException; 037import org.apache.maven.model.building.ModelBuildingRequest; 038import org.eclipse.aether.RepositorySystem; 039import org.eclipse.aether.RepositorySystemSession; 040import org.eclipse.aether.artifact.Artifact; 041import org.eclipse.aether.collection.CollectRequest; 042import org.eclipse.aether.graph.Dependency; 043import org.eclipse.aether.graph.DependencyNode; 044import org.eclipse.aether.repository.RemoteRepository; 045import org.eclipse.aether.resolution.ArtifactRequest; 046import org.eclipse.aether.resolution.ArtifactResolutionException; 047import org.eclipse.aether.resolution.DependencyRequest; 048import org.eclipse.aether.resolution.DependencyResolutionException; 049import org.eclipse.aether.util.artifact.SubArtifact; 050import org.eclipse.aether.util.graph.visitor.PreorderDependencyNodeConsumerVisitor; 051import org.jdrupes.builder.api.BuildException; 052import org.jdrupes.builder.api.Resource; 053import org.jdrupes.builder.api.ResourceFactory; 054import org.jdrupes.builder.api.ResourceRequest; 055import org.jdrupes.builder.core.AbstractProvider; 056import static org.jdrupes.builder.mvnrepo.MvnRepoTypes.*; 057 058/// Depending on the request, this provider provides two types of resources. 059/// 060/// 1. The artifacts to be resolved as resources of type [MvnRepoDependency]. 061/// The artifacts to be resolved are those added with [resolve]. 062/// Note that the result also includes the [MvnRepoBom]s added with 063/// [bom]. 064/// 065/// 2. The resources of type [MvnRepoLibraryJarFile] that result from 066/// resolving the artifacts to be resolved. 067/// 068/// The repositories used are those configured for all instances of this 069/// provider by [MavenContext] and the repositories added with 070/// [addRepository]. Should there be no repositories configured, the 071/// Maven Central repository will be added automatically. 072/// 073/// Resolving is performed using Maven Resolver (formerly Eclipse Aether) 074/// version 2.x. Dependencies are collected from the specified artifacts after 075/// evaluating their effective Maven models, including any imported 076/// BOMs. Version conflicts are resolved using a "highest wins" 077/// strategy, i.e. the highest version of a dependency encountered in the 078/// dependency graph is selected. Note that this differs from Maven's 079/// default behavior which is "nearest wins". 080/// 081/// Results of the dependency resolution are written to the log with 082/// log level FINE. 083/// 084@SuppressWarnings("PMD.CouplingBetweenObjects") 085public class MvnRepoLookup extends AbstractProvider { 086 087 private static final FluentLogger logger = FluentLogger.forEnclosingClass(); 088 private final List<RemoteRepository> addedRepos = new ArrayList<>(); 089 @SuppressWarnings("PMD.AvoidUsingVolatile") 090 private volatile List<RemoteRepository> mergedRepos; 091 private final List<String> coordinates = new ArrayList<>(); 092 private final List<String> boms = new ArrayList<>(); 093 private boolean downloadSources = true; 094 private boolean downloadJavadoc = true; 095 private boolean probeMode; 096 097 /// Initializes a new Maven repository lookup. 098 /// 099 public MvnRepoLookup() { 100 // Make javadoc happy. 101 } 102 103 /// Add a repository that is to be used for the lookup. 104 /// 105 /// @param repository the repository 106 /// @return the mvn repo lookup 107 /// 108 public MvnRepoLookup addRepository(RemoteRepository repository) { 109 addedRepos.add(repository); 110 return this; 111 112 } 113 114 /// Add a repository that is to be used for the lookup. 115 /// 116 /// @param id the repository id 117 /// @param uri the repository uri 118 /// @param supported the supported version types 119 /// @return the mvn repo lookup 120 /// 121 public MvnRepoLookup addRepository( 122 String id, URI uri, MvnVersionType... supported) { 123 var types = EnumSet.copyOf(Arrays.asList(supported)); 124 var builder = new RemoteRepository.Builder( 125 id, "default", uri.toString()) 126 .setReleasePolicy( 127 MavenContext.createPolicy(MvnVersionType.RELEASE, 128 types.contains(MvnVersionType.RELEASE), null, null)) 129 .setSnapshotPolicy( 130 MavenContext.createPolicy(MvnVersionType.SNAPSHOT, 131 types.contains(MvnVersionType.SNAPSHOT), null, null)); 132 addedRepos.add(builder.build()); 133 return this; 134 } 135 136 @SuppressWarnings({ "PMD.AvoidSynchronizedStatement" }) 137 private List<RemoteRepository> remoteRepositories() { 138 if (mergedRepos != null) { 139 return mergedRepos; 140 } 141 synchronized (this) { 142 if (mergedRepos != null) { 143 return mergedRepos; 144 } 145 List<RemoteRepository> repos 146 = new ArrayList<>(MavenContext.remoteRepositories()); 147 repos.addAll(addedRepos); 148 if (repos.isEmpty()) { 149 repos.add(MavenContext.mavenCentral()); 150 } 151 mergedRepos = repos; 152 return mergedRepos; 153 } 154 } 155 156 /// Add a bill of materials. The coordinates are resolved as 157 /// a dependency with scope `import` which is added to the 158 /// `dependencyManagement` section when evaluating the effective 159 /// model. 160 /// 161 /// @param coordinates the coordinates in the form 162 /// groupId:artifactId:version 163 /// @return the mvn repo lookup 164 /// 165 public MvnRepoLookup bom(String... coordinates) { 166 boms.addAll(Arrays.asList(coordinates)); 167 return this; 168 } 169 170 /// Add artifacts, specified by their coordinates 171 /// (`groupId:artifactId:version`) as resources. 172 /// 173 /// @param coordinates the coordinates in the form 174 /// groupId:artifactId:version 175 /// @return the mvn repo lookup 176 /// 177 public MvnRepoLookup resolve(String... coordinates) { 178 this.coordinates.addAll(Arrays.asList(coordinates)); 179 return this; 180 } 181 182 /// Add artifacts. The method handles [MvnRepoBom]s correctly. 183 /// 184 /// @param resources the resources 185 /// @return the mvn repo lookup 186 /// 187 public MvnRepoLookup resolve(Stream<? extends MvnRepoResource> resources) { 188 resources.forEach(r -> { 189 if (r instanceof MvnRepoBom) { 190 bom(r.coordinates()); 191 } else { 192 resolve(r.coordinates()); 193 } 194 }); 195 return this; 196 } 197 198 /// Failing to resolve the dependencies normally results in a 199 /// [BuildException], because the requested artifacts are assumed 200 /// to be required for the build. 201 /// 202 /// By invoking this method the provider enters probe mode 203 /// and returns an empty result stream instead of throwing an 204 /// exception if the resolution fails. 205 /// 206 /// @return the mvn repo lookup 207 /// 208 public MvnRepoLookup probe() { 209 probeMode = true; 210 logger.atFine().log("Probe mode enabled for %s", this); 211 return this; 212 } 213 214 /// Whether to also download the sources. Defaults to `true`. 215 /// 216 /// @param enable the enable 217 /// @return the mvn repo lookup 218 /// 219 public MvnRepoLookup downloadSources(boolean enable) { 220 this.downloadSources = enable; 221 return this; 222 } 223 224 /// Whether to also download the javadoc. Defaults to `true`. 225 /// 226 /// @param enable the enable 227 /// @return the mvn repo lookup 228 /// 229 public MvnRepoLookup downloadJavadoc(boolean enable) { 230 this.downloadJavadoc = enable; 231 return this; 232 } 233 234 /// Provide. 235 /// 236 /// @param <T> the generic type 237 /// @param request the requested resources 238 /// @return the stream 239 /// 240 @Override 241 protected <T extends Resource> Collection<T> 242 doProvide(ResourceRequest<T> request) { 243 if (request.accepts(MvnRepoDependencyType)) { 244 return provideMvnDeps(); 245 } 246 if (!request.accepts(MvnRepoLibraryJarFileType)) { 247 return Collections.emptyList(); 248 } 249 if (!request.isFor(MvnRepoLibraryJarFileType)) { 250 @SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" }) 251 var result = (Collection<T>) context() 252 .resources(this, of(MvnRepoLibraryJarFileType)).toList(); 253 return result; 254 } 255 try { 256 return provideJars(); 257 } catch (ModelBuildingException e) { 258 throw new BuildException().from(this).cause(e); 259 } catch (DependencyResolutionException e) { 260 if (probeMode) { 261 return Collections.emptyList(); 262 } 263 Throwable cause = e; 264 while (cause.getCause() != null) { 265 cause = cause.getCause(); 266 } 267 throw new BuildException().from(this).cause(cause); 268 } 269 270 } 271 272 private <T extends Resource> Collection<T> provideMvnDeps() { 273 @SuppressWarnings("unchecked") 274 var boms = (Stream<T>) this.boms.stream() 275 .map(MvnRepoBom::of); 276 @SuppressWarnings("unchecked") 277 var deps = (Stream<T>) coordinates.stream() 278 .map(MvnRepoDependency::of); 279 return Stream.concat(boms, deps).toList(); 280 } 281 282 @SuppressWarnings("PMD.AvoidSynchronizedStatement") 283 private <T extends Resource> Collection<T> provideJars() 284 throws DependencyResolutionException, ModelBuildingException { 285 @SuppressWarnings("PMD.CloseResource") 286 var repoSystem = MavenContext.repositorySystem(); 287 var repoSession = MavenContext.repositorySession(); 288 var remoteRepositories = remoteRepositories(); 289 290 // Create one synthetic CollectRequest 291 CollectRequest collectRequest 292 = new CollectRequest().setRepositories(remoteRepositories); 293 294 // Add dependencies via their effective model 295 coordinates.stream().parallel().map(c -> depsFromEffectiveModel( 296 c, repoSystem, repoSession, remoteRepositories)).forEach(deps -> { 297 // collectRequest::addDependency is not thread safe 298 synchronized (collectRequest) { 299 deps.forEach(collectRequest::addDependency); 300 } 301 }); 302 303 // Resolve dependencies - Resolver performs mediation 304 logger.atFine().log("Resolving dependencies: %s", 305 lazy(() -> collectRequest.getDependencies().stream() 306 .map(Dependency::toString).collect(Collectors.joining(", ")))); 307 DependencyRequest dependencyRequest 308 = new DependencyRequest(collectRequest, null); 309 DependencyNode rootNode = repoSystem.resolveDependencies(repoSession, 310 dependencyRequest).getRoot(); 311 logger.atFine().log("Dependency tree for %s:\n%s", name(), 312 lazy(() -> buildTreeString(rootNode, 0, "", true))); 313 List<DependencyNode> dependencyNodes = new ArrayList<>(); 314 rootNode.accept(new PreorderDependencyNodeConsumerVisitor( 315 dependencyNodes::add)); 316 @SuppressWarnings("unchecked") 317 var result = (Collection<T>) dependencyNodes.stream() 318 .filter(d -> d.getArtifact() != null) 319 .map(DependencyNode::getArtifact) 320 .map(a -> extraDownloads(repoSystem, repoSession, 321 remoteRepositories, a)) 322 .map(a -> ResourceFactory.create(MvnRepoLibraryJarFileType, 323 a.toString(), a.getPath())) 324 .toList(); 325 return result; 326 } 327 328 private Stream<Dependency> depsFromEffectiveModel( 329 String coordinates, RepositorySystem repoSystem, 330 RepositorySystemSession repoSession, 331 List<RemoteRepository> repos) { 332 // First build raw model 333 Model model = new Model(); 334 model.setModelVersion("4.0.0"); 335 model.setGroupId("model.group"); 336 model.setArtifactId("model.artifact"); 337 model.setVersion("0.0.0"); 338 model.setDescription(name()); 339 var depMgmt = new DependencyManagement(); 340 model.setDependencyManagement(depMgmt); 341 342 // Build raw model from boms and coordinate 343 for (String bom : boms) { 344 var dep = DependencyConverter 345 .convert(MvnRepoDependency.of(bom), "import"); 346 dep.setType("pom"); 347 depMgmt.addDependency(dep); 348 } 349 model.addDependency(DependencyConverter.convert( 350 MvnRepoDependency.of(coordinates), "compile")); 351 352 // Now build (derive) effective model and add its dependencies 353 var buildingRequest = new DefaultModelBuildingRequest() 354 .setRawModel(model).setProcessPlugins(false) 355 .setValidationLevel(ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL) 356 .setModelResolver( 357 new MvnModelResolver(repoSystem, repoSession, repos)); 358 try { 359 var effectiveModel = new DefaultModelBuilderFactory() 360 .newInstance().build(buildingRequest).getEffectiveModel(); 361 return effectiveModel.getDependencies().stream() 362 .map(DependencyConverter::convert); 363 } catch (ModelBuildingException e) { 364 throw new BuildException().from(this).cause(e); 365 } 366 } 367 368 private Artifact extraDownloads( 369 RepositorySystem repoSystem, RepositorySystemSession repoSession, 370 List<RemoteRepository> repos, Artifact artifact) { 371 if (downloadSources) { 372 downloadSourceJar(repoSystem, repoSession, repos, artifact); 373 } 374 if (downloadJavadoc) { 375 downloadJavadocJar(repoSystem, repoSession, repos, artifact); 376 } 377 return artifact; 378 } 379 380 private void downloadSourceJar(RepositorySystem repoSystem, 381 RepositorySystemSession repoSession, 382 List<RemoteRepository> repos, Artifact jarArtifact) { 383 Artifact sourcesArtifact 384 = new SubArtifact(jarArtifact, "sources", "jar"); 385 ArtifactRequest sourcesRequest = new ArtifactRequest(); 386 sourcesRequest.setArtifact(sourcesArtifact); 387 sourcesRequest.setRepositories(repos); 388 try { 389 repoSystem.resolveArtifact(repoSession, sourcesRequest); 390 } catch (ArtifactResolutionException e) { // NOPMD 391 // Ignore, sources are optional 392 } 393 } 394 395 private void downloadJavadocJar(RepositorySystem repoSystem, 396 RepositorySystemSession repoSession, 397 List<RemoteRepository> repos, Artifact jarArtifact) { 398 Artifact javadocArtifact 399 = new SubArtifact(jarArtifact, "javadoc", "jar"); 400 ArtifactRequest sourcesRequest = new ArtifactRequest(); 401 sourcesRequest.setArtifact(javadocArtifact); 402 sourcesRequest.setRepositories(repos); 403 try { 404 repoSystem.resolveArtifact(repoSession, sourcesRequest); 405 } catch (ArtifactResolutionException e) { // NOPMD 406 // Ignore, javadoc is optional 407 } 408 } 409 410 private String buildTreeString(DependencyNode node, int indent, 411 String prefix, boolean isLast) { 412 @SuppressWarnings("PMD.ShortVariable") 413 StringBuilder sb = new StringBuilder(); 414 var artifact = node.getArtifact(); 415 416 if (indent == 0) { 417 sb.append("root\n"); 418 } else { 419 sb.append(prefix).append(isLast ? "`-- " : "|-- ") 420 .append(artifact != null ? artifact.toString() : "node") 421 .append('\n'); 422 } 423 424 var children = node.getChildren(); 425 String childPrefix 426 = prefix + (indent == 0 ? " " : isLast ? " " : "| "); 427 428 for (int i = 0; i < children.size(); i++) { 429 sb.append(buildTreeString(children.get(i), indent + 1, childPrefix, 430 i == children.size() - 1)); 431 } 432 return sb.toString(); 433 } 434}