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