1 package org.djutils.io;
2
3 import java.io.File;
4 import java.io.IOException;
5 import java.io.InputStream;
6 import java.io.UncheckedIOException;
7 import java.net.MalformedURLException;
8 import java.net.URI;
9 import java.net.URISyntaxException;
10 import java.net.URL;
11 import java.net.http.HttpClient;
12 import java.net.http.HttpRequest;
13 import java.net.http.HttpResponse;
14 import java.nio.file.FileSystem;
15 import java.nio.file.FileSystemNotFoundException;
16 import java.nio.file.FileSystems;
17 import java.nio.file.Files;
18 import java.nio.file.InvalidPathException;
19 import java.nio.file.Path;
20 import java.util.Base64;
21 import java.util.Map;
22 import java.util.NoSuchElementException;
23 import java.util.concurrent.ConcurrentHashMap;
24
25 import org.djutils.exceptions.Throw;
26
27 /**
28 * ResourceResolver resolves a resource on the classpath, on a file system, as a URL or in a jar-file. For resources, it looks
29 * both in the root directory ad in the directory <code>/resources</code> as some Maven packaging stores the resources inside
30 * this folder. The class can handle HTTP resources, also with username and password. Entries within jar-files can be retrieved
31 * from any type of resource. Example of usage of the class:
32 *
33 * <pre>
34 * // Relative file (resolved against baseDir, but also against classpath and classpath/resources)
35 * var h1 = ResourceResolver.resolve("data/input.csv");
36 *
37 * // Absolute file
38 * var h2 = ResourceResolver.resolve("/var/tmp/report.txt");
39 *
40 * // HTTP(S)
41 * var h3 = ResourceResolver.resolve("https://example.com/file.json");
42 *
43 * // FTP with credentials
44 * var h4 = ResourceResolver.resolve("ftp://user:pass@ftp.example.com/pub/notes.txt");
45 *
46 * // Classpath with specific classloader and relative to a specific path
47 * var h5 = ResourceResolver.resolve("config/app.yaml", MyApp.class.getClassLoader(), Path.of("."));
48 *
49 * // Raw bang syntax -> normalized to jar:
50 * var h6 = ResourceResolver.resolve("file:/opt/app/lib/app.jar!/META-INF/MANIFEST.MF");
51 *
52 * // Use the result as a URL, URI, or Path:
53 * try (var in = h6.openStream())
54 * {
55 * // use h6 to read something from the stream
56 * }
57 * h6.asPath().ifPresent(path -> System.out.println(Files.size(path)));
58 * System.out.println(h6.asUrl());
59 * System.out.println(h6.asUri());
60 * </pre>
61 * <p>
62 * Copyright (c) 2025-2025 Delft University of Technology, Jaffalaan 5, 2628 BX Delft, the Netherlands. All rights reserved. See
63 * for project information <a href="https://djutils.org" target="_blank"> https://djutils.org</a>. The DJUTILS project is
64 * distributed under a three-clause BSD-style license, which can be found at
65 * <a href="https://djutils.org/docs/license.html" target="_blank"> https://djutils.org/docs/license.html</a>.
66 * </p>
67 * @author <a href="https://www.tudelft.nl/averbraeck">Alexander Verbraeck</a>
68 */
69 public final class ResourceResolver
70 {
71 /** HTTP client for resolving http:// and https:// resources; build when needed. */
72 private static HttpClient httpClient;
73
74 /** Utility class constructor. */
75 private ResourceResolver()
76 {
77 // utility class
78 }
79
80 /**
81 * Resolve a resource string against a base directory and classloader.
82 * @param resource the specification of the resource to load
83 * @param classLoader the class loader to specifically use (can be null)
84 * @param baseDirPath the potential base directory to which the resource is relative (can be null)
85 * @param asResource read relative to the classpath (if it's a file)
86 * @param asFile read relative to the baseDir (if it's a file)
87 * @return a resource handle to the resource
88 * @throws NoSuchElementException when the resource could not be found
89 */
90 private static ResourceHandle resolve(final String resource, final ClassLoader classLoader, final Path baseDirPath,
91 final boolean asResource, final boolean asFile)
92 {
93 Throw.whenNull(resource, "resource");
94 ClassLoader cl = classLoader == null ? Thread.currentThread().getContextClassLoader() : classLoader;
95 Path baseDir = baseDirPath == null ? Path.of("").toAbsolutePath() : baseDirPath;
96
97 // 1) Normalize raw "...jar!/entry" into jar: URI if needed
98 String normalized = resource.trim();
99 int bangPos = indexOfBang(normalized);
100 if (bangPos >= 0)
101 {
102 URI jarUri = buildJarUriFromBang(normalized, bangPos, baseDir);
103 return ResourceHandle.forJarUri(jarUri);
104 }
105
106 if (asFile)
107 {
108 // 2) If it parses as an absolute URI, handle by scheme
109 URI candidateUri = tryParseUri(normalized);
110 if (candidateUri != null && candidateUri.isAbsolute())
111 {
112 String scheme = candidateUri.getScheme();
113 if ("file".equalsIgnoreCase(scheme))
114 {
115 Path p = Path.of(candidateUri);
116 return ResourceHandle.forFile(p);
117 }
118 if ("jar".equalsIgnoreCase(scheme))
119 {
120 return ResourceHandle.forJarUri(candidateUri);
121 }
122 if ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))
123 {
124 if (httpClient == null)
125 {
126 httpClient = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build();
127 }
128 return ResourceHandle.forHttp(candidateUri, httpClient);
129 }
130 if ("ftp".equalsIgnoreCase(scheme))
131 {
132 return ResourceHandle.forGenericUrl(uriToUrl(candidateUri));
133 }
134 // unknown but absolute — try URL stream
135 return ResourceHandle.forGenericUrl(uriToUrl(candidateUri));
136 }
137
138 // 3) Try as a local/relative path
139 Path p = baseDir.resolve(resource).normalize();
140 if (Files.exists(p))
141 {
142 return ResourceHandle.forFile(p);
143 }
144 }
145
146 // 4) Try classpath
147 if (asResource)
148 {
149 String cpName = resource.startsWith("/") ? resource.substring(1) : resource;
150 URL url = cl.getResource(cpName);
151 if (url == null)
152 {
153 url = cl.getResource("resources/" + cpName);
154 }
155 if (url != null)
156 {
157 // Could be "jar:file:...!/entry" or plain "file:"
158 if ("jar".equalsIgnoreCase(url.getProtocol()) || url.toString().contains("!/"))
159 {
160 URI jarUri = ensureJarUri(url);
161 return ResourceHandle.forJarUri(jarUri).markClassPath();
162 }
163 else if ("file".equalsIgnoreCase(url.getProtocol()))
164 {
165 try
166 {
167 return ResourceHandle.forFile(Path.of(url.toURI())).markClassPath();
168 }
169 catch (URISyntaxException e)
170 {
171 // fallback to generic
172 return ResourceHandle.forGenericUrl(url).markClassPath();
173 }
174 }
175 else
176 {
177 return ResourceHandle.forGenericUrl(url).markClassPath();
178 }
179 }
180 }
181 throw new NoSuchElementException("Resource not found: " + resource);
182 }
183
184 /**
185 * Resolve a resource string against a base directory and classloader.
186 * @param resource the specification of the resource to load
187 * @param classLoader the class loader to specifically use (can be null)
188 * @param baseDirPath the potential base directory to which the resource is relative (can be null)
189 * @return a resource handle to the resource
190 * @throws NoSuchElementException when the resource could not be found
191 */
192 public static ResourceHandle resolve(final String resource, final ClassLoader classLoader, final Path baseDirPath)
193 {
194 return resolve(resource, classLoader, baseDirPath, true, true);
195 }
196
197 /**
198 * Resolve a resource string against a base directory and classloader.
199 * @param resource the specification of the resource to load
200 * @param classLoader the class loader to specifically use (can be null)
201 * @param baseDir the potential base directory to which the resource is relative
202 * @return a resource handle to the resource
203 * @throws NoSuchElementException when the resource could not be found
204 * @throws InvalidPathException if the baseDir path string cannot be converted to a Path
205 */
206 public static ResourceHandle resolve(final String resource, final ClassLoader classLoader, final String baseDir)
207 {
208 Path baseDirPath = Path.of(baseDir);
209 return resolve(resource, classLoader, baseDirPath, true, true);
210 }
211
212 /**
213 * resolve() method without ClassLoader and base path.
214 * @param resource the specification of the resource to resolve
215 * @param baseDirPath the potential base directory to which the resource is relative (can be null)
216 * @return a resource handle to the resource
217 * @throws NoSuchElementException when the resource could not be found
218 */
219 public static ResourceHandle resolve(final String resource, final Path baseDirPath)
220 {
221 return resolve(resource, null, baseDirPath, true, true);
222 }
223
224 /**
225 * resolve() method without ClassLoader and base path.
226 * @param resource the specification of the resource to resolve
227 * @param baseDir the potential base directory to which the resource is relative
228 * @return a resource handle to the resource
229 * @throws NoSuchElementException when the resource could not be found
230 * @throws InvalidPathException if the baseDir path string cannot be converted to a Path
231 */
232 public static ResourceHandle resolve(final String resource, final String baseDir)
233 {
234 Path baseDirPath = Path.of(baseDir);
235 return resolve(resource, null, baseDirPath, true, true);
236 }
237
238 /**
239 * resolve() method without ClassLoader.
240 * @param resource the specification of the resource to resolve
241 * @return a resource handle to the resource
242 * @throws NoSuchElementException when the resource could not be found
243 */
244 public static ResourceHandle resolve(final String resource)
245 {
246 return resolve(resource, null, null, true, true);
247 }
248
249 /**
250 * Resolve the resource relative to the classloader path.
251 * @param resource the specification of the resource to resolve
252 * @return a resource handle to the resource
253 * @throws NoSuchElementException when the resource could not be found
254 */
255 public static ResourceHandle resolveAsResource(final String resource)
256 {
257 return resolve(resource, null, null, true, false);
258 }
259
260 /**
261 * Resolve the resource relative to the base path, but NOT relative to the classloader path.
262 * @param resource the specification of the resource to resolve
263 * @return a resource handle to the resource
264 * @throws NoSuchElementException when the resource could not be found
265 */
266 public static ResourceHandle resolveAsFile(final String resource)
267 {
268 return resolve(resource, null, null, false, true);
269 }
270
271 // --- helpers ---
272
273 /**
274 * Find the 'bang' sign (!/ or !\) inside a resource string.
275 * @param s the resource string that might contain "!/" or "!\"
276 * @return the location of the bang sign
277 */
278 private static int indexOfBang(final String s)
279 {
280 // Normalize Windows "!\" to "!/"
281 int i = s.indexOf("!/");
282 if (i >= 0)
283 {
284 return i;
285 }
286 i = s.indexOf("!\\");
287 return i;
288 }
289
290 /**
291 * Build a valid URI for a jar entry from a bang resource string.
292 * @param raw the raw resource string
293 * @param bangPos the location of the bang sign "!/" in the resource string
294 * @param baseDir the base directory against which we resolve (cannot be null)
295 * @return a valid URI for the jar entry
296 */
297 private static URI buildJarUriFromBang(final String raw, final int bangPos, final Path baseDir)
298 {
299 // Normalize any "!\" to "!/"
300 String s = raw.replace("!\\", "!/");
301 int bang = s.indexOf("!/");
302 String leftRaw = s.substring(0, bang).trim();
303 String entry = s.substring(bang + 2);
304
305 // If 'left' is already an absolute URI (file:, http:, https:, etc.) use it directly
306 URI leftAbs = tryParseAbsoluteUri(leftRaw);
307 if (leftAbs != null)
308 {
309 return URI.create("jar:" + leftAbs + "!/" + entry);
310 }
311
312 // --- Windows safety: fix "/C:/..." forms BEFORE Path.of(...)
313 leftRaw = normalizeWindowsDrive(leftRaw);
314
315 Path jarPath = Path.of(leftRaw);
316 if (!jarPath.isAbsolute())
317 {
318 jarPath = baseDir.resolve(jarPath);
319 }
320 jarPath = jarPath.normalize();
321
322 URI fileUri = jarPath.toUri(); // properly escaped
323 return URI.create("jar:" + fileUri + "!/" + entry);
324 }
325
326 /**
327 * On Windows, path can get mangled when moving between URI/URL and Path. This method turns "/C:/foo" or "\C:\foo" into
328 * "C:/foo" (or "C:\foo")
329 * @param s the path string to check
330 * @return a normalized string for Windows
331 */
332 private static String normalizeWindowsDrive(final String s)
333 {
334 if (File.separatorChar == '\\' && s.length() >= 4 && (s.charAt(0) == '/' || s.charAt(0) == '\\')
335 && Character.isLetter(s.charAt(1)) && s.charAt(2) == ':' && (s.charAt(3) == '/' || s.charAt(3) == '\\'))
336 {
337 return s.substring(1);
338 }
339 return s;
340 }
341
342 /**
343 * Try to parse an absolute URI from a resource string (e.g., file:// or http://).
344 * @param s the resource string to parse
345 * @return a URI for the absolute path to the resource, or null when it could not be found
346 */
347 private static URI tryParseAbsoluteUri(final String s)
348 {
349 try
350 {
351 URI u = URI.create(s);
352 return u.isAbsolute() ? u : null;
353 }
354 catch (IllegalArgumentException e)
355 {
356 return null;
357 }
358 }
359
360 /**
361 * Try to create a URI from the given String.
362 * @param s the specification that might be convertible into a URI
363 * @return a URI or null when s cannot be converted to a URI
364 */
365 private static URI tryParseUri(final String s)
366 {
367 try
368 {
369 return URI.create(s);
370 }
371 catch (IllegalArgumentException e)
372 {
373 return null;
374 }
375 }
376
377 /**
378 * Try to convert the given URI into a URL.
379 * @param u the URI that might be convertible into a URL
380 * @return a URL or null when u cannot be converted to a URL
381 */
382 private static URL uriToUrl(final URI u)
383 {
384 try
385 {
386 return u.toURL();
387 }
388 catch (MalformedURLException e)
389 {
390 throw new IllegalArgumentException(e);
391 }
392 }
393
394 /**
395 * Create a URI for a jar-entry from a URL.
396 * @param url the url for the path that might start with "jar:"; if not it is added
397 * @return a URI for the jar entry
398 */
399 private static URI ensureJarUri(final URL url)
400 {
401 String s = url.toString();
402 if (!s.startsWith("jar:"))
403 {
404 s = "jar:" + s;
405 }
406 return URI.create(s);
407 }
408
409 // ------------------------------------------------------------------------
410
411 /**
412 * Inner class with the specifications for a resource.
413 */
414 public static final class ResourceHandle
415 {
416 /** The URI of the resource. */
417 private final URI uri;
418
419 /** The kind of resource: file, JAR entry, HTTP resource, or generic URL. */
420 private final Kind kind;
421
422 /** Optional http-client to help retrieve the resource, can be null. */
423 private final HttpClient httpClient;
424
425 /** Relative to classpath? */
426 private boolean fromClasspath;
427
428 /**
429 * Instantiate a ResourceHandle with the specifications for a resource.
430 * @param uri the URI of the resource
431 * @param kind the kind of resource: file, JAR entry, HTTP resource, or generic URL
432 * @param httpClient the http-client to help retrieve the resource, can be null for non-HTTP resources
433 */
434 private ResourceHandle(final URI uri, final Kind kind, final HttpClient httpClient)
435 {
436 this.uri = uri;
437 this.kind = kind;
438 this.httpClient = httpClient;
439 }
440
441 /**
442 * Return the URI of the resource.
443 * @return the URI of the resource
444 */
445 public URI asUri()
446 {
447 return this.uri;
448 }
449
450 /**
451 * Return the URL of the resource, if it can be transformed to a URL. If not, an Exception is thrown.
452 * @return the URI of the resource, if it can be transformed to a URL
453 * @throws IllegalStateException when the URI cannot be transformed into a URL
454 */
455 public URL asUrl()
456 {
457 try
458 {
459 return this.uri.toURL();
460 }
461 catch (MalformedURLException e)
462 {
463 throw new IllegalStateException(e);
464 }
465 }
466
467 /**
468 * Return a readable stream of the resource for supported schemes.
469 * @return a readable stream of the resource for supported schemes
470 * @throws IOException when an I/O error occurs during building of the stream
471 */
472 public InputStream openStream() throws IOException
473 {
474 switch (this.kind)
475 {
476 case FILE ->
477 {
478 return Files.newInputStream(Path.of(this.uri));
479 }
480 case JAR_ENTRY ->
481 {
482 JarCache.JarEntryPath entry = JarCache.resolveJarEntry(this.uri);
483 return Files.newInputStream(entry.path());
484 }
485 case HTTP ->
486 {
487 HttpRequest.Builder b = HttpRequest.newBuilder(this.uri).GET();
488 // Optional: add Basic auth if userInfo is present (discouraged, but supported)
489 String userInfo = this.uri.getUserInfo();
490 if (userInfo != null && !userInfo.isEmpty())
491 {
492 String enc =
493 Base64.getEncoder().encodeToString(userInfo.getBytes(java.nio.charset.StandardCharsets.UTF_8));
494 b.header("Authorization", "Basic " + enc);
495 }
496 try
497 {
498 HttpResponse<InputStream> resp =
499 this.httpClient.send(b.build(), HttpResponse.BodyHandlers.ofInputStream());
500 if (resp.statusCode() >= 200 && resp.statusCode() < 300)
501 {
502 return resp.body();
503 }
504 resp.body().close();
505 throw new IOException("HTTP " + resp.statusCode() + " for " + this.uri);
506 }
507 catch (InterruptedException ie)
508 {
509 throw new IOException("Interrupted while loading HTTP resource", ie);
510 }
511 }
512 case GENERIC_URL ->
513 {
514 return asUrl().openStream();
515 }
516 default -> throw new UnsupportedOperationException("Unknown kind: " + this.kind);
517 }
518 }
519
520 /**
521 * A Path is available for plain files and for JAR entries (mounted file system).
522 * @return a Path representation of the resource or null when no Path exists.
523 */
524 public Path asPath()
525 {
526 try
527 {
528 return switch (this.kind)
529 {
530 case FILE -> Path.of(this.uri);
531 case JAR_ENTRY -> JarCache.resolveJarEntry(this.uri).path();
532 default -> null;
533 };
534 }
535 catch (Exception e)
536 {
537 return null;
538 }
539 }
540
541 /**
542 * Return whether the resource was loaded from the classpath.
543 * @return whether the resource was loaded from the classpath or not
544 */
545 public boolean isClassPath()
546 {
547 return this.fromClasspath;
548 }
549
550 /**
551 * Return whether the resource is of type FILE.
552 * @return whether the resource is of type FILE or not
553 */
554 public boolean isFile()
555 {
556 return this.kind.equals(Kind.FILE);
557 }
558
559 /**
560 * Return whether the resource is of type HTTP.
561 * @return whether the resource is of type HTTP or not
562 */
563 public boolean isHttp()
564 {
565 return this.kind.equals(Kind.HTTP);
566 }
567
568 /**
569 * Return whether the resource is of type GENERIC_URL.
570 * @return whether the resource is of type GENERIC_URL or not
571 */
572 public boolean isGenericUrl()
573 {
574 return this.kind.equals(Kind.GENERIC_URL);
575 }
576
577 /**
578 * Return whether the resource is of type JAR_ENTRY.
579 * @return whether the resource is of type JAR_ENTRY or not
580 */
581 public boolean isJarEntry()
582 {
583 return this.kind.equals(Kind.JAR_ENTRY);
584 }
585
586 /**
587 * Mark that the resource was loaded from the classpath.
588 * @return the ResourceHandle for fluent design
589 */
590 private ResourceHandle markClassPath()
591 {
592 this.fromClasspath = true;
593 return this;
594 }
595
596 /**
597 * Instantiate a ResourceHandle from a given Path.
598 * @param p the Path
599 * @return a ResourceHandle for the Path
600 */
601 static ResourceHandle forFile(final Path p)
602 {
603 return new ResourceHandle(p.toUri(), Kind.FILE, null);
604 }
605
606 /**
607 * Instantiate a ResourceHandle from a jar-entry containing URI such as <code>jar:file:/.../lib.jar!/path/inside</code>.
608 * @param jarUri the jar-entry containing URI
609 * @return a ResourceHandle for the jar-entry containing URI
610 */
611 static ResourceHandle forJarUri(final URI jarUri)
612 {
613 return new ResourceHandle(jarUri, Kind.JAR_ENTRY, null);
614 }
615
616 /**
617 * Instantiate a ResourceHandle for a given URI for a http-connection using the provided http-client.
618 * @param httpUri a URI for a http-connection
619 * @param http the http-client to use
620 * @return a ResourceHandle for the given URI for a http-connection
621 */
622 static ResourceHandle forHttp(final URI httpUri, final HttpClient http)
623 {
624 return new ResourceHandle(httpUri, Kind.HTTP, http);
625 }
626
627 /**
628 * Instantiate a ResourceHandle from a given generic URL. Throw an exception when the resource handle cannot be
629 * instantiated from the URL.
630 * @param url the generic URL
631 * @return a ResourceHandle for the given URL
632 * @throws IllegalArgumentException when the resource handle cannot be instantiated from the URL
633 */
634 static ResourceHandle forGenericUrl(final URL url)
635 {
636 try
637 {
638 return new ResourceHandle(url.toURI(), Kind.GENERIC_URL, null);
639 }
640 catch (URISyntaxException e)
641 {
642 throw new IllegalArgumentException(e);
643 }
644 }
645
646 /**
647 * Enum for the kind of resource handle: file, JAR entry, HTTP resource, or generic URL.
648 */
649 enum Kind
650 {
651 /** File via the local file system, but not a JAR entry. */
652 FILE,
653
654 /** Entry within a JAR file, typically indicated with a "!/" inside the string. */
655 JAR_ENTRY,
656
657 /** File on the Internet that can be retrieved via the http(s) protocol. */
658 HTTP,
659
660 /** Any other type of URL that might point to the resource. */
661 GENERIC_URL
662 }
663 }
664
665 /**
666 * JAR FileSystem caching for FileSystems that need to be mounted when retrieving a Jar.
667 */
668 private static final class JarCache
669 {
670 /** Cache JAR root URI -> FileSystem (weak so closed/reclaimed when unused). */
671 private static final Map<URI, FileSystem> FS_CACHE = new ConcurrentHashMap<>();
672
673 /**
674 * Record to return the combination of a FileSystem and a Path within that FileSystem.
675 * @param fs the FileSystem
676 * @param path the Path within the FileSystem
677 */
678 record JarEntryPath(FileSystem fs, Path path)
679 {
680 }
681
682 /**
683 * Resolve a Jar entry, such as jar:file:/.../lib.jar!/path/in/jar.
684 * @param jarUri the URI that stores the JAR path
685 * @return a FileSystem plus Path record for the JAR entry
686 * @throws IllegalArgumentException when the URI does not contain a JAR entry
687 */
688 static JarEntryPath resolveJarEntry(final URI jarUri)
689 {
690 String ssp = jarUri.getSchemeSpecificPart();
691 int bang = ssp.indexOf("!/");
692 if (bang < 0)
693 {
694 throw new IllegalArgumentException("Not a JAR URI: " + jarUri);
695 }
696 URI jarFileUri = URI.create(ssp.substring(0, bang)); // file:/.../lib.jar
697 String entry = ssp.substring(bang + 2); // path/in/jar
698
699 FileSystem fs = FS_CACHE.computeIfAbsent(jarFileUri, k ->
700 {
701 try
702 {
703 // Reuse if already mounted; else mount
704 try
705 {
706 return FileSystems.getFileSystem(URI.create("jar:" + k));
707 }
708 catch (FileSystemNotFoundException ignored)
709 {
710 return FileSystems.newFileSystem(URI.create("jar:" + k), Map.of());
711 }
712 }
713 catch (IOException e)
714 {
715 throw new UncheckedIOException(e);
716 }
717 });
718
719 return new JarEntryPath(fs, fs.getPath(entry));
720 }
721 }
722 }