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