/** * Jooby https://jooby.io * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ package io.jooby; import com.typesafe.config.Config; import io.jooby.exception.MissingValueException; import org.slf4j.Logger; import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.inject.Provider; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.Executor; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.IntStream; import java.util.stream.Stream; import static java.util.Arrays.asList; import static java.util.Collections.unmodifiableList; import static java.util.Objects.requireNonNull; /** * Routing DSL functions. * * @since 2.0.0 * @author edgar */ public interface Router extends Registry { /** * Find route result. */ interface Match { /** * True for matching route. * * @return True for matching route. */ boolean matches(); /** * Matched route. * * @return Matched route. */ @Nonnull Route route(); void execute(@Nonnull Context context); /** * Path pattern variables. * * @return Path pattern variables. */ @Nonnull Map pathMap(); } /** HTTP GET. */ String GET = "GET"; /** HTTP POST. */ String POST = "POST"; /** HTTP PUT. */ String PUT = "PUT"; /** HTTP DELETE. */ String DELETE = "DELETE"; /** HTTP PATCH. */ String PATCH = "PATCH"; /** HTTP HEAD. */ String HEAD = "HEAD"; /** HTTP OPTIONS. */ String OPTIONS = "OPTIONS"; /** HTTP TRACE. */ String TRACE = "TRACE"; /** HTTP Methods. */ List METHODS = unmodifiableList( asList(GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS, TRACE)); /** Web socket. */ String WS = "WS"; /** Sever-Sent events. */ String SSE = "SSE"; /** * Application configuration. * * @return Application configuration. */ @Nonnull Config getConfig(); /** * Application environment. * * @return Application environment. */ @Nonnull Environment getEnvironment(); /** * Returns the supported locales. * * @return The supported locales. */ @Nonnull List getLocales(); /** * Mutable map of application attributes. * * @return Mutable map of application attributes. */ @Nonnull Map getAttributes(); /** * Get an attribute by his key. This is just an utility method around {@link #getAttributes()}. * * @param key Attribute key. * @param Attribute type. * @return Attribute value. */ @Nonnull default T attribute(@Nonnull String key) { T attribute = (T) getAttributes().get(key); if (attribute == null) { throw new MissingValueException(key); } return attribute; } /** * Set an application attribute. * * @param key Attribute key. * @param value Attribute value. * @return This router. */ @Nonnull default Router attribute(@Nonnull String key, Object value) { getAttributes().put(key, value); return this; } /** * Application service registry. Services are accessible via this registry or * {@link Jooby#require(Class)} calls. * * This method returns a mutable registry. You are free to modify/alter the registry. * * @return Service registry. */ @Nonnull ServiceRegistry getServices(); /** * Set application context path. Context path is the base path for all routes. Default is: * /. * * @param contextPath Context path. * @return This router. */ @Nonnull Router setContextPath(@Nonnull String contextPath); /** * Get application context path (a.k.a as base path). * * @return Application context path (a.k.a as base path). */ @Nonnull String getContextPath(); /** * When true handles X-Forwarded-* headers by updating the values on the current context to * match what was sent in the header(s). * * This should only be installed behind a reverse proxy that has been configured to send the * X-Forwarded-* header, otherwise a remote user can spoof their address by * sending a header with bogus values. * * The headers that are read/set are: *
    *
  • X-Forwarded-For: Set/update the remote address {@link Context#setRemoteAddress(String)}.
  • *
  • X-Forwarded-Proto: Set/update request scheme {@link Context#setScheme(String)}.
  • *
  • X-Forwarded-Host: Set/update the request host {@link Context#setHost(String)}.
  • *
  • X-Forwarded-Port: Set/update the request port {@link Context#setPort(int)}.
  • *
* * @return True when enabled. Default is false. */ boolean isTrustProxy(); /** * When true handles X-Forwarded-* headers by updating the values on the current context to * match what was sent in the header(s). * * This should only be installed behind a reverse proxy that has been configured to send the * X-Forwarded-* header, otherwise a remote user can spoof their address by * sending a header with bogus values. * * The headers that are read/set are: *
    *
  • X-Forwarded-For: Set/update the remote address {@link Context#setRemoteAddress(String)}.
  • *
  • X-Forwarded-Proto: Set/update request scheme {@link Context#setScheme(String)}.
  • *
  • X-Forwarded-Host: Set/update the request host {@link Context#setHost(String)}.
  • *
  • X-Forwarded-Port: Set/update the request port {@link Context#setPort(int)}.
  • *
* * @param trustProxy True to enabled. * @return This router. */ @Nonnull Router setTrustProxy(boolean trustProxy); /** * Provides a way to override the current HTTP method. Request must be: * * - POST Form/multipart request * * For alternative strategy use the {@link #setHiddenMethod(Function)} method. * * @param parameterName Form field name. * @return This router. */ @Nonnull Router setHiddenMethod(@Nonnull String parameterName); /** * Provides a way to override the current HTTP method using lookup strategy. * * @param provider Lookup strategy. * @return This router. */ @Nonnull Router setHiddenMethod(@Nonnull Function> provider); /** * Provides a way to set the current user from a {@link Context}. Current user can be retrieve it * using {@link Context#getUser()}. * * @param provider User provider/factory. * @return This router. */ @Nonnull Router setCurrentUser(@Nullable Function provider); /** * If enabled, allows to retrieve the {@link Context} object associated with the current * request via the service registry while the request is being processed. * * @param contextAsService whether to enable or disable this feature * @return This router. */ @Nonnull Router setContextAsService(boolean contextAsService); /* *********************************************************************************************** * use(Router) * *********************************************************************************************** */ /** * Enabled routes for specific domain. Domain matching is done using the host header. * *
{@code
   * {
   *   domain("foo.com", new FooApp());
   *   domain("bar.com", new BarApp());
   * }
   * }
* * NOTE: if you run behind a reverse proxy you might to enabled {@link #setTrustProxy(boolean)}. * * NOTE: ONLY routes are imported. Services, callback, etc.. are ignored. * * @param domain Predicate * @param subrouter Subrouter. * @return This router. */ @Nonnull Router domain(@Nonnull String domain, @Nonnull Router subrouter); /** * Enabled routes for specific domain. Domain matching is done using the host header. * *
{@code
   * {
   *   domain("foo.com", () -> {
   *     get("/", ctx -> "foo");
   *   });
   *   domain("bar.com", () -> {
   *     get("/", ctx -> "bar");
   *   });
   * }
   * }
* * NOTE: if you run behind a reverse proxy you might to enabled {@link #setTrustProxy(boolean)}. * * @param domain Predicate * @param body Route action. * @return This router. */ @Nonnull RouteSet domain(@Nonnull String domain, @Nonnull Runnable body); /** * Import routes from given router. Predicate works like a filter and only when predicate pass * the routes match against the current request. * * Example of domain predicate filter: * *
{@code
   * {
   *   use(ctx -> ctx.getHost().equals("foo.com"), new FooApp());
   *   use(ctx -> ctx.getHost().equals("bar.com"), new BarApp());
   * }
   * }
* * Imported routes are matched only when predicate pass. * * NOTE: if you run behind a reverse proxy you might to enabled {@link #setTrustProxy(boolean)}. * * NOTE: ONLY routes are imported. Services, callback, etc.. are ignored. * * @param predicate Context predicate. * @param router Router to import. * @return This router. * @deprecated Use {@link #mount(Predicate, Router)} */ @Deprecated @Nonnull default Router use(@Nonnull Predicate predicate, @Nonnull Router router) { return mount(predicate, router); } /** * Import routes from given router. Predicate works like a filter and only when predicate pass * the routes match against the current request. * * Example of domain predicate filter: * *
{@code
   * {
   *   use(ctx -> ctx.getHost().equals("foo.com"), new FooApp());
   *   use(ctx -> ctx.getHost().equals("bar.com"), new BarApp());
   * }
   * }
* * Imported routes are matched only when predicate pass. * * NOTE: if you run behind a reverse proxy you might to enabled {@link #setTrustProxy(boolean)}. * * NOTE: ONLY routes are imported. Services, callback, etc.. are ignored. * * @param predicate Context predicate. * @param router Router to import. * @return This router. */ @Nonnull Router mount(@Nonnull Predicate predicate, @Nonnull Router router); /** * Import routes from given action. Predicate works like a filter and only when predicate pass * the routes match against the current request. * * Example of domain predicate filter: * *
{@code
   * {
   *   use(ctx -> ctx.getHost().equals("foo.com"), () -> {
   *     get("/", ctx -> "foo");
   *   });
   *   use(ctx -> ctx.getHost().equals("bar.com"), () -> {
   *     get("/", ctx -> "bar");
   *   });
   * }
   * }
* * NOTE: if you run behind a reverse proxy you might to enabled {@link #setTrustProxy(boolean)}. * * @param predicate Context predicate. * @param body Route action. * @return This router. * @deprecated Use {@link #mount(Predicate, Runnable)} */ @Deprecated @Nonnull default RouteSet use(@Nonnull Predicate predicate, @Nonnull Runnable body) { return mount(predicate, body); } /** * Import routes from given action. Predicate works like a filter and only when predicate pass * the routes match against the current request. * * Example of domain predicate filter: * *
{@code
   * {
   *   mount(ctx -> ctx.getHost().equals("foo.com"), () -> {
   *     get("/", ctx -> "foo");
   *   });
   *   mount(ctx -> ctx.getHost().equals("bar.com"), () -> {
   *     get("/", ctx -> "bar");
   *   });
   * }
   * }
* * NOTE: if you run behind a reverse proxy you might to enabled {@link #setTrustProxy(boolean)}. * * NOTE: ONLY routes are imported. Services, callback, etc.. are ignored. * * @param predicate Context predicate. * @param body Route action. * @return This router. */ @Nonnull RouteSet mount(@Nonnull Predicate predicate, @Nonnull Runnable body); /** * Import all routes from the given router and prefix them with the given path. * * NOTE: ONLY routes are imported. Services, callback, etc.. are ignored. * * @param path Prefix path. * @param router Router to import. * @return This router. * @deprecated Use {@link #mount(String, Router)} */ @Deprecated @Nonnull default Router use(@Nonnull String path, @Nonnull Router router) { return mount(path, router); } /** * Import all routes from the given router and prefix them with the given path. * * NOTE: ONLY routes are imported. Services, callback, etc.. are ignored. * * @param path Prefix path. * @param router Router to import. * @return This router. */ @Nonnull Router mount(@Nonnull String path, @Nonnull Router router); /** * Import all routes from the given router. * * NOTE: ONLY routes are imported. Services, callback, etc.. are ignored. * * @param router Router to import. * @return This router. * @deprecated Use {@link #mount(Router)} */ @Deprecated @Nonnull default Router use(@Nonnull Router router) { return mount(router); } /** * Import all routes from the given router. * * NOTE: ONLY routes are imported. Services, callback, etc.. are ignored. * * @param router Router to import. * @return This router. */ @Nonnull Router mount(@Nonnull Router router); /* *********************************************************************************************** * Mvc * *********************************************************************************************** */ /** * Import all route method from the given controller class. At runtime the controller instance * is resolved by calling {@link Jooby#require(Class)}. * * @param router Controller class. * @return This router. */ @Nonnull Router mvc(@Nonnull Class router); /** * Import all route method from the given controller class. * * @param router Controller class. * @param provider Controller provider. * @param Controller type. * @return This router. */ @Nonnull Router mvc(@Nonnull Class router, @Nonnull Provider provider); /** * Import all route methods from given controller instance. * * @param router Controller instance. * @return This routes. */ @Nonnull Router mvc(@Nonnull Object router); /** * Add a websocket handler. * * @param pattern WebSocket path pattern. * @param handler WebSocket handler. * @return A new route. */ @Nonnull Route ws(@Nonnull String pattern, @Nonnull WebSocket.Initializer handler); /** * Add a server-sent event handler. * * @param pattern Path pattern. * @param handler Handler. * @return A new route. */ @Nonnull Route sse(@Nonnull String pattern, @Nonnull ServerSentEmitter.Handler handler); /** * Returns all routes. * * @return All routes. */ @Nonnull List getRoutes(); /** * Register a route response encoder. * * @param encoder MessageEncoder instance. * @return This router. */ @Nonnull Router encoder(@Nonnull MessageEncoder encoder); /** * Register a route response encoder. * * @param contentType Accept header should matches the content-type. * @param encoder MessageEncoder instance. * @return This router. */ @Nonnull Router encoder(@Nonnull MediaType contentType, @Nonnull MessageEncoder encoder); /** * Application temporary directory. This method initialize the {@link Environment} when isn't * set manually. * * @return Application temporary directory. */ @Nonnull Path getTmpdir(); /** * Register a decoder for the given content type. * * @param contentType Content type to match. * @param decoder MessageDecoder. * @return This router. */ @Nonnull Router decoder(@Nonnull MediaType contentType, @Nonnull MessageDecoder decoder); /** * Returns the worker thread pool. This thread pool is used to run application blocking code. * * @return Worker thread pool. */ @Nonnull Executor getWorker(); /** * Set a worker thread pool. This thread pool is used to run application blocking code. * * @param worker Worker thread pool. * @return This router. */ @Nonnull Router setWorker(@Nonnull Executor worker); /** * Set the default worker thread pool. Via this method the underlying web server set/suggests the * worker thread pool that should be used it. * * A call to {@link #getWorker()} returns the default thread pool, unless you explicitly set one. * * @param worker Default worker thread pool. * @return This router. */ @Nonnull Router setDefaultWorker(@Nonnull Executor worker); /** * Add a route decorator to the route pipeline. * * @param decorator Decorator. * @return This router. */ @Nonnull Router decorator(@Nonnull Route.Decorator decorator); /** * Add a before route decorator to the route pipeline. * * @param before Before decorator. * @return This router. */ @Nonnull Router before(@Nonnull Route.Before before); /** * Add an after route decorator to the route pipeline. * * @param after After decorator. * @return This router. */ @Nonnull Router after(@Nonnull Route.After after); /** * Dispatch route pipeline to the {@link #getWorker()} worker thread pool. After dispatch * application code is allowed to do blocking calls. * * @param body Dispatch body. * @return This router. */ @Nonnull Router dispatch(@Nonnull Runnable body); /** * Dispatch route pipeline to the given executor. After dispatch application code is allowed to * do blocking calls. * * @param executor Executor. {@link java.util.concurrent.ExecutorService} instances automatically * shutdown at application exit. * @param body Dispatch body. * @return This router. */ @Nonnull Router dispatch(@Nonnull Executor executor, @Nonnull Runnable body); /** * Group one or more routes. Useful for applying cross cutting concerns to the enclosed routes. * * @param body Route body. * @return All routes created. */ @Nonnull RouteSet routes(@Nonnull Runnable body); /** * Group one or more routes under a common path prefix. Useful for applying cross cutting * concerns to the enclosed routes. * * @param pattern Path pattern. * @param body Route body. * @return All routes created. */ @Nonnull RouteSet path(@Nonnull String pattern, @Nonnull Runnable body); /** * Add a HTTP GET handler. * * @param pattern Path pattern. * @param handler Application code. * @return A route. */ @Nonnull default Route get(@Nonnull String pattern, @Nonnull Route.Handler handler) { return route(GET, pattern, handler); } /** * Add a HTTP POST handler. * * @param pattern Path pattern. * @param handler Application code. * @return A route. */ @Nonnull default Route post(@Nonnull String pattern, @Nonnull Route.Handler handler) { return route(POST, pattern, handler); } /** * Add a HTTP PUT handler. * * @param pattern Path pattern. * @param handler Application code. * @return A route. */ @Nonnull default Route put(@Nonnull String pattern, @Nonnull Route.Handler handler) { return route(PUT, pattern, handler); } /** * Add a HTTP DELETE handler. * * @param pattern Path pattern. * @param handler Application code. * @return A route. */ @Nonnull default Route delete(@Nonnull String pattern, @Nonnull Route.Handler handler) { return route(DELETE, pattern, handler); } /** * Add a HTTP PATCH handler. * * @param pattern Path pattern. * @param handler Application code. * @return A route. */ @Nonnull default Route patch(@Nonnull String pattern, @Nonnull Route.Handler handler) { return route(PATCH, pattern, handler); } /** * Add a HTTP HEAD handler. * * @param pattern Path pattern. * @param handler Application code. * @return A route. */ @Nonnull default Route head(@Nonnull String pattern, @Nonnull Route.Handler handler) { return route(HEAD, pattern, handler); } /** * Add a HTTP OPTIONS handler. * * @param pattern Path pattern. * @param handler Application code. * @return A route. */ @Nonnull default Route options(@Nonnull String pattern, @Nonnull Route.Handler handler) { return route(OPTIONS, pattern, handler); } /** * Add a HTTP TRACE handler. * * @param pattern Path pattern. * @param handler Application code. * @return A route. */ @Nonnull default Route trace(@Nonnull String pattern, @Nonnull Route.Handler handler) { return route(TRACE, pattern, handler); } /** * Add a static resource handler. Static resources are resolved from file system. * * @param pattern Path pattern. * @param source File system directory. * @return A route. */ default @Nonnull Route assets(@Nonnull String pattern, @Nonnull Path source) { return assets(pattern, AssetSource.create(source)); } /** * Add a static resource handler. Static resources are resolved from: * * - file-system if the source folder exists in the current user directory * - or fallback to classpath when file-system folder doesn't exist. * * NOTE: This method choose file-system or classpath, it doesn't merge them. * * @param pattern Path pattern. * @param source File-System folder when exists, or fallback to a classpath folder. * @return A route. */ default @Nonnull Route assets(@Nonnull String pattern, @Nonnull String source) { Path path = Stream.of(source.split("/")) .reduce(Paths.get(System.getProperty("user.dir")), Path::resolve, Path::resolve); if (Files.exists(path)) { return assets(pattern, path); } return assets(pattern, AssetSource.create(getClass().getClassLoader(), source)); } /** * Add a static resource handler. * * @param pattern Path pattern. * @param sources Asset sources. At least one source is required. * @return A route. */ default @Nonnull Route assets(@Nonnull String pattern, @Nonnull AssetSource... sources) { return assets(pattern, new AssetHandler(sources)); } /** * Add a static resource handler. * * @param pattern Path pattern. * @param handler Asset handler. * @return A route. */ default @Nonnull Route assets(@Nonnull String pattern, @Nonnull AssetHandler handler) { return route(GET, pattern, handler); } /** * Add a route. * * @param method HTTP method. * @param pattern Path pattern. * @param handler Application code. * @return A route. */ @Nonnull Route route(@Nonnull String method, @Nonnull String pattern, @Nonnull Route.Handler handler); /** * Find a matching route using the given context. * * If no match exists this method returns a route with a 404 handler. * See {@link Route#NOT_FOUND}. * * @param ctx Web Context. * @return A route match result. */ @Nonnull Match match(@Nonnull Context ctx); /** * Find a matching route using the given context. * * If no match exists this method returns a route with a 404 handler. * See {@link Route#NOT_FOUND}. * * @param method Method to match. * @param path Path to match. * @return A route match result. */ boolean match(@Nonnull String method, @Nonnull String path); /* Error handler: */ /** * Map an exception type to a status code. * * @param type Exception type. * @param statusCode Status code. * @return This router. */ @Nonnull Router errorCode(@Nonnull Class type, @Nonnull StatusCode statusCode); /** * Computes the status code for the given exception. * * @param cause Exception. * @return Status code. */ @Nonnull StatusCode errorCode(@Nonnull Throwable cause); /** * Add a custom error handler that matches the given status code. * * @param statusCode Status code. * @param handler Error handler. * @return This router. */ @Nonnull default Router error(@Nonnull StatusCode statusCode, @Nonnull ErrorHandler handler) { return error(statusCode::equals, handler); } /** * Add a custom error handler that matches the given exception type. * * @param type Exception type. * @param handler Error handler. * @return This router. */ @Nonnull default Router error(@Nonnull Class type, @Nonnull ErrorHandler handler) { return error((ctx, x, statusCode) -> { if (type.isInstance(x) || type.isInstance(x.getCause())) { handler.apply(ctx, x, statusCode); } }); } /** * Add a custom error handler that matches the given predicate. * * @param predicate Status code filter. * @param handler Error handler. * @return This router. */ @Nonnull default Router error(@Nonnull Predicate predicate, @Nonnull ErrorHandler handler) { return error((ctx, x, statusCode) -> { if (predicate.test(statusCode)) { handler.apply(ctx, x, statusCode); } }); } /** * Add a custom error handler. * * @param handler Error handler. * @return This router. */ @Nonnull Router error(@Nonnull ErrorHandler handler); /** * Get the error handler. * * @return An error handler. */ @Nonnull ErrorHandler getErrorHandler(); /** * Application logger. * * @return Application logger. */ @Nonnull Logger getLog(); /** * Add a response handler factory. * * @param factory Response handler factory. * @return This router. */ @Nonnull Router responseHandler(@Nonnull ResponseHandler factory); /** * Router options. * * @return Router options. */ @Nonnull Set getRouterOptions(); /** * Set router options. * * @param options router options. * @return This router. */ @Nonnull Router setRouterOptions(@Nonnull RouterOption... options); /** * Session store. Default use a cookie ID with a memory storage. * * See {@link SessionStore#memory()}. * * @return Session store. */ @Nonnull SessionStore getSessionStore(); /** * Set session store. * * @param store Session store. * @return This router. */ @Nonnull Router setSessionStore(@Nonnull SessionStore store); /** * Get an executor from application registry. * * @param name Executor name. * @return Executor. */ default @Nonnull Executor executor(@Nonnull String name) { return require(Executor.class, name); } /** * Put an executor into the application registry. * * @param name Executor's name. * @param executor Executor. * @return This router. */ @Nonnull Router executor(@Nonnull String name, @Nonnull Executor executor); /** * Set flash cookie name. * * @param name Flash cookie name. * @return This router. * @deprecated Use {@link #setFlashCookie(Cookie)} instead. */ @Deprecated @Nonnull Router setFlashCookie(@Nonnull String name); /** * Template for the flash cookie. Default name is: jooby.flash. * * @return Template for the flash cookie. */ @Nonnull Cookie getFlashCookie(); /** * Sets a cookie used as a template to generate the flash cookie, allowing * to customize the cookie name and other cookie parameters. * * @param flashCookie The cookie template. * @return This router. */ @Nonnull Router setFlashCookie(@Nonnull Cookie flashCookie); /** * Add a custom string value converter. * * @param converter Custom value converter. * @return This router. */ @Nonnull Router converter(@Nonnull ValueConverter converter); /** * Add a custom bean value converter. * * @param converter Custom value converter. * @return This router. */ @Nonnull Router converter(@Nonnull BeanConverter converter); /** * Get all simple/string value converters. * * @return All simple/string value converters. */ @Nonnull List getConverters(); /** * Get all complex/bean value converters. * * @return All complex/bean value converters. */ @Nonnull List getBeanConverters(); /** * Available server options. * * @return Server options. */ @Nonnull ServerOptions getServerOptions(); /** * Ensure path start with a /(leading slash). * * @param path Path to process. * @return Path with leading slash. */ static @Nonnull String leadingSlash(@Nullable String path) { if (path == null || path.length() == 0 || path.equals("/")) { return "/"; } return path.charAt(0) == '/' ? path : "/" + path; } /** * Strip trailing slashes. * * @param path Path to process. * @return Path without trailing slashes. */ static @Nonnull String noTrailingSlash(@Nonnull String path) { StringBuilder buff = new StringBuilder(path); int i = buff.length() - 1; while (i > 0 && buff.charAt(i) == '/') { buff.setLength(i); i -= 1; } if (path.length() != buff.length()) { return buff.toString(); } return path; } /** * Normalize a path by removing consecutive /(slashes). * * @param path Path to process. * @return Safe path pattern. */ static @Nonnull String normalizePath(@Nullable String path) { if (path == null || path.length() == 0 || path.equals("/")) { return "/"; } int len = path.length(); boolean modified = false; int p = 0; char[] buff = new char[len + 1]; if (path.charAt(0) != '/') { buff[p++] = '/'; modified = true; } for (int i = 0; i < path.length(); i++) { char ch = path.charAt(i); if (ch != '/') { buff[p++] = ch; } else if (i == 0 || path.charAt(i - 1) != '/') { buff[p++] = ch; } else { // double slash modified = true; } } // creates string? return modified ? new String(buff, 0, p) : path; } /** * Extract path keys from given path pattern. A path key (a.k.a path variable) looks like: * *
/product/{id}
* * @param pattern Path pattern. * @return Path keys. */ static @Nonnull List pathKeys(@Nonnull String pattern) { return pathKeys(pattern, (k, v) -> { }); } /** * Extract path keys from given path pattern. A path key (a.k.a path variable) looks like: * *
/product/{id}
* * @param pattern Path pattern. * @param consumer Listen for key and regex variables found. * @return Path keys. */ static @Nonnull List pathKeys(@Nonnull String pattern, BiConsumer consumer) { List result = new ArrayList<>(); int start = -1; int end = Integer.MAX_VALUE; int len = pattern.length(); int curly = 0; for (int i = 0; i < len; i++) { char ch = pattern.charAt(i); if (ch == '{') { if (curly == 0) { start = i + 1; end = Integer.MAX_VALUE; } curly += 1; } else if (ch == ':') { end = i; } else if (ch == '}') { curly -= 1; if (curly == 0) { String id = pattern.substring(start, Math.min(i, end)); String value; if (end == Integer.MAX_VALUE) { value = null; } else { value = pattern.substring(end + 1, i); } consumer.accept(id, value); result.add(id); start = -1; end = Integer.MAX_VALUE; } } else if (ch == '*') { String id; if (i == len - 1) { id = "*"; } else { id = pattern.substring(i + 1); } result.add(id); consumer.accept(id, "\\.*"); i = len; } } switch (result.size()) { case 0: return Collections.emptyList(); case 1: return Collections.singletonList(result.get(0)); default: return unmodifiableList(result); } } /** * Look for optional path parameter and expand the given pattern into multiple pattern. * *
   *   /path => [/path]
   *   /{id} => [/{id}]
   *   /path/{id} => [/path/{id}]
   *
   *   /{id}? => [/, /{id}]
   *   /path/{id}? => [/path, /path/{id}]
   *   /path/{id}/{start}?/{end}? => [/path/{id}, /path/{id}/{start}, /path/{id}/{start}/{end}]
   *   /path/{id}?/suffix => [/path, /path/{id}, /path/suffix]
   * 
* * @param pattern Pattern. * @return One or more patterns. */ static @Nonnull List expandOptionalVariables(@Nonnull String pattern) { if (pattern == null || pattern.isEmpty() || pattern.equals("/")) { return Collections.singletonList("/"); } int len = pattern.length(); AtomicInteger key = new AtomicInteger(); Map paths = new HashMap<>(); BiConsumer pathAppender = (index, segment) -> { for (int i = index; i < index - 1; i++) { paths.get(i).append(segment); } paths.computeIfAbsent(index, current -> { StringBuilder value = new StringBuilder(); if (current > 0) { StringBuilder previous = paths.get(current - 1); if (!previous.toString().equals("/")) { value.append(previous); } } return value; }).append(segment); }; StringBuilder segment = new StringBuilder(); boolean isLastOptional = false; for (int i = 0; i < len;) { char ch = pattern.charAt(i); if (ch == '/') { if (segment.length() > 0) { pathAppender.accept(key.get(), segment); segment.setLength(0); } segment.append(ch); i += 1; } else if (ch == '{') { segment.append(ch); int curly = 1; int j = i + 1; while (j < len) { char next = pattern.charAt(j++); segment.append(next); if (next == '{') { curly += 1; } else if (next == '}') { curly -= 1; if (curly == 0) { break; } } } if (j < len && pattern.charAt(j) == '?') { j += 1; isLastOptional = true; if (paths.isEmpty()) { paths.put(0, new StringBuilder("/")); } pathAppender.accept(key.incrementAndGet(), segment); } else { isLastOptional = false; pathAppender.accept(key.get(), segment); } segment.setLength(0); i = j; } else { segment.append(ch); i += 1; } } if (paths.isEmpty()) { return Collections.singletonList(pattern); } if (segment.length() > 0) { pathAppender.accept(key.get(), segment); if (isLastOptional) { paths.put(key.incrementAndGet(), segment); } } return paths.values().stream() .map(StringBuilder::toString) .collect(Collectors.toList()); } /** * Recreate a path pattern using the given variables. Variable replacement is done using the * current index. * * @param pattern Path pattern. * @param values Path keys. * @return Path. */ static @Nonnull String reverse(@Nonnull String pattern, @Nonnull Object... values) { Map keys = new HashMap<>(); IntStream.range(0, values.length).forEach(k -> keys.put(Integer.toString(k), values[k])); return reverse(pattern, keys); } /** * Recreate a path pattern using the given variables. * * @param pattern Path pattern. * @param keys Path keys. * @return Path. */ static @Nonnull String reverse(@Nonnull String pattern, @Nonnull Map keys) { StringBuilder path = new StringBuilder(); int start = 0; int end = Integer.MAX_VALUE; int len = pattern.length(); int keyIdx = 0; for (int i = 0; i < len; i++) { char ch = pattern.charAt(i); if (ch == '{') { path.append(pattern, start, i); start = i + 1; end = Integer.MAX_VALUE; } else if (ch == ':') { end = i; } else if (ch == '}') { String id = pattern.substring(start, Math.min(i, end)); Object value = keys.getOrDefault(id, keys.get(Integer.toString(keyIdx++))); requireNonNull(value, "Missing key: '" + id + "'"); path.append(value); start = i + 1; end = Integer.MAX_VALUE; } else if (ch == '*') { path.append(pattern, start, i); String id; if (i == len - 1) { id = "*"; } else { id = pattern.substring(i + 1); } Object value = keys.getOrDefault(id, keys.get(Integer.toString(keyIdx++))); requireNonNull(value, "Missing key: '" + id + "'"); path.append(value); start = len; i = len; } } if (path.length() == 0) { return pattern; } if (start > 0) { path.append(pattern, start, len); } return path.toString(); } }