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