001/* 002 * JDrupes Builder 003 * Copyright (C) 2025 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.uberjar; 020 021import com.google.common.flogger.FluentLogger; 022import io.github.azagniotov.matcher.AntPathMatcher; 023import java.io.IOException; 024import java.nio.file.Path; 025import java.util.ArrayList; 026import java.util.Arrays; 027import java.util.Collection; 028import java.util.Collections; 029import java.util.List; 030import java.util.Map; 031import java.util.Objects; 032import java.util.concurrent.ConcurrentHashMap; 033import java.util.function.Predicate; 034import java.util.jar.JarEntry; 035import org.jdrupes.builder.api.BuildException; 036import org.jdrupes.builder.api.ConfigurationException; 037import org.jdrupes.builder.api.FileTree; 038import org.jdrupes.builder.api.Generator; 039import org.jdrupes.builder.api.IOResource; 040import org.jdrupes.builder.api.Intent; 041import static org.jdrupes.builder.api.Intent.*; 042import org.jdrupes.builder.api.Project; 043import org.jdrupes.builder.api.Resource; 044import org.jdrupes.builder.api.ResourceRequest; 045import org.jdrupes.builder.api.ResourceType; 046import static org.jdrupes.builder.api.ResourceType.*; 047import org.jdrupes.builder.api.Resources; 048import org.jdrupes.builder.java.AppJarFile; 049import org.jdrupes.builder.java.ClassTree; 050import org.jdrupes.builder.java.ClasspathElement; 051import org.jdrupes.builder.java.JarFile; 052import org.jdrupes.builder.java.JarFileEntry; 053import org.jdrupes.builder.java.JavaResourceTree; 054import static org.jdrupes.builder.java.JavaTypes.*; 055import org.jdrupes.builder.java.LibraryBuilder; 056import org.jdrupes.builder.java.LibraryJarFile; 057import org.jdrupes.builder.java.ServicesEntryResource; 058import org.jdrupes.builder.mvnrepo.MvnRepoJarFile; 059import org.jdrupes.builder.mvnrepo.MvnRepoLookup; 060import org.jdrupes.builder.mvnrepo.MvnRepoResource; 061import static org.jdrupes.builder.mvnrepo.MvnRepoTypes.*; 062 063/// A [Generator] for uber jars. 064/// 065/// Depending on the request, the generator provides two types of resources. 066/// 067/// 1. A [JarFile]. This type of resource is also returned if a more 068/// general [ResourceType] such as [ClasspathElement] is requested. 069/// 070/// 2. An [AppJarFile]. When requesting this special jar type, the 071/// generator checks if a main class is specified. 072/// 073/// The generator takes the following approach: 074/// 075/// * Request `Resources<ClasspathElement>` from the providers. Add the 076/// resource trees and the jar files to the sources to be processed. 077/// Ignore jar files from maven repositories (instances of 078/// [MvnRepoJarFile]). 079/// * Request all [MvnRepoResource]s from the providers and use them for 080/// a dependency resolution. Add the jar files from the dependency 081/// resolution to the resources to be processed. 082/// * Add resources from the sources to the uber jar. Merge the files in 083/// `META-INF/services/` that have the same name by concatenating them. 084/// * Filter out any other duplicate direct child files of `META-INF`. 085/// These files often contain information related to the origin jar 086/// that is not applicable to the uber jar. 087/// * Filter out any module-info.class entries. 088/// 089/// Note that the [UberJarBuilder] does deliberately not request the 090/// [ClasspathElement]s as `RuntimeResources` because this may return 091/// resources twice if a project uses another project as runtime 092/// dependency (i.e. with [Intent#Consume]. If this rule causes entries 093/// to be missing, simply add them explicitly. 094/// 095/// The resource type of the uber jar generator's output is one 096/// of the resource types of its inputs, because uber jars can also be used 097/// as [ClasspathElement]. Therefore, if you want to create an uber jar 098/// from all resources provided by a project, you must not add the 099/// generator to the project like this: 100/// ```java 101/// generator(UberJarGenerator::new).add(this); // Circular dependency 102/// ``` 103/// 104/// This would add the project as provider and thus make the uber jar 105/// generator as supplier to the project its own provider (via 106/// [Project.resources][Project#resources]). Rather, you have to use this 107/// slightly more complicated approach to adding providers to the uber 108/// jar generator: 109/// ```java 110/// generator(UberJarGenerator::new) 111/// .addAll(providers().select(Forward, Expose, Supply)); 112/// ``` 113/// This requests the same providers from the project as 114/// [Project.resources][Project#resources] does, but allows the uber jar 115/// generator's [addFrom] method to filter out the uber jar 116/// generator itself from the providers. The given intents can 117/// vary depending on the requirements. 118/// 119/// If you don't want the generated uber jar to be available to other 120/// generators of your project, you can also add it to a project like this: 121/// ```java 122/// dependency(new UberJarGenerator(this) 123/// .from(providers(EnumSet.of(Forward, Expose, Supply))), Intent.Forward) 124/// ``` 125/// 126/// Of course, the easiest thing to do is separate the generation of 127/// class trees or library jars from the generation of the uber jar by 128/// generating the uber jar in a project of its own. Often the root 129/// project can be used for this purpose. 130/// 131public class UberJarBuilder extends LibraryBuilder { 132 133 private static final FluentLogger logger = FluentLogger.forEnclosingClass(); 134 @SuppressWarnings("PMD.FieldNamingConventions") 135 private static final AntPathMatcher pathMatcher 136 = new AntPathMatcher.Builder().build(); 137 private Map<Path, java.util.jar.JarFile> openJars = Map.of(); 138 private Predicate<Resource> resourceFilter = _ -> true; 139 private final List<String> ignoredDuplicates = new ArrayList<>(); 140 141 /// Instantiates a new uber jar generator. 142 /// 143 /// @param project the project 144 /// 145 public UberJarBuilder(Project project) { 146 super(Objects.requireNonNull(project)); 147 } 148 149 @Override 150 public UberJarBuilder name(String name) { 151 rename(Objects.requireNonNull(name)); 152 return this; 153 } 154 155 /// Ignore duplicates matching the given glob patterns when merging. 156 /// 157 /// @param patterns the patterns 158 /// @return the uber jar builder 159 /// 160 public UberJarBuilder ignoreDuplicates(String... patterns) { 161 ignoredDuplicates.addAll(Arrays.asList(patterns)); 162 return this; 163 } 164 165 @Override 166 protected void collectFromProviders( 167 Map<Path, Resources<IOResource>> contents) { 168 openJars = new ConcurrentHashMap<>(); 169 contentProviders().stream().filter(p -> !p.equals(this)) 170 .map(p -> p.resources( 171 of(ClasspathElementType).using(Supply, Expose))) 172 // Terminate to trigger all future stream evaluations before 173 // starting to process the results. Then collect in parallel 174 .toList().stream().flatMap(s -> s).toList().parallelStream() 175 .filter(resourceFilter::test).forEach(cpe -> { 176 if (cpe instanceof FileTree<?> fileTree) { 177 collect(contents, fileTree); 178 } else if (cpe instanceof JarFile jarFile 179 // Ignore jar files from maven repositories, see below 180 && !(jarFile instanceof MvnRepoJarFile)) { 181 addJarFile(contents, jarFile, openJars); 182 } 183 }); 184 185 // Jar files from maven repositories must be resolved before 186 // they can be added to the uber jar, i.e. they must be added 187 // with their transitive dependencies. 188 var lookup = new MvnRepoLookup(); 189 lookup.resolve(contentProviders().stream().map( 190 p -> p.resources(of(MvnRepoDependencyType).usingAll())) 191 .flatMap(s -> s)); 192 project().context().resources(lookup, of(ClasspathElementType) 193 .using(Consume, Reveal, Supply, Expose, Forward)) 194 .parallel().filter(resourceFilter::test).forEach(cpe -> { 195 if (cpe instanceof MvnRepoJarFile jarFile) { 196 addJarFile(contents, jarFile, openJars); 197 } 198 }); 199 } 200 201 /// Apply the given filter to the resources obtained from the provider. 202 /// The resources can be [ClasspathElement]s or [MvnRepoResource]s. 203 /// This may be required to avoid warnings about duplicates if e.g. 204 /// a sub-project provides generated resources both as 205 /// [ClassTree]/[JavaResourceTree] and as [LibraryJarFile]. 206 /// 207 /// @param filter the filter. Returns `true` for resources to be 208 /// included. 209 /// @return the uber jar generator 210 /// 211 public UberJarBuilder resourceFilter(Predicate<Resource> filter) { 212 resourceFilter = Objects.requireNonNull(filter); 213 return this; 214 } 215 216 private void addJarFile(Map<Path, Resources<IOResource>> entries, 217 JarFile jarFile, Map<Path, java.util.jar.JarFile> openJars) { 218 @SuppressWarnings({ "PMD.CloseResource" }) 219 java.util.jar.JarFile jar 220 = openJars.computeIfAbsent(jarFile.path(), _ -> { 221 try { 222 return new java.util.jar.JarFile(jarFile.path().toFile()); 223 } catch (IOException e) { 224 throw new BuildException().from(this).cause(e); 225 } 226 }); 227 jar.stream().filter(Predicate.not(JarEntry::isDirectory)) 228 .filter(e -> !Path.of(e.getName()) 229 .endsWith(Path.of("module-info.class"))) 230 .filter(e -> { 231 // Filter top-level entries in META-INF/ 232 var segs = Path.of(e.getRealName()).iterator(); 233 if (segs.next().equals(Path.of("META-INF"))) { 234 segs.next(); 235 return segs.hasNext(); 236 } 237 return true; 238 }).forEach(e -> { 239 var relPath = Path.of(e.getRealName()); 240 entries.computeIfAbsent(relPath, 241 _ -> Resources.with(IOResource.class)) 242 .add(new JarFileEntry(jar, e)); 243 }); 244 } 245 246 @SuppressWarnings({ "PMD.UselessPureMethodCall", 247 "PMD.AvoidLiteralsInIfCondition" }) 248 @Override 249 protected void resolveDuplicates(Map<Path, Resources<IOResource>> entries) { 250 entries.entrySet().parallelStream().forEach(item -> { 251 var entryName = item.getKey(); 252 var candidates = item.getValue(); 253 if (candidates.stream().count() == 1) { 254 return; 255 } 256 if (entryName.startsWith("META-INF/services")) { 257 var combined = new ServicesEntryResource(); 258 candidates.stream().forEach(service -> { 259 try { 260 combined.add(service); 261 } catch (IOException e) { 262 throw new BuildException().from(this).cause(e); 263 } 264 }); 265 candidates.clear(); 266 candidates.add(combined); 267 return; 268 } 269 if (entryName.startsWith("META-INF")) { 270 candidates.clear(); 271 } 272 if (ignoredDuplicates.stream() 273 .map(p -> pathMatcher.isMatch(p, entryName.toString())) 274 .filter(Boolean::booleanValue).findFirst().isPresent()) { 275 return; 276 } 277 candidates.stream().reduce((a, b) -> { 278 logger.atWarning().log("Entry %s from %s duplicates" 279 + " entry from %s and is skipped.", entryName, a, b); 280 return a; 281 }); 282 }); 283 } 284 285 @Override 286 @SuppressWarnings({ "PMD.CollapsibleIfStatements", "unchecked", 287 "PMD.CloseResource", "PMD.UseTryWithResources", 288 "PMD.CognitiveComplexity", "PMD.CyclomaticComplexity" }) 289 protected <T extends Resource> Collection<T> 290 doProvide(ResourceRequest<T> request) { 291 if (!request.accepts(AppJarFileType) 292 && !request.accepts(CleanlinessType)) { 293 return Collections.emptyList(); 294 } 295 296 // Maybe only delete 297 if (request.accepts(CleanlinessType)) { 298 destination().resolve(jarName()).toFile().delete(); 299 return Collections.emptyList(); 300 } 301 302 // Make sure mainClass is set for app jar 303 if (request.isFor(AppJarFileType) && mainClass() == null) { 304 throw new ConfigurationException().from(this) 305 .message("Main class must be set for %s", name()); 306 } 307 308 // Upgrade to most specific type to avoid duplicate generation 309 if (mainClass() != null && !request.type().equals(AppJarFileType)) { 310 return (Collection<T>) context() 311 .resources(this, project().of(AppJarFileType)).toList(); 312 } 313 if (mainClass() == null && !request.type().equals(JarFileType)) { 314 return (Collection<T>) context() 315 .resources(this, project().of(JarFileType)).toList(); 316 } 317 318 // Prepare jar file 319 var destDir = destination(); 320 if (!destDir.toFile().exists()) { 321 if (!destDir.toFile().mkdirs()) { 322 throw new ConfigurationException().from(this) 323 .message("Cannot create directory " + destDir); 324 } 325 } 326 var jarResource = request.isFor(AppJarFileType) 327 ? AppJarFile.of(destDir.resolve(jarName())) 328 : LibraryJarFile.of(destDir.resolve(jarName())); 329 try { 330 buildJar(jarResource); 331 } finally { 332 // buidJar indirectly calls collectFromProviders which opens 333 // resources that are used in buildJar. Close them now. 334 for (var jarFile : openJars.values()) { 335 try { 336 jarFile.close(); 337 } catch (IOException e) { // NOPMD 338 // Ignore, just trying to be nice. 339 } 340 } 341 } 342 return List.of((T) jarResource); 343 } 344}