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