ResourceLoader.java
package org.wikimedia.eventutilities.core.util;
import static com.google.common.collect.ImmutableList.toImmutableList;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.wikimedia.eventutilities.core.http.BasicHttpClient;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.io.Resources;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
/**
* Loads resource content at URIs.
* How the resource is loaded depends on the configured loader functions for
* a URL protocol scheme.
*
* Usage:
*
* <pre>{@code
* ResourceLoader resourceLoader = ResourceLoader.builder()
* .setBaseUrls(Arrays.asList(
* new URL
*
* }</pre>
*/
public class ResourceLoader {
/**
* Map of scheme/protocool to loader function.
*/
private final Map<String, Function<URI, byte[]>> loaders;
/**
* Default loader function used if a URI's scheme is not in loaders map.
*/
private final Function<URI, byte[]> defaultLoader;
/**
* Base URLs to resolve relative URIs in.
*/
private final List<URL> baseUrls;
private static final Logger LOG = LoggerFactory.getLogger(ResourceLoader.class.getName());
/**
*
* @param loaders
* Map of URI protocol/scheme (e.g. file, http, https, etc.) to
* {@link Function} that takes a {@link URI} and returns a byte[] of the content at the URI.
*
* @param defaultLoader
* Default loader to use if no loader exists for the URI's scheme.
*
* @param baseUrls
* baseUrls prefixes that act like a relative URI search path.
* When loading a uri, if that uri is relative, each baseUrl will
* be prefixed to it and then attempted to be loaded.
* Whichever baseUrl + uri successfully loads first will be returned.
*/
@SuppressFBWarnings(value = "OCP_OVERLY_CONCRETE_PARAMETER", justification = "ImmutableList.copyOf is confusing spotbug")
public ResourceLoader(
Map<String, Function<URI, byte[]>> loaders,
Function<URI, byte[]> defaultLoader,
List<URL> baseUrls
) {
this.loaders = ImmutableMap.copyOf(loaders);
this.defaultLoader = defaultLoader;
this.baseUrls = ImmutableList.copyOf(baseUrls);
}
/**
* Loads the resource at uri, potentially prefixing relative URIs with baseUrls.
*
* @param uri {@link URI}
* @return contents as bute[] at uri
*/
public byte[] load(URI uri) throws ResourceLoadingException {
return loadFirst(getPossibleResourceUris(uri));
}
/**
* Calls the loader function for the uri's scheme.
* If the uri is not absolute, or if no loader for the uri scheme exists,
* this will use the defaultLoader.
*/
private byte[] fetch(URI uri) {
Function<URI, byte[]> loader = defaultLoader;
if (uri.isAbsolute() && loaders.containsKey(uri.getScheme())) {
loader = loaders.get(uri.getScheme());
}
return loader.apply(uri);
}
/**
* Attempts to fetch the content at each uri, and returns the first fetch result that succeeds.
*/
@SuppressWarnings("checkstyle:IllegalCatch")
private byte[] loadFirst(List<URI> uris) throws ResourceLoadingException {
byte[] content = null;
List<ResourceLoadingException> loadingExceptions = new ArrayList<>();
for (URI uri: uris) {
try {
content = this.fetch(uri);
break;
} catch (Exception e) {
loadingExceptions.add(new ResourceLoadingException(uri, "Failed loading resource.", e));
}
}
if (content != null) {
return content;
} else {
// If we failed loading a schema but we encountered any ResourceLoadingException
// while trying, log them all but only throw the first one.
if (!loadingExceptions.isEmpty()) {
for (ResourceLoadingException e: loadingExceptions) {
LOG.error("Caught exception when trying to load resource.", e);
}
throw loadingExceptions.get(0);
} else {
// This shouldn't happen, as content should not be null if loadingExceptions is empty.
throw new RuntimeException(this + " failed loading resource in list of URIs: " + uris);
}
}
}
/**
* If the uri is aboslute, or if no baseUris are set, the only possible
* uri is the provided one. Else, the uri will be prefixed with each of the baseUris.
*/
public List<URI> getPossibleResourceUris(URI uri) {
if (uri.isAbsolute() || baseUrls.isEmpty()) {
return ImmutableList.of(uri);
} else {
return baseUrls.stream().map(baseUrl -> {
try {
return new URI(baseUrl.toString() + uri.toString());
} catch (java.net.URISyntaxException e) {
throw new IllegalArgumentException(
"Failed building new URI with " + baseUrl + " + " + uri + ". ", e
);
}
}).collect(toImmutableList());
}
}
/**
* Returns a ResourceLoader.Builder.
*/
public static Builder builder() {
return new Builder();
}
/**
* Builder class for ResouceLoader.
*/
public static class Builder {
private Map<String, Function<URI, byte[]>> loaders = new HashMap<>();
private Function<URI, byte[]> defaultLoader;
private List<URL> baseUrls = new ArrayList<>();
public Builder() {
defaultLoader = uri -> {
try {
return Resources.toByteArray(uri.toURL());
} catch (IOException e) {
throw new UncheckedIOException(e);
}
};
}
/**
* Adds a loader function for a URI scheme.
*/
public Builder addLoader(String scheme, Function<URI, byte[]> loader) {
loaders.put(scheme, loader);
return this;
}
/**
* Sets the default loader. If this is not called, the defaultLoader
* will use {@link Resources}.toByteArray.
*/
public Builder setDefaultLoader(Function<URI, byte[]> loader) {
defaultLoader = loader;
return this;
}
// TODO: Shouldn't we always build with an httpClient by default?
/**
* Adds loaders for http and https using {@link BasicHttpClient}.
*/
public Builder withHttpClient(BasicHttpClient httpClient) {
loaders.put("http", httpClient::getAsBytes);
loaders.put("https", httpClient::getAsBytes);
return this;
}
/**
* Adds loaders for http and https using a default {@link BasicHttpClient}.
*/
public Builder withHttpClient() {
BasicHttpClient httpClient = BasicHttpClient.builder().build();
return withHttpClient(httpClient);
}
/**
* Sets the baseUrls.
*/
@SuppressFBWarnings(value = "OCP_OVERLY_CONCRETE_PARAMETER", justification = "ImmutableList.copyOf is confusing spotbug")
public Builder setBaseUrls(List<URL> baseUrls) {
this.baseUrls = ImmutableList.copyOf(baseUrls);
return this;
}
public ResourceLoader build() {
return new ResourceLoader(ImmutableMap.copyOf(loaders), defaultLoader, baseUrls);
}
}
/**
* Helper function to convert a String url to a URL.
*/
public static URL asURL(String u) {
try {
return new URL(u);
} catch (MalformedURLException e) {
throw new IllegalArgumentException("baseUrl string " + u + "could be converted to URL.", e);
}
}
/**
* Helper function to convert a List of String urls to URLs.
*/
public static List<URL> asURLs(Collection<String> baseUrls) {
return baseUrls.stream().map(ResourceLoader::asURL).collect(toImmutableList());
}
public String toString() {
return "ResourceLoader([" + String.join(",", baseUrls.toString()) + "])";
}
}