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