View Javadoc
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 }