software.amazon.smithy.model.loader.ModelDiscovery Maven / Gradle / Ivy
/*
* Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://aws.amazon.com/apache2.0
*
* or in the "license" file accompanying this file. This file is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/
package software.amazon.smithy.model.loader;
import static java.lang.String.format;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URL;
import java.net.URLConnection;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.logging.Logger;
import java.util.regex.Pattern;
/**
* Discovers Smithy models by finding all {@code META-INF/smithy/manifest}
* files on the class path and loading all of the newline separated relative
* model files referenced from the manifest.
*
* The URLs discovered through model discovery are imported into a
* {@code ModelAssembler} when {@link ModelAssembler#discoverModels} is
* called, providing a mechanism for discovering Smithy models on the
* classpath at runtime.
*
*
The format of a {@code META-INF/smithy/manifest} file is a newline
* separated UTF-8 text file in which each line is a resource that is
* relative to the manifest file. A line is considered to be terminated
* by any one of a line feed ('\n'), a carriage return ('\r'), a carriage
* return followed immediately by a line feed, or by reaching the end-of-file
* (EOF). The resources referenced by a manifest are loaded by resolving the
* relative resource against the URL that contains each {@code META-INF/smithy/manifest}
* file found using {@link ClassLoader#getResources}.
*
*
The following restrictions and interpretations apply to the names of
* model resources that can be placed on manifest lines:
*
*
* - Empty lines are ignored.
* - Lines that start with a number sign (#) are comments and are ignored.
* - Lines must contain only ASCII characters
* - Lines must not start with "/" or end with "/". Models are resolved
* as relative resources to the manifest URL and expected to be
* contained within the same JAR/JMOD as the manifest.
* - Lines must not contain empty segments (//).
* - Lines must not contain dot-dot segments (..).
* - Lines must not contain dot segments (/./) (./).
* - Lines must not contain spaces ( ) or tabs (\t).
* - Lines must not contain a backslash (\).
* - Lines must not contain a question mark (?).
* - Lines must not contain a percent sign (%).
* - Lines must not contain an asterisk (*).
* - Lines must not contain a colon (:).
* - Lines must not contain a vertical bar (|).
* - Lines must not contain a quote (") or (').
* - Lines must not contain greater than (>) or less than (<) signs.
* - Lines must not contain pound signs (#).
*
*
* For example, given the following {@code META-INF/smithy/manifest} file
* discovered at {@code jar:file:///C:/foo/baz/bar.jar!/META-INF/smithy/manifest}
* on the class path,
*
*
* smithy.example.traits.smithy
* foo/another.file.smithy
*
*
* Smithy will attempt to discover the following models:
*
*
* - {@code jar:file:///C:/foo/baz/bar.jar!/META-INF/smithy/smithy.example.traits.smithy}
* - {@code jar:file:///C:/foo/baz/bar.jar!/META-INF/smithy/foo/another.file.smithy}
*
*
* Models defined in {@code META-INF/smithy} should be named after the
* namespace that is defined within the file. Files that define multiple
* namespaces are free to use whatever naming scheming they choose, but
* model files should be globally unique in an application.
*/
public final class ModelDiscovery {
private static final Logger LOGGER = Logger.getLogger(ModelDiscovery.class.getName());
private static final String ROOT_RESOURCE_PATH = "META-INF/smithy/";
private static final String MANIFEST = "manifest";
private static final String MANIFEST_PATH = ROOT_RESOURCE_PATH + MANIFEST;
private static final Pattern PROHIBITED_RESOURCE_SEGMENT_CHARS = Pattern.compile("[\t\\\\?%*:|\"'><# ]+");
private ModelDiscovery() {}
/**
* Finds Smithy models using the thread context {@code ClassLoader}.
*
* @return Returns the URLs of each model referenced by manifests.
*/
public static List findModels() {
return findModels(Thread.currentThread().getContextClassLoader());
}
/**
* Finds Smithy models using the given {@code ClassLoader}.
*
* @param loader ClassLoader used to discover models.
* @return Returns the URLs of each model referenced by manifests.
*/
public static List findModels(ClassLoader loader) {
try {
List result = new ArrayList<>();
Enumeration manifests = loader.getResources(MANIFEST_PATH);
while (manifests.hasMoreElements()) {
result.addAll(findModels(manifests.nextElement()));
}
return result;
} catch (IOException e) {
throw new ModelManifestException("Error locating Smithy model manifests", e);
}
}
/**
* Parse the Smithy models from the given URL that points to a Smithy
* manifest file in a JAR.
*
* The provided URL is expected to point to a manifest stored in JAR
* (e.g., "jar:file:/example.jar!/META-INF/smithy/manifest).
*
* @param jarManifestUrl Manifest URL to parse line by line.
* @return Returns the URLs of each model referenced by the manifest.
*/
public static List findModels(URL jarManifestUrl) {
List result = new ArrayList<>();
LOGGER.finer(() -> "Found ModelDiscovery manifest at " + jarManifestUrl);
String modelUrlPrefix = jarManifestUrl.toString();
modelUrlPrefix = modelUrlPrefix.substring(0, modelUrlPrefix.length() - MANIFEST.length());
try {
for (String model : parseManifest(jarManifestUrl)) {
URL modelUrl = new URL(modelUrlPrefix + model);
LOGGER.finest(() -> format("Found Smithy model `%s` in manifest", modelUrl));
result.add(modelUrl);
}
} catch (IOException e) {
throw new ModelManifestException("Error parsing Smithy model manifest from " + jarManifestUrl, e);
}
return result;
}
/**
* Extracts the relative name of a Smithy model from a URL that points to
* a Smithy model returned from {@link #findModels()}.
*
* For example, given "jar:file:/example.jar!/META-INF/smithy/example.json",
* this method will return "example.json".
*
* @param modelUrl Model URL to get the name from.
* @return Returns the extracted name.
*/
public static String getSmithyModelPathFromJarUrl(URL modelUrl) {
String urlString = modelUrl.toString();
int position = urlString.indexOf(ROOT_RESOURCE_PATH);
if (position == -1) {
throw new IllegalArgumentException("Invalid Smithy model URL: " + modelUrl);
}
return urlString.substring(position + ROOT_RESOURCE_PATH.length());
}
/**
* Creates a URL that points to the Smithy manifest file of a JAR.
*
*
The provided {@code fileOrUrl} string can be an absolute path
* to a file, (e.g., "/foo/baz.jar"), a file URL (e.g., "file:/baz.jar"),
* or a JAR URL (e.g., "jar:file:/baz.jar").
*
* @param fileOrUrl Filename or URL that points to a JAR.
* @return Returns the computed URL.
*/
public static URL createSmithyJarManifestUrl(String fileOrUrl) {
try {
return new URL(getFilenameWithScheme(fileOrUrl) + "!/" + MANIFEST_PATH);
} catch (IOException e) {
throw new ModelImportException(e.getMessage(), e);
}
}
private static String getFilenameWithScheme(String filename) {
if (filename.startsWith("jar:")) {
return filename;
} else if (filename.startsWith("file:")) {
return "jar:" + filename;
} else {
return "jar:file:" + filename;
}
}
private static Set parseManifest(URL location) throws IOException {
Set models = new LinkedHashSet<>();
URLConnection connection = location.openConnection();
connection.setUseCaches(false);
try (InputStream input = connection.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) {
for (;;) {
String line = reader.readLine();
if (line == null) {
break;
} else if (!line.isEmpty()) {
if (line.charAt(0) == '#') {
// Ignore comments.
} else if (!isValidateResourceLine(line)) {
throw new ModelManifestException(format(
"Illegal Smithy model manifest syntax found in `%s`: `%s`", location, line));
} else {
models.add(line);
}
}
}
}
return models;
}
private static boolean isValidateResourceLine(String line) {
for (String segment : line.split("/")) {
// Ensure each segment is valid.
if (segment.isEmpty() || segment.equals(".") || segment.equals("..")) {
return false;
}
// Ensure the segment contains only allowed characters.
if (PROHIBITED_RESOURCE_SEGMENT_CHARS.matcher(segment).find()) {
return false;
}
}
// Don't allow trailing slashes.
return line.charAt(line.length() - 1) != '/';
}
}