ResourceResolver.java
package org.djutils.io;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.FileSystem;
import java.nio.file.FileSystemNotFoundException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.util.Base64;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.concurrent.ConcurrentHashMap;
import org.djutils.exceptions.Throw;
/**
* ResourceResolver resolves a resource on the classpath, on a file system, as a URL or in a jar-file. For resources, it looks
* both in the root directory ad in the directory <code>/resources</code> as some Maven packaging stores the resources inside
* this folder. The class can handle HTTP resources, also with username and password. Entries within jar-files can be retrieved
* from any type of resource. Example of usage of the class:
*
* <pre>
* // Relative file (resolved against baseDir, but also against classpath and classpath/resources)
* var h1 = ResourceResolver.resolve("data/input.csv");
*
* // Absolute file
* var h2 = ResourceResolver.resolve("/var/tmp/report.txt");
*
* // HTTP(S)
* var h3 = ResourceResolver.resolve("https://example.com/file.json");
*
* // FTP with credentials
* var h4 = ResourceResolver.resolve("ftp://user:pass@ftp.example.com/pub/notes.txt");
*
* // Classpath with specific classloader and relative to a specific path
* var h5 = ResourceResolver.resolve("config/app.yaml", MyApp.class.getClassLoader(), Path.of("."));
*
* // Raw bang syntax -> normalized to jar:
* var h6 = ResourceResolver.resolve("file:/opt/app/lib/app.jar!/META-INF/MANIFEST.MF");
*
* // Use the result as a URL, URI, or Path:
* try (var in = h6.openStream())
* {
* // use h6 to read something from the stream
* }
* h6.asPath().ifPresent(path -> System.out.println(Files.size(path)));
* System.out.println(h6.asUrl());
* System.out.println(h6.asUri());
* </pre>
* <p>
* Copyright (c) 2025-2025 Delft University of Technology, Jaffalaan 5, 2628 BX Delft, the Netherlands. All rights reserved. See
* for project information <a href="https://djutils.org" target="_blank"> https://djutils.org</a>. The DJUTILS project is
* distributed under a three-clause BSD-style license, which can be found at
* <a href="https://djutils.org/docs/license.html" target="_blank"> https://djutils.org/docs/license.html</a>.
* </p>
* @author <a href="https://www.tudelft.nl/averbraeck">Alexander Verbraeck</a>
*/
public final class ResourceResolver
{
/** HTTP client for resolving http:// and https:// resources; build when needed. */
private static HttpClient httpClient;
/** Utility class constructor. */
private ResourceResolver()
{
// utility class
}
/**
* Resolve a resource string against a base directory and classloader.
* @param resource the specification of the resource to load
* @param classLoader the class loader to specifically use (can be null)
* @param baseDirPath the potential base directory to which the resource is relative (can be null)
* @param asResource read relative to the classpath (if it's a file)
* @param asFile read relative to the baseDir (if it's a file)
* @return a resource handle to the resource
* @throws NoSuchElementException when the resource could not be found
*/
private static ResourceHandle resolve(final String resource, final ClassLoader classLoader, final Path baseDirPath,
final boolean asResource, final boolean asFile)
{
Throw.whenNull(resource, "resource");
ClassLoader cl = classLoader == null ? Thread.currentThread().getContextClassLoader() : classLoader;
Path baseDir = baseDirPath == null ? Path.of("").toAbsolutePath() : baseDirPath;
// 1) Normalize raw "...jar!/entry" into jar: URI if needed
String normalized = resource.trim();
int bangPos = indexOfBang(normalized);
if (bangPos >= 0)
{
URI jarUri = buildJarUriFromBang(normalized, bangPos, baseDir);
return ResourceHandle.forJarUri(jarUri);
}
if (asFile)
{
// 2) If it parses as an absolute URI, handle by scheme
URI candidateUri = tryParseUri(normalized);
if (candidateUri != null && candidateUri.isAbsolute())
{
String scheme = candidateUri.getScheme();
if ("file".equalsIgnoreCase(scheme))
{
Path p = Path.of(candidateUri);
return ResourceHandle.forFile(p);
}
if ("jar".equalsIgnoreCase(scheme))
{
return ResourceHandle.forJarUri(candidateUri);
}
if ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))
{
if (httpClient == null)
{
httpClient = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build();
}
return ResourceHandle.forHttp(candidateUri, httpClient);
}
if ("ftp".equalsIgnoreCase(scheme))
{
return ResourceHandle.forGenericUrl(uriToUrl(candidateUri));
}
// unknown but absolute — try URL stream
return ResourceHandle.forGenericUrl(uriToUrl(candidateUri));
}
// 3) Try as a local/relative path
Path p = baseDir.resolve(resource).normalize();
if (Files.exists(p))
{
return ResourceHandle.forFile(p);
}
}
// 4) Try classpath
if (asResource)
{
String cpName = resource.startsWith("/") ? resource.substring(1) : resource;
URL url = cl.getResource(cpName);
if (url == null)
{
url = cl.getResource("resources/" + cpName);
}
if (url != null)
{
// Could be "jar:file:...!/entry" or plain "file:"
if ("jar".equalsIgnoreCase(url.getProtocol()) || url.toString().contains("!/"))
{
URI jarUri = ensureJarUri(url);
return ResourceHandle.forJarUri(jarUri).markClassPath();
}
else if ("file".equalsIgnoreCase(url.getProtocol()))
{
try
{
return ResourceHandle.forFile(Path.of(url.toURI())).markClassPath();
}
catch (URISyntaxException e)
{
// fallback to generic
return ResourceHandle.forGenericUrl(url).markClassPath();
}
}
else
{
return ResourceHandle.forGenericUrl(url).markClassPath();
}
}
}
throw new NoSuchElementException("Resource not found: " + resource);
}
/**
* Resolve a resource string against a base directory and classloader.
* @param resource the specification of the resource to load
* @param classLoader the class loader to specifically use (can be null)
* @param baseDirPath the potential base directory to which the resource is relative (can be null)
* @return a resource handle to the resource
* @throws NoSuchElementException when the resource could not be found
*/
public static ResourceHandle resolve(final String resource, final ClassLoader classLoader, final Path baseDirPath)
{
return resolve(resource, classLoader, baseDirPath, true, true);
}
/**
* Resolve a resource string against a base directory and classloader.
* @param resource the specification of the resource to load
* @param classLoader the class loader to specifically use (can be null)
* @param baseDir the potential base directory to which the resource is relative
* @return a resource handle to the resource
* @throws NoSuchElementException when the resource could not be found
* @throws InvalidPathException if the baseDir path string cannot be converted to a Path
*/
public static ResourceHandle resolve(final String resource, final ClassLoader classLoader, final String baseDir)
{
Path baseDirPath = Path.of(baseDir);
return resolve(resource, classLoader, baseDirPath, true, true);
}
/**
* resolve() method without ClassLoader and base path.
* @param resource the specification of the resource to resolve
* @param baseDirPath the potential base directory to which the resource is relative (can be null)
* @return a resource handle to the resource
* @throws NoSuchElementException when the resource could not be found
*/
public static ResourceHandle resolve(final String resource, final Path baseDirPath)
{
return resolve(resource, null, baseDirPath, true, true);
}
/**
* resolve() method without ClassLoader and base path.
* @param resource the specification of the resource to resolve
* @param baseDir the potential base directory to which the resource is relative
* @return a resource handle to the resource
* @throws NoSuchElementException when the resource could not be found
* @throws InvalidPathException if the baseDir path string cannot be converted to a Path
*/
public static ResourceHandle resolve(final String resource, final String baseDir)
{
Path baseDirPath = Path.of(baseDir);
return resolve(resource, null, baseDirPath, true, true);
}
/**
* resolve() method without ClassLoader.
* @param resource the specification of the resource to resolve
* @return a resource handle to the resource
* @throws NoSuchElementException when the resource could not be found
*/
public static ResourceHandle resolve(final String resource)
{
return resolve(resource, null, null, true, true);
}
/**
* Resolve the resource relative to the classloader path.
* @param resource the specification of the resource to resolve
* @return a resource handle to the resource
* @throws NoSuchElementException when the resource could not be found
*/
public static ResourceHandle resolveAsResource(final String resource)
{
return resolve(resource, null, null, true, false);
}
/**
* Resolve the resource relative to the base path, but NOT relative to the classloader path.
* @param resource the specification of the resource to resolve
* @return a resource handle to the resource
* @throws NoSuchElementException when the resource could not be found
*/
public static ResourceHandle resolveAsFile(final String resource)
{
return resolve(resource, null, null, false, true);
}
// --- helpers ---
/**
* Find the 'bang' sign (!/ or !\) inside a resource string.
* @param s the resource string that might contain "!/" or "!\"
* @return the location of the bang sign
*/
private static int indexOfBang(final String s)
{
// Normalize Windows "!\" to "!/"
int i = s.indexOf("!/");
if (i >= 0)
{
return i;
}
i = s.indexOf("!\\");
return i;
}
/**
* Build a valid URI for a jar entry from a bang resource string.
* @param raw the raw resource string
* @param bangPos the location of the bang sign "!/" in the resource string
* @param baseDir the base directory against which we resolve (cannot be null)
* @return a valid URI for the jar entry
*/
private static URI buildJarUriFromBang(final String raw, final int bangPos, final Path baseDir)
{
// Normalize any "!\" to "!/"
String s = raw.replace("!\\", "!/");
int bang = s.indexOf("!/");
String leftRaw = s.substring(0, bang).trim();
String entry = s.substring(bang + 2);
// If 'left' is already an absolute URI (file:, http:, https:, etc.) use it directly
URI leftAbs = tryParseAbsoluteUri(leftRaw);
if (leftAbs != null)
{
return URI.create("jar:" + leftAbs + "!/" + entry);
}
// --- Windows safety: fix "/C:/..." forms BEFORE Path.of(...)
leftRaw = normalizeWindowsDrive(leftRaw);
Path jarPath = Path.of(leftRaw);
if (!jarPath.isAbsolute())
{
jarPath = baseDir.resolve(jarPath);
}
jarPath = jarPath.normalize();
URI fileUri = jarPath.toUri(); // properly escaped
return URI.create("jar:" + fileUri + "!/" + entry);
}
/**
* On Windows, path can get mangled when moving between URI/URL and Path. This method turns "/C:/foo" or "\C:\foo" into
* "C:/foo" (or "C:\foo")
* @param s the path string to check
* @return a normalized string for Windows
*/
private static String normalizeWindowsDrive(final String s)
{
if (File.separatorChar == '\\' && s.length() >= 4 && (s.charAt(0) == '/' || s.charAt(0) == '\\')
&& Character.isLetter(s.charAt(1)) && s.charAt(2) == ':' && (s.charAt(3) == '/' || s.charAt(3) == '\\'))
{
return s.substring(1);
}
return s;
}
/**
* Try to parse an absolute URI from a resource string (e.g., file:// or http://).
* @param s the resource string to parse
* @return a URI for the absolute path to the resource, or null when it could not be found
*/
private static URI tryParseAbsoluteUri(final String s)
{
try
{
URI u = URI.create(s);
return u.isAbsolute() ? u : null;
}
catch (IllegalArgumentException e)
{
return null;
}
}
/**
* Try to create a URI from the given String.
* @param s the specification that might be convertible into a URI
* @return a URI or null when s cannot be converted to a URI
*/
private static URI tryParseUri(final String s)
{
try
{
return URI.create(s);
}
catch (IllegalArgumentException e)
{
return null;
}
}
/**
* Try to convert the given URI into a URL.
* @param u the URI that might be convertible into a URL
* @return a URL or null when u cannot be converted to a URL
*/
private static URL uriToUrl(final URI u)
{
try
{
return u.toURL();
}
catch (MalformedURLException e)
{
throw new IllegalArgumentException(e);
}
}
/**
* Create a URI for a jar-entry from a URL.
* @param url the url for the path that might start with "jar:"; if not it is added
* @return a URI for the jar entry
*/
private static URI ensureJarUri(final URL url)
{
String s = url.toString();
if (!s.startsWith("jar:"))
{
s = "jar:" + s;
}
return URI.create(s);
}
// ------------------------------------------------------------------------
/**
* Inner class with the specifications for a resource.
*/
public static final class ResourceHandle
{
/** The URI of the resource. */
private final URI uri;
/** The kind of resource: file, JAR entry, HTTP resource, or generic URL. */
private final Kind kind;
/** Optional http-client to help retrieve the resource, can be null. */
private final HttpClient httpClient;
/** Relative to classpath? */
private boolean fromClasspath;
/**
* Instantiate a ResourceHandle with the specifications for a resource.
* @param uri the URI of the resource
* @param kind the kind of resource: file, JAR entry, HTTP resource, or generic URL
* @param httpClient the http-client to help retrieve the resource, can be null for non-HTTP resources
*/
private ResourceHandle(final URI uri, final Kind kind, final HttpClient httpClient)
{
this.uri = uri;
this.kind = kind;
this.httpClient = httpClient;
}
/**
* Return the URI of the resource.
* @return the URI of the resource
*/
public URI asUri()
{
return this.uri;
}
/**
* Return the URL of the resource, if it can be transformed to a URL. If not, an Exception is thrown.
* @return the URI of the resource, if it can be transformed to a URL
* @throws IllegalStateException when the URI cannot be transformed into a URL
*/
public URL asUrl()
{
try
{
return this.uri.toURL();
}
catch (MalformedURLException e)
{
throw new IllegalStateException(e);
}
}
/**
* Return a readable stream of the resource for supported schemes.
* @return a readable stream of the resource for supported schemes
* @throws IOException when an I/O error occurs during building of the stream
*/
public InputStream openStream() throws IOException
{
switch (this.kind)
{
case FILE ->
{
return Files.newInputStream(Path.of(this.uri));
}
case JAR_ENTRY ->
{
JarCache.JarEntryPath entry = JarCache.resolveJarEntry(this.uri);
return Files.newInputStream(entry.path());
}
case HTTP ->
{
HttpRequest.Builder b = HttpRequest.newBuilder(this.uri).GET();
// Optional: add Basic auth if userInfo is present (discouraged, but supported)
String userInfo = this.uri.getUserInfo();
if (userInfo != null && !userInfo.isEmpty())
{
String enc =
Base64.getEncoder().encodeToString(userInfo.getBytes(java.nio.charset.StandardCharsets.UTF_8));
b.header("Authorization", "Basic " + enc);
}
try
{
HttpResponse<InputStream> resp =
this.httpClient.send(b.build(), HttpResponse.BodyHandlers.ofInputStream());
if (resp.statusCode() >= 200 && resp.statusCode() < 300)
{
return resp.body();
}
resp.body().close();
throw new IOException("HTTP " + resp.statusCode() + " for " + this.uri);
}
catch (InterruptedException ie)
{
throw new IOException("Interrupted while loading HTTP resource", ie);
}
}
case GENERIC_URL ->
{
return asUrl().openStream();
}
default -> throw new UnsupportedOperationException("Unknown kind: " + this.kind);
}
}
/**
* A Path is available for plain files and for JAR entries (mounted file system).
* @return a Path representation of the resource or null when no Path exists.
*/
public Path asPath()
{
try
{
return switch (this.kind)
{
case FILE -> Path.of(this.uri);
case JAR_ENTRY -> JarCache.resolveJarEntry(this.uri).path();
default -> null;
};
}
catch (Exception e)
{
return null;
}
}
/**
* Return whether the resource was loaded from the classpath.
* @return whether the resource was loaded from the classpath or not
*/
public boolean isClassPath()
{
return this.fromClasspath;
}
/**
* Return whether the resource is of type FILE.
* @return whether the resource is of type FILE or not
*/
public boolean isFile()
{
return this.kind.equals(Kind.FILE);
}
/**
* Return whether the resource is of type HTTP.
* @return whether the resource is of type HTTP or not
*/
public boolean isHttp()
{
return this.kind.equals(Kind.HTTP);
}
/**
* Return whether the resource is of type GENERIC_URL.
* @return whether the resource is of type GENERIC_URL or not
*/
public boolean isGenericUrl()
{
return this.kind.equals(Kind.GENERIC_URL);
}
/**
* Return whether the resource is of type JAR_ENTRY.
* @return whether the resource is of type JAR_ENTRY or not
*/
public boolean isJarEntry()
{
return this.kind.equals(Kind.JAR_ENTRY);
}
/**
* Mark that the resource was loaded from the classpath.
* @return the ResourceHandle for fluent design
*/
private ResourceHandle markClassPath()
{
this.fromClasspath = true;
return this;
}
/**
* Instantiate a ResourceHandle from a given Path.
* @param p the Path
* @return a ResourceHandle for the Path
*/
static ResourceHandle forFile(final Path p)
{
return new ResourceHandle(p.toUri(), Kind.FILE, null);
}
/**
* Instantiate a ResourceHandle from a jar-entry containing URI such as <code>jar:file:/.../lib.jar!/path/inside</code>.
* @param jarUri the jar-entry containing URI
* @return a ResourceHandle for the jar-entry containing URI
*/
static ResourceHandle forJarUri(final URI jarUri)
{
return new ResourceHandle(jarUri, Kind.JAR_ENTRY, null);
}
/**
* Instantiate a ResourceHandle for a given URI for a http-connection using the provided http-client.
* @param httpUri a URI for a http-connection
* @param http the http-client to use
* @return a ResourceHandle for the given URI for a http-connection
*/
static ResourceHandle forHttp(final URI httpUri, final HttpClient http)
{
return new ResourceHandle(httpUri, Kind.HTTP, http);
}
/**
* Instantiate a ResourceHandle from a given generic URL. Throw an exception when the resource handle cannot be
* instantiated from the URL.
* @param url the generic URL
* @return a ResourceHandle for the given URL
* @throws IllegalArgumentException when the resource handle cannot be instantiated from the URL
*/
static ResourceHandle forGenericUrl(final URL url)
{
try
{
return new ResourceHandle(url.toURI(), Kind.GENERIC_URL, null);
}
catch (URISyntaxException e)
{
throw new IllegalArgumentException(e);
}
}
/**
* Enum for the kind of resource handle: file, JAR entry, HTTP resource, or generic URL.
*/
enum Kind
{
/** File via the local file system, but not a JAR entry. */
FILE,
/** Entry within a JAR file, typically indicated with a "!/" inside the string. */
JAR_ENTRY,
/** File on the Internet that can be retrieved via the http(s) protocol. */
HTTP,
/** Any other type of URL that might point to the resource. */
GENERIC_URL
}
}
/**
* JAR FileSystem caching for FileSystems that need to be mounted when retrieving a Jar.
*/
private static final class JarCache
{
/** Cache JAR root URI -> FileSystem (weak so closed/reclaimed when unused). */
private static final Map<URI, FileSystem> FS_CACHE = new ConcurrentHashMap<>();
/**
* Record to return the combination of a FileSystem and a Path within that FileSystem.
* @param fs the FileSystem
* @param path the Path within the FileSystem
*/
record JarEntryPath(FileSystem fs, Path path)
{
}
/**
* Resolve a Jar entry, such as jar:file:/.../lib.jar!/path/in/jar.
* @param jarUri the URI that stores the JAR path
* @return a FileSystem plus Path record for the JAR entry
* @throws IllegalArgumentException when the URI does not contain a JAR entry
*/
static JarEntryPath resolveJarEntry(final URI jarUri)
{
String ssp = jarUri.getSchemeSpecificPart();
int bang = ssp.indexOf("!/");
if (bang < 0)
{
throw new IllegalArgumentException("Not a JAR URI: " + jarUri);
}
URI jarFileUri = URI.create(ssp.substring(0, bang)); // file:/.../lib.jar
String entry = ssp.substring(bang + 2); // path/in/jar
FileSystem fs = FS_CACHE.computeIfAbsent(jarFileUri, k ->
{
try
{
// Reuse if already mounted; else mount
try
{
return FileSystems.getFileSystem(URI.create("jar:" + k));
}
catch (FileSystemNotFoundException ignored)
{
return FileSystems.newFileSystem(URI.create("jar:" + k), Map.of());
}
}
catch (IOException e)
{
throw new UncheckedIOException(e);
}
});
return new JarEntryPath(fs, fs.getPath(entry));
}
}
}