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.HashMap; 029import java.util.List; 030import java.util.Map; 031import java.util.Objects; 032import java.util.stream.Collectors; 033import java.util.stream.Stream; 034import org.apache.maven.model.DependencyManagement; 035import org.apache.maven.model.Model; 036import org.apache.maven.model.building.DefaultModelBuilderFactory; 037import org.apache.maven.model.building.DefaultModelBuildingRequest; 038import org.apache.maven.model.building.ModelBuildingException; 039import org.apache.maven.model.building.ModelBuildingRequest; 040import org.eclipse.aether.RepositorySystem; 041import org.eclipse.aether.RepositorySystemSession; 042import org.eclipse.aether.artifact.Artifact; 043import org.eclipse.aether.collection.CollectRequest; 044import org.eclipse.aether.graph.Dependency; 045import org.eclipse.aether.graph.DependencyNode; 046import org.eclipse.aether.repository.RemoteRepository; 047import org.eclipse.aether.resolution.ArtifactRequest; 048import org.eclipse.aether.resolution.ArtifactResolutionException; 049import org.eclipse.aether.resolution.DependencyRequest; 050import org.eclipse.aether.resolution.DependencyResolutionException; 051import org.eclipse.aether.util.artifact.SubArtifact; 052import org.eclipse.aether.util.graph.visitor.PreorderDependencyNodeConsumerVisitor; 053import org.eclipse.aether.util.version.GenericVersionScheme; 054import org.eclipse.aether.version.InvalidVersionSpecificationException; 055import org.eclipse.aether.version.Version; 056import org.jdrupes.builder.api.BuildException; 057import org.jdrupes.builder.api.Renamable; 058import org.jdrupes.builder.api.Resource; 059import org.jdrupes.builder.api.ResourceRequest; 060import org.jdrupes.builder.core.AbstractProvider; 061import static org.jdrupes.builder.mvnrepo.MvnRepoTypes.*; 062 063/// Depending on the request, this provider provides two types of resources. 064/// 065/// 1. The artifacts to be resolved as resources of type [MvnRepoDependency]. 066/// The artifacts to be resolved are those added with [resolve]. 067/// Note that the result also includes the [MvnRepoBom]s added with 068/// [bom]. 069/// 070/// 2. The resources of type [MvnRepoLibraryJarFile] that result from 071/// resolving the artifacts to be resolved. 072/// 073/// The repositories used are those added with [addRepositories]. If 074/// no repositories are configured, the Maven Central repository 075/// is added automatically. 076/// 077/// Resolving is performed using Maven Resolver (formerly Eclipse Aether) 078/// version 2.x. Dependencies are collected from the specified artifacts after 079/// evaluating their effective Maven models, including any imported 080/// BOMs. Version conflicts are resolved using a "highest wins" 081/// strategy, i.e. the highest version of a dependency encountered in the 082/// dependency graph is selected. Note that this differs from Maven's 083/// default behavior which is "nearest wins". 084/// 085/// Results of the dependency resolution are written to the log with 086/// log level FINE. 087/// 088@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.GodClass" }) 089public class MvnRepoLookup extends AbstractProvider implements Renamable { 090 091 private static final FluentLogger logger = FluentLogger.forEnclosingClass(); 092 private final List<RemoteRepository> addedRepos = new ArrayList<>(); 093 private final List<String> coordinates = new ArrayList<>(); 094 private final List<String> boms = new ArrayList<>(); 095 private boolean downloadSources = true; 096 private boolean downloadJavadoc = true; 097 private boolean probeMode; 098 099 /// Initializes a new Maven repository lookup. 100 /// 101 public MvnRepoLookup() { 102 // Make javadoc happy. 103 } 104 105 @Override 106 public MvnRepoLookup name(String name) { 107 rename(name); 108 return this; 109 } 110 111 /// Add repositories to be used for the lookup. 112 /// 113 /// @param repositories the repositories 114 /// @return the mvn repo lookup 115 /// 116 public MvnRepoLookup addRepositories(RemoteRepository... repositories) { 117 for (var repo : Objects.requireNonNull(repositories)) { 118 Objects.requireNonNull(repo); 119 } 120 addedRepos.addAll(Arrays.asList(repositories)); 121 return this; 122 123 } 124 125 /// Uses [MavenContext#createRepository] to create a repository 126 /// and adds it to the lookup. 127 /// 128 /// @param id the repository id 129 /// @param uri the repository uri 130 /// @param supported the supported version types 131 /// @return the mvn repo lookup 132 /// 133 public MvnRepoLookup addRepository( 134 String id, URI uri, MvnVersionType... supported) { 135 addedRepos.add(MavenContext.createRepository(id, uri, supported)); 136 return this; 137 } 138 139 /// Add a bill of materials. The coordinates are resolved as 140 /// a dependency with scope `import` which is added to the 141 /// `dependencyManagement` section when evaluating the effective 142 /// model. 143 /// 144 /// @param coordinates the coordinates in the form 145 /// groupId:artifactId:version 146 /// @return the mvn repo lookup 147 /// 148 public MvnRepoLookup bom(String... coordinates) { 149 boms.addAll(Arrays.asList(coordinates)); 150 return this; 151 } 152 153 /// Add artifacts, specified by their coordinates 154 /// (`groupId:artifactId:version`) as resources. 155 /// 156 /// @param coordinates the coordinates in the form 157 /// groupId:artifactId:version 158 /// @return the mvn repo lookup 159 /// 160 public MvnRepoLookup resolve(String... coordinates) { 161 this.coordinates.addAll(Arrays.asList(coordinates)); 162 return this; 163 } 164 165 /// Add artifacts. The method handles [MvnRepoBom]s correctly. 166 /// 167 /// @param resources the resources 168 /// @return the mvn repo lookup 169 /// 170 public MvnRepoLookup resolve(Stream<? extends MvnRepoResource> resources) { 171 resources.forEach(r -> { 172 if (r instanceof MvnRepoBom) { 173 bom(r.coordinates()); 174 } else { 175 resolve(r.coordinates()); 176 } 177 }); 178 return this; 179 } 180 181 /// Failing to resolve the dependencies normally results in a 182 /// [BuildException], because the requested artifacts are assumed 183 /// to be required for the build. 184 /// 185 /// By invoking this method the provider enters probe mode 186 /// and returns an empty result stream instead of throwing an 187 /// exception if the resolution fails. 188 /// 189 /// @return the mvn repo lookup 190 /// 191 public MvnRepoLookup probe() { 192 probeMode = true; 193 logger.atFine().log("Probe mode enabled for %s", this); 194 return this; 195 } 196 197 /// Whether to also download the sources. Defaults to `true`. 198 /// 199 /// @param enable the enable 200 /// @return the mvn repo lookup 201 /// 202 public MvnRepoLookup downloadSources(boolean enable) { 203 this.downloadSources = enable; 204 return this; 205 } 206 207 /// Whether to also download the javadoc. Defaults to `true`. 208 /// 209 /// @param enable the enable 210 /// @return the mvn repo lookup 211 /// 212 public MvnRepoLookup downloadJavadoc(boolean enable) { 213 this.downloadJavadoc = enable; 214 return this; 215 } 216 217 @Override 218 protected <T extends Resource> Collection<T> 219 doProvide(ResourceRequest<T> request) { 220 // Check if provided and evaluate for most special type 221 if (!request.accepts(MvnRepoLibraryJarFileType, 222 MvnRepoDependencyType)) { 223 return Collections.emptyList(); 224 } 225 if (request.accepts(MvnRepoDependencyType) 226 && !request.isFor(MvnRepoDependencyType)) { 227 @SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" }) 228 var result = (Collection<T>) context() 229 .resources(this, of(MvnRepoDependencyType)).toList(); 230 return result; 231 } 232 if (request.accepts(MvnRepoLibraryJarFileType) 233 && !request.isFor(MvnRepoLibraryJarFileType)) { 234 @SuppressWarnings({ "unchecked" }) 235 var result = (Collection<T>) context() 236 .resources(this, of(MvnRepoLibraryJarFileType)).toList(); 237 return result; 238 } 239 240 // Handle request for maven dependencies 241 if (request.accepts(MvnRepoDependencyType)) { 242 return provideMvnDeps(); 243 } 244 245 // Handle request for libraries 246 try { 247 return provideJars(); 248 } catch (ModelBuildingException e) { 249 throw new BuildException().from(this).cause(e); 250 } catch (DependencyResolutionException e) { 251 if (probeMode) { 252 return Collections.emptyList(); 253 } 254 logger.atSevere().withCause(e).log("%s failed to resolve", this); 255 Throwable cause = e; 256 while (cause.getCause() != null) { 257 cause = cause.getCause(); 258 } 259 throw new BuildException().from(this).cause(cause); 260 } 261 262 } 263 264 private <T extends Resource> Collection<T> provideMvnDeps() { 265 @SuppressWarnings("unchecked") 266 var boms = (Stream<T>) this.boms.stream() 267 .map(MvnRepoBom::of); 268 @SuppressWarnings("unchecked") 269 var deps = (Stream<T>) coordinates.stream() 270 .map(MvnRepoDependency::of); 271 return Stream.concat(boms, deps).toList(); 272 } 273 274 @SuppressWarnings("PMD.AvoidSynchronizedStatement") 275 private <T extends Resource> Collection<T> provideJars() 276 throws DependencyResolutionException, ModelBuildingException { 277 @SuppressWarnings("PMD.CloseResource") 278 var repoSystem = MavenContext.repositorySystem(); 279 var repoSession = MavenContext.repositorySession(); 280 281 // Create one CollectRequest 282 var repos = new ArrayList<>(addedRepos); 283 if (repos.isEmpty()) { 284 repos.add(MavenContext.mavenCentral()); 285 } 286 287 // Collect all dependencies via their effective models 288 List<Dependency> allDeps = new ArrayList<>(); 289 coordinates.stream().parallel().map(c -> depsFromEffectiveModel( 290 c, repoSystem, repoSession, repos)).forEach(deps -> { 291 // allDeps is not thread safe 292 synchronized (allDeps) { 293 deps.forEach(allDeps::add); 294 } 295 }); 296 297 // Deduplicate: keep highest version for each 298 // groupId:artifactId:classifier:type:scope. 299 // The resolver does not apply the highest wins strategy to the 300 // list of dependencies passed to it, only to those pulled in by 301 // those dependencies. 302 List<Dependency> resolvedDeps = deduplicateByHighestVersion(allDeps); 303 304 // Populate collect request 305 CollectRequest collectRequest 306 = new CollectRequest().setRepositories(repos); 307 resolvedDeps.forEach(collectRequest::addDependency); 308 309 // Resolve dependencies - Resolver performs mediation 310 logger.atFine().log("%s resolving dependencies: %s", name(), 311 lazy(() -> collectRequest.getDependencies().stream() 312 .map(Dependency::toString).collect(Collectors.joining(", ")))); 313 DependencyRequest dependencyRequest 314 = new DependencyRequest(collectRequest, null); 315 DependencyNode rootNode = repoSystem.resolveDependencies(repoSession, 316 dependencyRequest).getRoot(); 317 logger.atFine().log("Dependency tree from %s:\n%s", name(), 318 lazy(() -> buildTreeString(rootNode, 0, "", true))); 319 List<DependencyNode> dependencyNodes = new ArrayList<>(); 320 rootNode.accept(new PreorderDependencyNodeConsumerVisitor( 321 dependencyNodes::add)); 322 @SuppressWarnings("unchecked") 323 var result = (Collection<T>) dependencyNodes.stream() 324 .filter(d -> d.getArtifact() != null) 325 .map(d -> { 326 var artifact = extraDownloads( 327 repoSystem, repoSession, repos, d.getArtifact()); 328 return MvnRepoLibraryJarFile.of(d.getRepositories(), 329 artifact.toString(), artifact.getPath()); 330 }).toList(); 331 return result; 332 } 333 334 @SuppressWarnings("PMD.CognitiveComplexity") 335 private List<Dependency> deduplicateByHighestVersion( 336 List<Dependency> dependencies) { 337 var versionScheme = new GenericVersionScheme(); 338 339 // Group by all properties except version 340 @SuppressWarnings("PMD.UseConcurrentHashMap") 341 Map<String, Dependency> bestByGroup = new HashMap<>(); 342 for (Dependency dep : dependencies) { 343 var artifact = dep.getArtifact(); 344 String key = artifact.getGroupId() + ':' + artifact.getArtifactId() 345 + ':' + (artifact.getClassifier() != null 346 ? artifact.getClassifier() 347 : "") 348 + ':' + (artifact.getExtension() != null 349 ? artifact.getExtension() 350 : "jar") 351 + ':' + dep.getScope(); 352 353 Dependency existing = bestByGroup.get(key); 354 if (existing == null) { 355 bestByGroup.put(key, dep); 356 } else { 357 // Compare versions, keep highest 358 try { 359 Version existingVersion = versionScheme.parseVersion( 360 existing.getArtifact().getVersion()); 361 Version candidateVersion = versionScheme.parseVersion( 362 artifact.getVersion()); 363 if (candidateVersion.compareTo(existingVersion) > 0) { 364 bestByGroup.put(key, dep); 365 } 366 } catch (InvalidVersionSpecificationException e) { 367 // Fall back to string comparison 368 String existingVersion 369 = existing.getArtifact().getVersion(); 370 String candidateVersion = artifact.getVersion(); 371 if (candidateVersion.compareTo(existingVersion) > 0) { 372 bestByGroup.put(key, dep); 373 } 374 } 375 } 376 } 377 return new ArrayList<>(bestByGroup.values()); 378 } 379 380 private Stream<Dependency> depsFromEffectiveModel( 381 String coordinates, RepositorySystem repoSystem, 382 RepositorySystemSession repoSession, 383 List<RemoteRepository> repos) { 384 // First build raw model 385 Model model = new Model(); 386 model.setModelVersion("4.0.0"); 387 model.setGroupId("model.group"); 388 model.setArtifactId("model.artifact"); 389 model.setVersion("0.0.0"); 390 model.setDescription(name()); 391 var depMgmt = new DependencyManagement(); 392 model.setDependencyManagement(depMgmt); 393 394 // Build raw model from boms and coordinate 395 for (String bom : boms) { 396 var dep = DependencyConverter 397 .convert(MvnRepoDependency.of(bom), "import"); 398 dep.setType("pom"); 399 depMgmt.addDependency(dep); 400 } 401 model.addDependency(DependencyConverter.convert( 402 MvnRepoDependency.of(coordinates), "compile")); 403 404 // Now build (derive) effective model and add its dependencies 405 var buildingRequest = new DefaultModelBuildingRequest() 406 .setRawModel(model).setProcessPlugins(false) 407 .setValidationLevel(ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL) 408 .setModelResolver( 409 new MvnModelResolver(repoSystem, repoSession, repos)); 410 try { 411 var effectiveModel = new DefaultModelBuilderFactory() 412 .newInstance().build(buildingRequest).getEffectiveModel(); 413 return effectiveModel.getDependencies().stream() 414 .map(DependencyConverter::convert); 415 } catch (ModelBuildingException e) { 416 throw new BuildException().from(this).cause(e); 417 } 418 } 419 420 private Artifact extraDownloads( 421 RepositorySystem repoSystem, RepositorySystemSession repoSession, 422 List<RemoteRepository> repos, Artifact artifact) { 423 if (downloadSources) { 424 downloadSourceJar(repoSystem, repoSession, repos, artifact); 425 } 426 if (downloadJavadoc) { 427 downloadJavadocJar(repoSystem, repoSession, repos, artifact); 428 } 429 return artifact; 430 } 431 432 private void downloadSourceJar(RepositorySystem repoSystem, 433 RepositorySystemSession repoSession, 434 List<RemoteRepository> repos, Artifact jarArtifact) { 435 Artifact sourcesArtifact 436 = new SubArtifact(jarArtifact, "sources", "jar"); 437 ArtifactRequest sourcesRequest = new ArtifactRequest(); 438 sourcesRequest.setArtifact(sourcesArtifact); 439 sourcesRequest.setRepositories(repos); 440 try { 441 repoSystem.resolveArtifact(repoSession, sourcesRequest); 442 } catch (ArtifactResolutionException e) { // NOPMD 443 // Ignore, sources are optional 444 } 445 } 446 447 private void downloadJavadocJar(RepositorySystem repoSystem, 448 RepositorySystemSession repoSession, 449 List<RemoteRepository> repos, Artifact jarArtifact) { 450 Artifact javadocArtifact 451 = new SubArtifact(jarArtifact, "javadoc", "jar"); 452 ArtifactRequest sourcesRequest = new ArtifactRequest(); 453 sourcesRequest.setArtifact(javadocArtifact); 454 sourcesRequest.setRepositories(repos); 455 try { 456 repoSystem.resolveArtifact(repoSession, sourcesRequest); 457 } catch (ArtifactResolutionException e) { // NOPMD 458 // Ignore, javadoc is optional 459 } 460 } 461 462 private String buildTreeString(DependencyNode node, int indent, 463 String prefix, boolean isLast) { 464 @SuppressWarnings("PMD.ShortVariable") 465 StringBuilder sb = new StringBuilder(); 466 var artifact = node.getArtifact(); 467 468 if (indent == 0) { 469 sb.append("root\n"); 470 } else { 471 sb.append(prefix).append(isLast ? "`-- " : "|-- ") 472 .append(artifact != null ? artifact.toString() : "node") 473 .append('\n'); 474 } 475 476 var children = node.getChildren(); 477 String childPrefix 478 = prefix + (indent == 0 ? " " : isLast ? " " : "| "); 479 480 for (int i = 0; i < children.size(); i++) { 481 sb.append(buildTreeString(children.get(i), indent + 1, childPrefix, 482 i == children.size() - 1)); 483 } 484 return sb.toString(); 485 } 486}