diff --git a/jooby/pom.xml b/jooby/pom.xml new file mode 100644 index 00000000..87d0994d --- /dev/null +++ b/jooby/pom.xml @@ -0,0 +1,260 @@ + + + + 4.0.0 + + org.kill-bill.commons + killbill-commons + 0.26.14-SNAPSHOT + ../pom.xml + + killbill-jooby + Kill Bill Jooby + Fork of Jooby 1.6.9 (core, servlet, jetty, jackson, funzy) + + spotbugs-exclude.xml + + + + + com.google.inject + guice + + + + com.google.guava + guava + + + + com.typesafe + config + + + + org.slf4j + slf4j-api + + + + com.google.code.findbugs + jsr305 + + + + jakarta.annotation + jakarta.annotation-api + + + + org.ow2.asm + asm + 9.7 + true + + + + jakarta.servlet + jakarta.servlet-api + + + + org.eclipse.jetty + jetty-server + + + + org.eclipse.jetty.http2 + http2-server + ${jetty.version} + + + + org.eclipse.jetty + jetty-alpn-server + ${jetty.version} + + + + org.eclipse.jetty.websocket + websocket-jetty-api + ${jetty.version} + + + + org.eclipse.jetty + jetty-io + ${jetty.version} + + + org.eclipse.jetty + jetty-util + ${jetty.version} + + + + javax.inject + javax.inject + + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.datatype + jackson-datatype-jdk8 + ${jackson.version} + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + ${jackson.version} + + + com.fasterxml.jackson.module + jackson-module-parameter-names + ${jackson.version} + + + com.fasterxml.jackson.module + jackson-module-afterburner + ${jackson.version} + + + junit + junit + compile + true + + + + ch.qos.logback + logback-classic + test + + + org.easymock + easymock + test + + + org.mockito + mockito-core + test + + + + + + + org.apache.maven.plugins + maven-shade-plugin + + + shade-asm + package + + shade + + + + + org.ow2.asm:* + + + + + org.objectweb.asm + org.jooby.internal.asm + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + default-testCompile + none + + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + + + + + org.apache.rat + apache-rat-plugin + + + src/main/resources/** + src/test/resources/** + src/test/java-excluded/** + + + + + + + + jooby + + + + org.apache.maven.plugins + maven-compiler-plugin + + + default-testCompile + test-compile + + testCompile + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M7 + + false + + + + org.apache.maven.surefire + surefire-junit47 + 3.0.0-M7 + + + + + + + + diff --git a/jooby/spotbugs-exclude.xml b/jooby/spotbugs-exclude.xml new file mode 100644 index 00000000..a3de35e8 --- /dev/null +++ b/jooby/spotbugs-exclude.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + diff --git a/jooby/src/main/java/org/jooby/Asset.java b/jooby/src/main/java/org/jooby/Asset.java new file mode 100644 index 00000000..a23447e2 --- /dev/null +++ b/jooby/src/main/java/org/jooby/Asset.java @@ -0,0 +1,175 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static java.util.Objects.requireNonNull; + +import java.io.InputStream; +import java.net.URISyntaxException; +import java.net.URL; + +import com.google.common.io.BaseEncoding; +import com.google.common.primitives.Longs; +import org.jooby.funzy.Throwing; + +import javax.annotation.Nonnull; + +/** + * Usually a public file/resource like javascript, css, images files, etc... + * An asset consist of content type, stream and last modified since attributes, between others. + * + * @author edgar + * @since 0.1.0 + * @see Jooby#assets(String) + */ +public interface Asset { + + /** + * Forwarding asset. + * + * @author edgar + */ + public class Forwarding implements Asset { + + private Asset asset; + + public Forwarding(final Asset asset) { + this.asset = requireNonNull(asset, "Asset is required."); + } + + @Override + public String etag() { + return asset.etag(); + } + + @Override + public String name() { + return asset.name(); + } + + @Override + public String path() { + return asset.path(); + } + + @Override + public URL resource() { + return asset.resource(); + } + + @Override + public long length() { + return asset.length(); + } + + @Override + public long lastModified() { + return asset.lastModified(); + } + + @Override + public InputStream stream() throws Exception { + return asset.stream(); + } + + @Override + public MediaType type() { + return asset.type(); + } + + } + + /** + * Examples: + * + *
+   *  GET /assets/index.js {@literal ->} index.js
+   *  GET /assets/js/index.js {@literal ->} index.js
+   * 
+ * + * @return The asset name (without path). + */ + @Nonnull + default String name() { + String path = path(); + int slash = path.lastIndexOf('/'); + return path.substring(slash + 1); + } + + /** + * Examples: + * + *
+   *  GET /assets/index.js {@literal ->} /assets/index.js
+   *  GET /assets/js/index.js {@literal ->} /assets/js/index.js
+   * 
+ * + * @return The asset requested path, includes the name. + */ + @Nonnull + String path(); + + /** + * @return URL representing the resource. + */ + @Nonnull + URL resource(); + + /** + * @return Generate a weak Etag using the {@link #path()}, {@link #lastModified()} and + * {@link #length()}. + */ + @Nonnull + default String etag() { + try { + StringBuilder b = new StringBuilder(32); + b.append("W/\""); + + BaseEncoding b64 = BaseEncoding.base64(); + int lhash = resource().toURI().hashCode(); + + b.append(b64.encode(Longs.toByteArray(lastModified() ^ lhash))); + b.append(b64.encode(Longs.toByteArray(length() ^ lhash))); + b.append('"'); + return b.toString(); + } catch (URISyntaxException x) { + throw Throwing.sneakyThrow(x); + } + } + + /** + * @return Asset size (in bytes) or -1 if undefined. + */ + long length(); + + /** + * @return The last modified date if possible or -1 when isn't. + */ + long lastModified(); + + /** + * @return The content of this asset. + * @throws Exception If content can't be read it. + */ + @Nonnull + InputStream stream() throws Exception; + + /** + * @return Asset media type. + */ + @Nonnull + MediaType type(); +} diff --git a/jooby/src/main/java/org/jooby/AsyncMapper.java b/jooby/src/main/java/org/jooby/AsyncMapper.java new file mode 100644 index 00000000..e169c7a4 --- /dev/null +++ b/jooby/src/main/java/org/jooby/AsyncMapper.java @@ -0,0 +1,80 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; + +import org.jooby.internal.mapper.CallableMapper; +import org.jooby.internal.mapper.CompletableFutureMapper; + +/** + *

async-mapper

+ *

+ * Map {@link Callable} and {@link CompletableFuture} results to {@link Deferred Deferred API}. + *

+ * + *

usage

+ * + * Script route: + *
{@code
+ * {
+ *   map(new AsyncMapper());
+ *
+ *   get("/callable", () -> {
+ *     return new Callable () {
+ *       public String call() {
+ *         return "OK";
+ *       }
+ *     };
+ *   });
+ *
+ *   get("/completable-future", () -> {
+ *     return CompletableFuture.supplyAsync(() -> "OK");
+ *   });
+ * }
+ * }
+ * + * From Mvc route you can return a callable: + * + *
{@code
+ *
+ *  public class Controller {
+ *    @GET
+ *    @Path("/async")
+ *    public Callable async() {
+ *      return "Success";
+ *    }
+ *  }
+ * }
+ * + * @author edgar + * @since 1.0.0 + */ +@SuppressWarnings("rawtypes") +public class AsyncMapper implements Route.Mapper { + + @Override + public Object map(final Object value) throws Throwable { + if (value instanceof Callable) { + return new CallableMapper().map((Callable) value); + } else if (value instanceof CompletableFuture) { + return new CompletableFutureMapper().map((CompletableFuture) value); + } + return value; + } + +} diff --git a/jooby/src/main/java/org/jooby/Cookie.java b/jooby/src/main/java/org/jooby/Cookie.java new file mode 100644 index 00000000..d556214b --- /dev/null +++ b/jooby/src/main/java/org/jooby/Cookie.java @@ -0,0 +1,553 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import com.google.common.base.Splitter; +import com.google.common.base.Strings; +import com.google.common.io.BaseEncoding; +import static java.util.Objects.requireNonNull; +import org.jooby.funzy.Throwing; +import org.jooby.internal.CookieImpl; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Iterator; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * Creates a cookie, a small amount of information sent by a server to + * a Web browser, saved by the browser, and later sent back to the server. + * A cookie's value can uniquely + * identify a client, so cookies are commonly used for session management. + * + *

+ * A cookie has a name, a single value, and optional attributes such as a comment, path and domain + * qualifiers, a maximum age, and a version number. + *

+ * + *

+ * The server sends cookies to the browser by using the {@link Response#cookie(Cookie)} method, + * which adds fields to HTTP response headers to send cookies to the browser, one at a time. The + * browser is expected to support 20 cookies for each Web server, 300 cookies total, and may limit + * cookie size to 4 KB each. + *

+ * + *

+ * The browser returns cookies to the server by adding fields to HTTP request headers. Cookies can + * be retrieved from a request by using the {@link Request#cookie(String)} method. Several cookies + * might have the same name but different path attributes. + *

+ * + *

+ * This class supports both the Version 0 (by Netscape) and Version 1 (by RFC 2109) cookie + * specifications. By default, cookies are created using Version 0 to ensure the best + * interoperability. + *

+ * + * @author edgar and various + * @since 0.1.0 + */ +public interface Cookie { + + /** + * Decode a cookie value using, like: k=v, multiple k=v pair are + * separated by &. Also, k and v are decoded using + * {@link URLDecoder}. + */ + Function> URL_DECODER = value -> { + if (value == null) { + return Collections.emptyMap(); + } + Throwing.Function decode = v -> URLDecoder + .decode(v, StandardCharsets.UTF_8.name()); + + return Splitter.on('&') + .trimResults() + .omitEmptyStrings() + .splitToList(value) + .stream() + .map(v -> { + Iterator it = Splitter.on('=').trimResults().omitEmptyStrings() + .split(v) + .iterator(); + return new String[]{ + decode.apply(it.next()), + it.hasNext() ? decode.apply(it.next()) : null + }; + }) + .filter(it -> Objects.nonNull(it[1])) + .collect(Collectors.toMap(it -> it[0], it -> it[1])); + }; + + /** + * Encode a hash into cookie value, like: k1=v1&...&kn=vn. Also, + * key and value are encoded using {@link URLEncoder}. + */ + Function, String> URL_ENCODER = value -> { + Throwing.Function encode = v -> URLEncoder + .encode(v, StandardCharsets.UTF_8.name()); + return value.entrySet().stream() + .map(e -> new StringBuilder() + .append(encode.apply(e.getKey())) + .append('=') + .append(encode.apply(e.getValue()))) + .collect(Collectors.joining("&")) + .toString(); + }; + + /** + * Build a {@link Cookie}. + * + * @author edgar + * @since 0.1.0 + */ + class Definition { + + /** Cookie's name. */ + private String name; + + /** Cookie's value. */ + private String value; + + /** Cookie's domain. */ + private String domain; + + /** Cookie's path. */ + private String path; + + /** Cookie's comment. */ + private String comment; + + /** HttpOnly flag. */ + private Boolean httpOnly; + + /** True, ensure that the session cookie is only transmitted via HTTPS. */ + private Boolean secure; + + /** + * By default, -1 is returned, which indicates that the cookie will persist until + * browser shutdown. + */ + private Integer maxAge; + + /** + * Creates a new {@link Definition cookie's definition}. + */ + protected Definition() { + } + + /** + * Clone a new {@link Definition cookie's definition}. + * + * @param def A cookie's definition. + */ + public Definition(final Definition def) { + this.comment = def.comment; + this.domain = def.domain; + this.httpOnly = def.httpOnly; + this.maxAge = def.maxAge; + this.name = def.name; + this.path = def.path; + this.secure = def.secure; + this.value = def.value; + } + + /** + * Creates a new {@link Definition cookie's definition}. + * + * @param name Cookie's name. + * @param value Cookie's value. + */ + public Definition(final String name, final String value) { + name(name); + value(value); + } + + /** + * Creates a new {@link Definition cookie's definition}. + * + * @param name Cookie's name. + */ + public Definition(final String name) { + name(name); + } + + /** + * Produces a cookie from current definition. + * + * @return A new cookie. + */ + @Nonnull + public Cookie toCookie() { + return new CookieImpl(this); + } + + @Override + public String toString() { + return toCookie().encode(); + } + + /** + * Set/Override the cookie's name. + * + * @param name A cookie's name. + * @return This definition. + */ + @Nonnull + public Definition name(final String name) { + this.name = requireNonNull(name, "A cookie name is required."); + return this; + } + + /** + * @return Cookie's name. + */ + @Nonnull + public Optional name() { + return Optional.ofNullable(name); + } + + /** + * Set the cookie's value. + * + * @param value A value. + * @return This definition. + */ + @Nonnull + public Definition value(final String value) { + this.value = requireNonNull(value, "A cookie value is required."); + return this; + } + + /** + * @return Cookie's value. + */ + @Nonnull + public Optional value() { + if (Strings.isNullOrEmpty(value)) { + return Optional.empty(); + } + return Optional.of(value); + } + + /** + * Set the cookie's domain. + * + * @param domain Cookie's domain. + * @return This definition. + */ + @Nonnull + public Definition domain(final String domain) { + this.domain = requireNonNull(domain, "A cookie domain is required."); + return this; + } + + /** + * @return A cookie's domain. + */ + @Nonnull + public Optional domain() { + return Optional.ofNullable(domain); + } + + /** + * Set the cookie's path. + * + * @param path Cookie's path. + * @return This definition. + */ + @Nonnull + public Definition path(final String path) { + this.path = requireNonNull(path, "A cookie path is required."); + return this; + } + + /** + * @return Get cookie's path. + */ + @Nonnull + public Optional path() { + return Optional.ofNullable(path); + } + + /** + * Set cookie's comment. + * + * @param comment A cookie's comment. + * @return This definition. + */ + @Nonnull + public Definition comment(final String comment) { + this.comment = requireNonNull(comment, "A cookie comment is required."); + return this; + } + + /** + * @return Cookie's comment. + */ + @Nonnull + public Optional comment() { + return Optional.ofNullable(comment); + } + + /** + * Set HttpOnly flag. + * + * @param httpOnly True, for HTTP Only. + * @return This definition. + */ + @Nonnull + public Definition httpOnly(final boolean httpOnly) { + this.httpOnly = httpOnly; + return this; + } + + /** + * @return HTTP only flag. + */ + @Nonnull + public Optional httpOnly() { + return Optional.ofNullable(httpOnly); + } + + /** + * True, ensure that the session cookie is only transmitted via HTTPS. + * + * @param secure True, ensure that the session cookie is only transmitted via HTTPS. + * @return This definition. + */ + @Nonnull + public Definition secure(final boolean secure) { + this.secure = secure; + return this; + } + + /** + * @return True, ensure that the session cookie is only transmitted via HTTPS. + */ + @Nonnull + public Optional secure() { + return Optional.ofNullable(secure); + } + + /** + * Sets the maximum age in seconds for this Cookie. + * + *

+ * A positive value indicates that the cookie will expire after that many seconds have passed. + * Note that the value is the maximum age when the cookie will expire, not the cookie's + * current age. + *

+ * + *

+ * A negative value means that the cookie is not stored persistently and will be deleted when + * the Web browser exits. A zero value causes the cookie to be deleted. + *

+ * + * @param maxAge an integer specifying the maximum age of the cookie in seconds; if negative, + * means the cookie is not stored; if zero, deletes the cookie. + * @return This definition. + */ + @Nonnull + public Definition maxAge(final int maxAge) { + this.maxAge = maxAge; + return this; + } + + /** + * Gets the maximum age in seconds for this Cookie. + * + *

+ * A positive value indicates that the cookie will expire after that many seconds have passed. + * Note that the value is the maximum age when the cookie will expire, not the cookie's + * current age. + *

+ * + *

+ * A negative value means that the cookie is not stored persistently and will be deleted when + * the Web browser exits. A zero value causes the cookie to be deleted. + *

+ * + * @return Cookie's max age in seconds. + */ + @Nonnull + public Optional maxAge() { + return Optional.ofNullable(maxAge); + } + + } + + /** + * Sign cookies using a HMAC algorithm plus SHA-256 hash. + * Usage: + * + *
+   *   String signed = Signature.sign("hello", "mysecretkey");
+   *   ...
+   *   // is it valid?
+   *   assertEquals(signed, Signature.unsign(signed, "mysecretkey");
+   * 
+ * + * @author edgar + * @since 0.1.0 + */ + public class Signature { + + /** Remove trailing '='. */ + private static final Pattern EQ = Pattern.compile("=+$"); + + /** Algorithm name. */ + public static final String HMAC_SHA256 = "HmacSHA256"; + + /** Signature separator. */ + private static final String SEP = "|"; + + /** + * Sign a value using a secret key. A value and secret key are required. Sign is done with + * {@link #HMAC_SHA256}. + * Signed value looks like: + * + *
+     *   [signed value] '|' [raw value]
+     * 
+ * + * @param value A value to sign. + * @param secret A secret key. + * @return A signed value. + */ + @Nonnull + public static String sign(final String value, final String secret) { + requireNonNull(value, "A value is required."); + requireNonNull(secret, "A secret is required."); + + try { + Mac mac = Mac.getInstance(HMAC_SHA256); + mac.init(new SecretKeySpec(secret.getBytes(), HMAC_SHA256)); + byte[] bytes = mac.doFinal(value.getBytes()); + return EQ.matcher(BaseEncoding.base64().encode(bytes)).replaceAll("") + SEP + value; + } catch (Exception ex) { + throw new IllegalArgumentException("Can't sing value", ex); + } + } + + /** + * Un-sign a value, previously signed with {@link #sign(String, String)}. + * Try {@link #valid(String, String)} to check for valid signed values. + * + * @param value A signed value. + * @param secret A secret key. + * @return A new signed value or null. + */ + @Nullable + public static String unsign(final String value, final String secret) { + requireNonNull(value, "A value is required."); + requireNonNull(secret, "A secret is required."); + int sep = value.indexOf(SEP); + if (sep <= 0) { + return null; + } + String str = value.substring(sep + 1); + String mac = sign(str, secret); + + return mac.equals(value) ? str : null; + } + + /** + * True, if the given signed value is valid. + * + * @param value A signed value. + * @param secret A secret key. + * @return True, if the given signed value is valid. + */ + public static boolean valid(final String value, final String secret) { + return unsign(value, secret) != null; + } + + } + + /** + * @return Cookie's name. + */ + @Nonnull + String name(); + + /** + * @return Cookie's value. + */ + @Nonnull + Optional value(); + + /** + * @return An optional comment. + */ + @Nonnull + Optional comment(); + + /** + * @return Cookie's domain. + */ + @Nonnull + Optional domain(); + + /** + * Gets the maximum age of this cookie (in seconds). + * + *

+ * By default, -1 is returned, which indicates that the cookie will persist until + * browser shutdown. + *

+ * + * @return An integer specifying the maximum age of the cookie in seconds; if negative, means + * the cookie persists until browser shutdown + */ + int maxAge(); + + /** + * @return Cookie's path. + */ + @Nonnull + Optional path(); + + /** + * Returns true if the browser is sending cookies only over a secure protocol, or + * false if the browser can send cookies using any protocol. + * + * @return true if the browser uses a secure protocol, false otherwise. + */ + boolean secure(); + + /** + * @return True if HTTP Only. + */ + boolean httpOnly(); + + /** + * @return Encode the cookie. + */ + @Nonnull + String encode(); +} diff --git a/jooby/src/main/java/org/jooby/Deferred.java b/jooby/src/main/java/org/jooby/Deferred.java new file mode 100644 index 00000000..0a505881 --- /dev/null +++ b/jooby/src/main/java/org/jooby/Deferred.java @@ -0,0 +1,458 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static java.util.Objects.requireNonNull; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.Executor; +import java.util.concurrent.ForkJoinPool; + +/** + *

async request processing

+ *

+ * A Deferred result, useful for async request processing. + *

+ *

+ * Application can produces a result from a different thread. Once result is ready, a call to + * {@link #resolve(Object)} is required. Please note, a call to {@link #reject(Throwable)} is + * required in case of errors. + *

+ * + *

usage

+ * + *
+ * {
+ *    get("/async", deferred(() {@literal ->} {
+ *      return "Success";
+ *    }));
+ *  }
+ * 
+ * + * From MVC route: + * + *
{@code
+ *
+ *  public class Controller {
+ *    @GET
+ *    @Path("/async")
+ *    public Deferred async() {
+ *      return Deferred.deferred(() -> "Success");
+ *    }
+ *  }
+ * }
+ * + * If you add the {@link AsyncMapper} then your controller method can return a {@link Callable}. + * + * Previous example runs in the default executor, which always run deferred results in the + * same/caller thread. + * + * To effectively run a deferred result in new/different thread you need to provide an + * {@link Executor}: + * + *
{@code
+ * {
+ *   executor(new ForkJoinPool());
+ * }
+ * }
+ * + * This line override the default executor with a {@link ForkJoinPool}. You can add two or more + * named executor: + * + *
{@code
+ * {
+ *   executor(new ForkJoinPool());
+ *
+ *   executor("cached", Executors.newCachedExecutor());
+ *
+ *   get("/async", deferred("cached", () -> "Success"));
+ * }
+ * }
+ * + * A {@link Deferred} object works as a promise too, given you {@link #resolve(Object)} and + * {@link #reject(Throwable)} methods. Examples: + * + * As promise using the default executor (execute promise in same/caller thread): + *
+ * {
+ *    get("/async", promise(deferred {@literal ->} {
+ *      try {
+ *        deferred.resolve(...); // success value
+ *      } catch (Throwable ex) {
+ *        deferred.reject(ex); // error value
+ *      }
+ *    }));
+ *  }
+ * 
+ * + * As promise using a custom executor: + *
+ * {
+ *    executor(new ForkJoinPool());
+ *
+ *    get("/async", promise(deferred {@literal ->} {
+ *      try {
+ *        deferred.resolve(...); // success value
+ *      } catch (Throwable ex) {
+ *        deferred.reject(ex); // error value
+ *      }
+ *    }));
+ *  }
+ * 
+ * + * As promise using an alternative executor: + * + *
+ * {
+ *    executor(new ForkJoinPool());
+ *
+ *    executor("cached", Executors.newCachedExecutor());
+ *
+ *    get("/async", promise("cached", deferred {@literal ->} {
+ *      try {
+ *        deferred.resolve(...); // success value
+ *      } catch (Throwable ex) {
+ *        deferred.reject(ex); // error value
+ *      }
+ *    }));
+ *  }
+ * 
+ * + * @author edgar + * @since 0.10.0 + */ +public class Deferred extends Result { + + /** + * Deferred initializer, useful to provide a more functional API. + * + * @author edgar + * @since 0.10.0 + */ + public interface Initializer0 { + + /** + * Run the initializer block. + * + * @param deferred Deferred object. + * @throws Exception If something goes wrong. + */ + void run(Deferred deferred) throws Exception; + } + + /** + * Deferred initializer with {@link Request} access, useful to provide a more functional API. + * + * @author edgar + * @since 0.10.0 + */ + public interface Initializer { + + /** + * Run the initializer block. + * + * @param req Current request. + * @param deferred Deferred object. + * @throws Exception If something goes wrong. + */ + void run(Request req, Deferred deferred) throws Exception; + } + + /** + * A deferred handler. Application code should never use this class. INTERNAL USE ONLY. + * + * @author edgar + * @since 0.10.0 + */ + public interface Handler { + void handle(@Nullable Result result, Throwable exception); + } + + /** Deferred initializer. Optional. */ + private Initializer initializer; + + /** Deferred handler. Internal. */ + private Handler handler; + + private String executor; + + private String callerThread; + + /** + * Creates a new {@link Deferred} with an initializer. + * + * @param executor Executor to use. + * @param initializer An initializer. + */ + public Deferred(final String executor, final Initializer0 initializer) { + this(executor, (req, deferred) -> initializer.run(deferred)); + } + + /** + * Creates a new {@link Deferred} with an initializer. + * + * @param initializer An initializer. + */ + public Deferred(final Initializer0 initializer) { + this(null, initializer); + } + + /** + * Creates a new {@link Deferred} with an initializer. + * + * @param initializer An initializer. + */ + public Deferred(final Initializer initializer) { + this(null, initializer); + } + + /** + * Creates a new {@link Deferred} with an initializer. + * + * @param executor Executor to use. + * @param initializer An initializer. + */ + public Deferred(@Nullable final String executor, final Initializer initializer) { + this.executor = executor; + this.initializer = requireNonNull(initializer, "Initializer is required."); + this.callerThread = Thread.currentThread().getName(); + } + + /** + * Creates a new {@link Deferred}. + */ + public Deferred() { + } + + /** + * {@link #resolve(Object)} or {@link #reject(Throwable)} the given value. + * + * @param value Resolved value. + */ + @Override + @Nonnull + public Result set(final Object value) { + if (value instanceof Throwable) { + reject((Throwable) value); + } else { + resolve(value); + } + return this; + } + + /** + * Get an executor to run this deferred result. If the executor is present, then it will be use it + * to execute the deferred object. Otherwise it will use the global/application executor. + * + * @return Executor to use or fallback to global/application executor. + */ + @Nonnull + public Optional executor() { + return Optional.ofNullable(executor); + } + + /** + * Name of the caller thread (thread that creates this deferred object). + * + * @return Name of the caller thread (thread that creates this deferred object). + */ + @Nonnull + public String callerThread() { + return callerThread; + } + + /** + * Resolve the deferred value and handle it. This method will send the response to a client and + * cleanup and close all the resources. + * + * @param value A value for this deferred. + */ + public void resolve(@Nullable final Object value) { + if (value == null) { + handler.handle(null, null); + } else { + Result result; + if (value instanceof Result) { + super.set(value); + result = (Result) value; + } else { + super.set(value); + result = clone(); + } + handler.handle(result, null); + } + } + + /** + * Resolve the deferred with an error and handle it. This method will handle the given exception, + * send the response to a client and cleanup and close all the resources. + * + * @param cause A value for this deferred. + */ + public void reject(final Throwable cause) { + super.set(cause); + handler.handle(null, cause); + } + + /** + * Setup a handler for this deferred. Application code should never call this method: INTERNAL USE + * ONLY. + * + * @param req Current request. + * @param handler A response handler. + * @throws Exception If initializer fails to start. + */ + public void handler(final Request req, final Handler handler) throws Exception { + this.handler = requireNonNull(handler, "Handler is required."); + if (initializer != null) { + initializer.run(req, this); + } + } + + /** + * Functional version of {@link Deferred#Deferred(Initializer)}. + * + * Using the default executor (current thread): + * + *
{@code
+   * {
+   *   get("/fork", deferred(req -> {
+   *     return req.param("value").value();
+   *   }));
+   * }
+   * }
+ * + * Using a custom executor: + * + *
{@code
+   * {
+   *   executor(new ForkJoinPool());
+   *
+   *   get("/fork", deferred(req -> {
+   *     return req.param("value").value();
+   *   }));
+   * }
+   * }
+ * + * This handler automatically {@link Deferred#resolve(Object)} or + * {@link Deferred#reject(Throwable)} a route handler response. + * + * @param handler Application block. + * @return A new deferred handler. + */ + @Nonnull + public static Deferred deferred(final Route.OneArgHandler handler) { + return deferred(null, handler); + } + + /** + * Functional version of {@link Deferred#Deferred(Initializer)}. + * + * Using the default executor (current thread): + * + *
{@code
+   * {
+   *   get("/fork", deferred(() -> {
+   *     return req.param("value").value();
+   *   }));
+   * }
+   * }
+ * + * Using a custom executor: + * + *
{@code
+   * {
+   *   executor(new ForkJoinPool());
+   *
+   *   get("/fork", deferred(() -> {
+   *     return req.param("value").value();
+   *   }));
+   * }
+   * }
+ * + * This handler automatically {@link Deferred#resolve(Object)} or + * {@link Deferred#reject(Throwable)} a route handler response. + * + * @param handler Application block. + * @return A new deferred. + */ + @Nonnull + public static Deferred deferred(final Route.ZeroArgHandler handler) { + return deferred(null, handler); + } + + /** + * Functional version of {@link Deferred#Deferred(Initializer)}. To use ideally with one + * or more {@link Executor}: + * + *
{@code
+   * {
+   *   executor("cached", Executors.newCachedExecutor());
+   *
+   *   get("/fork", deferred("cached", () -> {
+   *     return "OK";
+   *   }));
+   * }
+   * }
+ * + * This handler automatically {@link Deferred#resolve(Object)} or + * {@link Deferred#reject(Throwable)} a route handler response. + * + * @param executor Executor to run the deferred. + * @param handler Application block. + * @return A new deferred handler. + */ + @Nonnull + public static Deferred deferred(final String executor, final Route.ZeroArgHandler handler) { + return deferred(executor, req -> handler.handle()); + } + + /** + * Functional version of {@link Deferred#Deferred(Initializer)}. To use ideally with one + * or more {@link Executor}: + * + *
{@code
+   * {
+   *   executor("cached", Executors.newCachedExecutor());
+   *
+   *   get("/fork", deferred("cached", req -> {
+   *     return req.param("value").value();
+   *   }));
+   * }
+   * }
+ * + * This handler automatically {@link Deferred#resolve(Object)} or + * {@link Deferred#reject(Throwable)} a route handler response. + * + * @param executor Executor to run the deferred. + * @param handler Application block. + * @return A new deferred handler. + */ + @Nonnull + public static Deferred deferred(final String executor, final Route.OneArgHandler handler) { + return new Deferred(executor, (req, deferred) -> { + try { + deferred.resolve(handler.handle(req)); + } catch (Throwable x) { + deferred.reject(x); + } + }); + } + +} diff --git a/jooby/src/main/java/org/jooby/Env.java b/jooby/src/main/java/org/jooby/Env.java new file mode 100644 index 00000000..502029d7 --- /dev/null +++ b/jooby/src/main/java/org/jooby/Env.java @@ -0,0 +1,622 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import com.google.common.base.Splitter; +import com.google.common.collect.ImmutableList; +import com.google.inject.Key; +import com.google.inject.name.Names; +import com.typesafe.config.Config; +import static java.util.Objects.requireNonNull; +import org.jooby.funzy.Throwing; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.Optional; +import java.util.function.BiFunction; +import java.util.function.BinaryOperator; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Allows to optimize, customize or apply defaults values for application services. + * + *

+ * A env is represented by it's name. For example: dev, prod, etc... A + * dev env is special and a module provider could do some special configuration for + * development, like turning off a cache, reloading of resources, etc. + *

+ *

+ * Same is true for not dev environments. For example, a module provider might + * create a high performance connection pool, caches, etc. + *

+ *

+ * By default env is set to dev, but you can change it by setting the + * application.env property to anything else. + *

+ * + * @author edgar + * @since 0.1.0 + */ +public interface Env extends LifeCycle { + + /** + * Property source for {@link Resolver} + * + * @author edgar + * @since 1.1.0 + */ + interface PropertySource { + + /** + * Get a property value or throw {@link NoSuchElementException}. + * + * @param key Property key/name. + * @return Value or throw {@link NoSuchElementException}. + * @throws NoSuchElementException If property is missing. + */ + @Nonnull + String get(String key) throws NoSuchElementException; + } + + /** + * {@link PropertySource} for {@link Config}. + * + * @author edgar + * @since 1.1.0 + */ + class ConfigSource implements PropertySource { + + private Config source; + + public ConfigSource(final Config source) { + this.source = source; + } + + @Override + public String get(final String key) throws NoSuchElementException { + if (source.hasPath(key)) { + return source.getString(key); + } + throw new NoSuchElementException(key); + } + + } + + /** + * {@link PropertySource} for {@link Map}. + * + * @author edgar + * @since 1.1.0 + */ + class MapSource implements PropertySource { + + private Map source; + + public MapSource(final Map source) { + this.source = source; + } + + @Override + public String get(final String key) throws NoSuchElementException { + Object value = source.get(key); + if (value != null) { + return value.toString(); + } + throw new NoSuchElementException(key); + } + + } + + /** + * Template literal implementation, replaces ${expression} from a String using a + * {@link Config} object. + * + * @author edgar + */ + class Resolver { + private String startDelim = "${"; + + private String endDelim = "}"; + + private PropertySource source; + + private boolean ignoreMissing; + + /** + * Set property source. + * + * @param source Source. + * @return This resolver. + */ + public Resolver source(final Map source) { + return source(new MapSource(source)); + } + + /** + * Set property source. + * + * @param source Source. + * @return This resolver. + */ + public Resolver source(final PropertySource source) { + this.source = source; + return this; + } + + /** + * Set property source. + * + * @param source Source. + * @return This resolver. + */ + public Resolver source(final Config source) { + return source(new ConfigSource(source)); + } + + /** + * Set start and end delimiters. + * + * @param start Start delimiter. + * @param end End delimiter. + * @return This resolver. + */ + public Resolver delimiters(final String start, final String end) { + this.startDelim = requireNonNull(start, "Start delimiter required."); + this.endDelim = requireNonNull(end, "End delmiter required."); + return this; + } + + /** + * Ignore missing property replacement and leave the expression untouch. + * + * @return This resolver. + */ + public Resolver ignoreMissing() { + this.ignoreMissing = true; + return this; + } + + /** + * Returns a string with all substitutions (the ${foo.bar} syntax, + * see the + * spec) resolved. Substitutions are looked up using the source param as the + * root object, that is, a substitution ${foo.bar} will be replaced with + * the result of getValue("foo.bar"). + * + * @param text Text to process. + * @return A processed string. + */ + public String resolve(final String text) { + requireNonNull(text, "Text is required."); + if (text.length() == 0) { + return ""; + } + + BiFunction, RuntimeException> err = ( + start, ex) -> { + String snapshot = text.substring(0, start); + int line = Splitter.on('\n').splitToList(snapshot).size(); + int column = start - snapshot.lastIndexOf('\n'); + return ex.apply(line, column); + }; + + StringBuilder buffer = new StringBuilder(); + int offset = 0; + int start = text.indexOf(startDelim); + while (start >= 0) { + int end = text.indexOf(endDelim, start + startDelim.length()); + if (end == -1) { + throw err.apply(start, (line, column) -> new IllegalArgumentException( + "found '" + startDelim + "' expecting '" + endDelim + "' at " + line + ":" + + column)); + } + buffer.append(text.substring(offset, start)); + String key = text.substring(start + startDelim.length(), end); + Object value; + try { + value = source.get(key); + } catch (NoSuchElementException x) { + if (ignoreMissing) { + value = text.substring(start, end + endDelim.length()); + } else { + throw err.apply(start, (line, column) -> new NoSuchElementException( + "Missing " + startDelim + key + endDelim + " at " + line + ":" + column)); + } + } + buffer.append(value); + offset = end + endDelim.length(); + start = text.indexOf(startDelim, offset); + } + if (buffer.length() == 0) { + return text; + } + if (offset < text.length()) { + buffer.append(text.substring(offset)); + } + return buffer.toString(); + } + } + + /** + * Utility class for generating {@link Key} for named services. + * + * @author edgar + */ + class ServiceKey { + private Map instances = new HashMap<>(); + + /** + * Generate at least one named key for the provided type. If this is the first call for the + * provided type then it generates an unnamed key. + * + * @param type Service type. + * @param name Service name. + * @param keys Key callback. Invoked once with a named key, and optionally again with an unamed + * key. + * @param Service type. + */ + public void generate(final Class type, final String name, final Consumer> keys) { + Integer c = instances.put(type, instances.getOrDefault(type, 0) + 1); + if (c == null) { + // def key + keys.accept(Key.get(type)); + } + keys.accept(Key.get(type, Names.named(name))); + } + } + + /** + * Build an jooby environment. + * + * @author edgar + */ + interface Builder { + + /** + * Build a new environment from a {@link Config} object. The environment is created from the + * application.env property. If such property is missing, env's name must be: + * dev. + * + * Please note an environment created with this method won't have a {@link Env#router()}. + * + * @param config A config instance. + * @return A new environment. + */ + @Nonnull + default Env build(final Config config) { + return build(config, null, Locale.getDefault()); + } + + /** + * Build a new environment from a {@link Config} object. The environment is created from the + * application.env property. If such property is missing, env's name must be: + * dev. + * + * @param config A config instance. + * @param router Application router. + * @param locale App locale. + * @return A new environment. + */ + @Nonnull + Env build(Config config, @Nullable Router router, Locale locale); + } + + /** + * Default builder. + */ + Env.Builder DEFAULT = (config, router, locale) -> { + requireNonNull(config, "Config required."); + String name = config.hasPath("application.env") ? config.getString("application.env") : "dev"; + return new Env() { + + private ImmutableList.Builder> start = ImmutableList.builder(); + + private ImmutableList.Builder> started = ImmutableList.builder(); + + private ImmutableList.Builder> shutdown = ImmutableList.builder(); + + private Map> xss = new HashMap<>(); + + private Map globals = new HashMap<>(); + + private ServiceKey key = new ServiceKey(); + + public Env set(Key key, T value) { + globals.put(key, value); + return this; + } + + public T unset(Key key) { + return (T) globals.remove(key); + } + + public Optional get(Key key) { + T value = (T) globals.get(key); + return Optional.ofNullable(value); + } + + @Override + public String name() { + return name; + } + + @Override + public ServiceKey serviceKey() { + return key; + } + + @Override + public Router router() { + if (router == null) { + throw new UnsupportedOperationException(); + } + return router; + } + + @Override + public Config config() { + return config; + } + + @Override + public Locale locale() { + return locale; + } + + @Override + public String toString() { + return name(); + } + + @Override + public List> stopTasks() { + return shutdown.build(); + } + + @Override + public Env onStop(final Throwing.Consumer task) { + this.shutdown.add(task); + return this; + } + + @Override + public Env onStart(final Throwing.Consumer task) { + this.start.add(task); + return this; + } + + @Override + public LifeCycle onStarted(final Throwing.Consumer task) { + this.started.add(task); + return this; + } + + @Override + public List> startTasks() { + return this.start.build(); + } + + @Override + public List> startedTasks() { + return this.started.build(); + } + + @Override + public Map> xss() { + return Collections.unmodifiableMap(xss); + } + + @Override + public Env xss(final String name, final Function escaper) { + xss.put(requireNonNull(name, "Name required."), + requireNonNull(escaper, "Function required.")); + return this; + } + }; + }; + + /** + * @return Env's name. + */ + @Nonnull + String name(); + + /** + * Application router. + * + * @return Available {@link Router}. + * @throws UnsupportedOperationException if router isn't available. + */ + @Nonnull + Router router() throws UnsupportedOperationException; + + /** + * @return environment properties. + */ + @Nonnull + Config config(); + + /** + * @return Default locale from application.lang. + */ + @Nonnull + Locale locale(); + + /** + * @return Utility method for generating keys for named services. + */ + @Nonnull + default ServiceKey serviceKey() { + return new ServiceKey(); + } + + /** + * Returns a string with all substitutions (the ${foo.bar} syntax, + * see the + * spec) resolved. Substitutions are looked up using the {@link #config()} as the root object, + * that is, a substitution ${foo.bar} will be replaced with + * the result of getValue("foo.bar"). + * + * @param text Text to process. + * @return A processed string. + */ + @Nonnull + default String resolve(final String text) { + return resolver().resolve(text); + } + + /** + * Creates a new environment {@link Resolver}. + * + * @return A resolver object. + */ + @Nonnull + default Resolver resolver() { + return new Resolver().source(config()); + } + + /** + * Runs the callback function if the current env matches the given name. + * + * @param name A name to test for. + * @param fn A callback function. + * @param A resulting type. + * @return A resulting object. + */ + @Nonnull + default Optional ifMode(final String name, final Supplier fn) { + if (name().equals(name)) { + return Optional.of(fn.get()); + } + return Optional.empty(); + } + + /** + * @return XSS escape functions. + */ + @Nonnull + Map> xss(); + + /** + * Get or chain the required xss functions. + * + * @param xss XSS to combine. + * @return Chain of required xss functions. + */ + @Nonnull + default Function xss(final String... xss) { + Map> fn = xss(); + BinaryOperator> reduce = Function::andThen; + return Arrays.asList(xss) + .stream() + .map(fn::get) + .filter(Objects::nonNull) + .reduce(Function.identity(), reduce); + } + + /** + * Set/override a XSS escape function. + * + * @param name Escape's name. + * @param escaper Escape function. + * @return This environment. + */ + @Nonnull + Env xss(String name, Function escaper); + + /** + * @return List of start tasks. + */ + @Nonnull + List> startTasks(); + + /** + * @return List of start tasks. + */ + @Nonnull + List> startedTasks(); + + /** + * @return List of stop tasks. + */ + @Nonnull + List> stopTasks(); + + /** + * Add a global object. + * + * @param key Object key. + * @param value Object value. + * @param Object type. + * @return This environment. + */ + @Nonnull + Env set(Key key, T value); + + /** + * Add a global object. + * + * @param key Object key. + * @param value Object value. + * @param Object type. + * @return This environment. + */ + @Nonnull + default Env set(Class key, T value) { + return set(Key.get(key), value); + } + + /** + * Remove a global object. + * + * @param key Object key. + * @param Object type. + * @return Object value might be null. + */ + @Nullable T unset(Key key); + + /** + * Get an object by key or empty when missing. + * + * @param key Object key. + * @param Object type. + * @return Object valur or empty. + */ + @Nonnull + Optional get(Key key); + + /** + * Get an object by key or empty when missing. + * + * @param key Object key. + * @param Object type. + * @return Object valur or empty. + */ + @Nonnull + default Optional get(Class key) { + return get(Key.get(key)); + } +} diff --git a/jooby/src/main/java/org/jooby/Err.java b/jooby/src/main/java/org/jooby/Err.java new file mode 100644 index 00000000..ab25c6e6 --- /dev/null +++ b/jooby/src/main/java/org/jooby/Err.java @@ -0,0 +1,327 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import java.util.*; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Supplier; + +import com.typesafe.config.Config; +import org.jooby.funzy.Try; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.base.Throwables; + +import javax.annotation.Nullable; + +/** + * An exception that carry a {@link Status}. The status field will be set in the HTTP + * response. + * + * See {@link Err.Handler} for more details on how to deal with exceptions. + * + * @author edgar + * @since 0.1.0 + */ +@SuppressWarnings("serial") +public class Err extends RuntimeException { + + /** + * Exception thrown from {@link MediaType#parse(String)} in case of encountering an invalid media + * type specification String. + * + * @author edgar + */ + public static class BadMediaType extends Err { + + /** + * Creates a new {@link BadMediaType}. + * + * @param message Error message. + */ + public BadMediaType(final String message) { + super(Status.BAD_REQUEST, message); + } + + } + + /** + * Missing parameter/header or request attribute. + * + * @author edgar + */ + public static class Missing extends Err { + + /** + * Creates a new {@link Missing} error. + * + * @param name Name of the missing parameter/header or request attribute. + */ + public Missing(final String name) { + super(Status.BAD_REQUEST, name); + } + + } + + /** + * Default err handler it does content negotation. + * + * On text/html requests the err handler creates an err view and use the + * {@link Err#toMap()} result as model. + * + * @author edgar + * @since 0.1.0 + */ + public static class DefHandler implements Err.Handler { + + /** Default err view. */ + public static final String VIEW = "err"; + + /** logger, logs!. */ + private final Logger log = LoggerFactory.getLogger(Err.class); + + @Override + public void handle(final Request req, final Response rsp, final Err ex) throws Throwable { + log.error("execution of: {}{} resulted in exception\nRoute:\n{}\n\nStacktrace:", + req.method(), req.path(), req.route().print(6), ex); + Config conf = req.require(Config.class); + Env env = req.require(Env.class); + boolean stackstrace = Try.apply(() -> conf.getBoolean("err.stacktrace")) + .orElse(env.name().equals("dev")); + + Function xssFilter = env.xss("html").compose(Objects::toString); + BiFunction escaper = (k, v) -> xssFilter.apply(v); + + Map details = ex.toMap(stackstrace); + details.compute("message", escaper); + details.compute("reason", escaper); + + rsp.send( + Results + .when(MediaType.html, () -> Results.html(VIEW).put("err", details)) + .when(MediaType.all, () -> details)); + } + + } + + /** + * Handle and render exceptions. Error handlers are executed in the order they were provided, the + * first err handler that send an output wins! + * + * The default err handler does content negotation on error, see {@link DefHandler}. + * + * @author edgar + * @since 0.1.0 + */ + public interface Handler { + + /** + * Handle a route exception by properly logging the error and sending a err response to the + * client. + * + * Please note you always get an {@link Err} whenever you throw it or not. For example if your + * application throws an {@link IllegalArgumentException} exception you will get an {@link Err} + * and you can retrieve the original exception by calling {@link Err#getCause()}. + * + * Jooby always give you an {@link Err} with an optional root cause and an associated status + * code. + * + * @param req HTTP request. + * @param rsp HTTP response. + * @param ex Error found and status code. + * @throws Throwable If something goes wrong. + */ + void handle(Request req, Response rsp, Err ex) throws Throwable; + } + + /** + * The status code. Required. + */ + private int status; + + /** + * Creates a new {@link Err}. + * + * @param status A HTTP status. Required. + * @param message A error message. Required. + * @param cause The cause of the problem. + */ + public Err(final Status status, final String message, final Throwable cause) { + super(message(status, message), cause); + this.status = status.value(); + } + + /** + * Creates a new {@link Err}. + * + * @param status A HTTP status. Required. + * @param message A error message. Required. + * @param cause The cause of the problem. + */ + public Err(final int status, final String message, final Throwable cause) { + super(message("", status, message), cause); + this.status = status; + } + + /** + * Creates a new {@link Err}. + * + * @param status A web socket close status. Required. + * @param message Close message. + */ + public Err(final WebSocket.CloseStatus status, final String message) { + super(message(status.reason(), status.code(), message)); + this.status = status.code(); + } + + /** + * Creates a new {@link Err}. + * + * @param status A HTTP status. Required. + * @param message A error message. Required. + */ + public Err(final Status status, final String message) { + super(message(status, message)); + this.status = status.value(); + } + + /** + * Creates a new {@link Err}. + * + * @param status A HTTP status. Required. + * @param message A error message. Required. + */ + public Err(final int status, final String message) { + this(Status.valueOf(status), message); + } + + /** + * Creates a new {@link Err}. + * + * @param status A HTTP status. Required. + * @param cause The cause of the problem. + */ + public Err(final Status status, final Throwable cause) { + super(message(status, null), cause); + this.status = status.value(); + } + + /** + * Creates a new {@link Err}. + * + * @param status A HTTP status. Required. + * @param cause The cause of the problem. + */ + public Err(final int status, final Throwable cause) { + this(Status.valueOf(status), cause); + } + + /** + * Creates a new {@link Err}. + * + * @param status A HTTP status. Required. + */ + public Err(final Status status) { + super(message(status, null)); + this.status = status.value(); + } + + /** + * Creates a new {@link Err}. + * + * @param status A HTTP status. Required. + */ + public Err(final int status) { + this(Status.valueOf(status)); + } + + /** + * @return The status code to send as response. + */ + public int statusCode() { + return status; + } + + /** + * Produces a friendly view of the err, resulting map has these attributes: + * + *
+   *  message: exception message (if present)
+   *  status: status code
+   *  reason: a status code reason
+   * 
+ * + * @return A lightweight view of the err. + */ + public Map toMap() { + return toMap(false); + } + + /** + * Produces a friendly view of the err, resulting map has these attributes: + * + *
+   *  message: exception message (if present)
+   *  stacktrace: array with the stacktrace
+   *  status: status code
+   *  reason: a status code reason
+   * 
+ * + * @param stacktrace True for adding stacktrace. + * @return A lightweight view of the err. + */ + public Map toMap(boolean stacktrace) { + Status status = Status.valueOf(this.status); + Throwable cause = Optional.ofNullable(getCause()).orElse(this); + String message = Optional.ofNullable(cause.getMessage()).orElse(status.reason()); + + Map err = new LinkedHashMap<>(); + err.put("message", message); + if (stacktrace) { + err.put("stacktrace", Throwables.getStackTraceAsString(cause).replace("\r", "").split("\\n")); + } + err.put("status", status.value()); + err.put("reason", status.reason()); + + return err; + } + + /** + * Build an error message using the HTTP status. + * + * @param status The HTTP Status. + * @param tail A message to append. + * @return An error message. + */ + private static String message(final Status status, @Nullable final String tail) { + return message(status.reason(), status.value(), tail); + } + + /** + * Build an error message using the HTTP status. + * + * @param reason Reason. + * @param status The Status. + * @param tail A message to append. + * @return An error message. + */ + private static String message(final String reason, final int status, @Nullable final String tail) { + return reason + "(" + status + ")" + (tail == null ? "" : ": " + tail); + } + +} diff --git a/jooby/src/main/java/org/jooby/FlashScope.java b/jooby/src/main/java/org/jooby/FlashScope.java new file mode 100644 index 00000000..c856ec1d --- /dev/null +++ b/jooby/src/main/java/org/jooby/FlashScope.java @@ -0,0 +1,144 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static java.util.Objects.requireNonNull; + +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + +import org.jooby.internal.handlers.FlashScopeHandler; +import org.jooby.mvc.Flash; + +import com.google.inject.Binder; +import com.typesafe.config.Config; + +/** + *

flash scope

+ *

+ * The flash scope is designed to transport success and error messages, between requests. The flash + * scope is similar to {@link Session} but lifecycle is shorter: data are kept for only one request. + *

+ *

+ * The flash scope is implemented as client side cookie, so it helps to keep application stateless. + *

+ * + *

usage

+ * + *
{@code
+ * {
+ *   use(new FlashScope());
+ *
+ *   get("/", req -> {
+ *     return req.ifFlash("success").orElse("Welcome!");
+ *   });
+ *
+ *   post("/", req -> {
+ *     req.flash("success", "The item has been created");
+ *     return Results.redirect("/");
+ *   });
+ * }
+ * }
+ * + * {@link FlashScope} is also available on mvc routes via {@link Flash} annotation: + * + *
{@code
+ * @Path("/")
+ * public class Controller {
+ *
+ *   @GET
+ *   public Object flashScope(@Flash Map<String, String> flash) {
+ *     ...
+ *   }
+ *
+ *   @GET
+ *   public Object flashAttr(@Flash String foo) {
+ *     ...
+ *   }
+ *
+ *   @GET
+ *   public Object optionlFlashAttr(@Flash Optional<String> foo) {
+ *     ...
+ *   }
+ * }
+ * }
+ * + *

+ * Worth to mention that flash attributes are accessible from template engine by prefixing + * attributes with flash.. Here is a handlebars.java example: + *

+ * + *
{@code
+ * {{#if flash.success}}
+ *   {{flash.success}}
+ * {{else}}
+ *   Welcome!
+ * {{/if}}
+ * }
+ * + * @author edgar + * @since 1.0.0.CR4 + */ +public class FlashScope implements Jooby.Module { + + public static final String NAME = "flash"; + + private Function> decoder = Cookie.URL_DECODER; + + private Function, String> encoder = Cookie.URL_ENCODER; + + private Optional cookie = Optional.empty(); + + private String method = "*"; + + private String path = "*"; + + /** + * Creates a new {@link FlashScope} and customize the flash cookie. + * + * @param cookie Cookie template for flash scope. + */ + public FlashScope(final Cookie.Definition cookie) { + this.cookie = Optional.of(requireNonNull(cookie, "Cookie required.")); + } + + /** + * Creates a new {@link FlashScope}. + */ + public FlashScope() { + } + + @Override + public void configure(final Env env, final Config conf, final Binder binder) { + Config $cookie = conf.getConfig("flash.cookie"); + String cpath = $cookie.getString("path"); + boolean chttp = $cookie.getBoolean("httpOnly"); + boolean csecure = $cookie.getBoolean("secure"); + Cookie.Definition cookie = this.cookie + .orElseGet(() -> new Cookie.Definition($cookie.getString("name"))); + + // uses user provided or fallback to defaults + cookie.path(cookie.path().orElse(cpath)) + .httpOnly(cookie.httpOnly().orElse(chttp)) + .secure(cookie.secure().orElse(csecure)); + + env.router() + .use(method, path, new FlashScopeHandler(cookie, decoder, encoder)) + .name("flash-scope"); + } + +} diff --git a/jooby/src/main/java/org/jooby/Jooby.java b/jooby/src/main/java/org/jooby/Jooby.java new file mode 100644 index 00000000..0abfb177 --- /dev/null +++ b/jooby/src/main/java/org/jooby/Jooby.java @@ -0,0 +1,3360 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import com.google.common.base.Joiner; +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkState; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.escape.Escaper; +import com.google.common.html.HtmlEscapers; +import com.google.common.net.UrlEscapers; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.inject.Binder; +import com.google.inject.Guice; +import com.google.inject.Injector; +import com.google.inject.Key; +import com.google.inject.Provider; +import com.google.inject.ProvisionException; +import com.google.inject.Stage; +import com.google.inject.TypeLiteral; +import com.google.inject.internal.ProviderMethodsModule; +import com.google.inject.multibindings.Multibinder; +import com.google.inject.name.Named; +import com.google.inject.name.Names; +import com.google.inject.util.Types; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; +import com.typesafe.config.ConfigObject; +import com.typesafe.config.ConfigValue; +import static com.typesafe.config.ConfigValueFactory.fromAnyRef; +import static java.util.Objects.requireNonNull; +import static org.jooby.Route.CONNECT; +import static org.jooby.Route.DELETE; +import org.jooby.Route.Definition; +import static org.jooby.Route.GET; +import static org.jooby.Route.HEAD; +import org.jooby.Route.Mapper; +import static org.jooby.Route.OPTIONS; +import static org.jooby.Route.PATCH; +import static org.jooby.Route.POST; +import static org.jooby.Route.PUT; +import static org.jooby.Route.TRACE; +import org.jooby.Session.Store; +import org.jooby.funzy.Throwing; +import org.jooby.funzy.Try; +import org.jooby.handlers.AssetHandler; +import org.jooby.internal.AppPrinter; +import org.jooby.internal.BuiltinParser; +import org.jooby.internal.BuiltinRenderer; +import org.jooby.internal.CookieSessionManager; +import org.jooby.internal.DefaulErrRenderer; +import org.jooby.internal.HttpHandlerImpl; +import org.jooby.internal.JvmInfo; +import org.jooby.internal.LocaleUtils; +import org.jooby.internal.ParameterNameProvider; +import org.jooby.internal.RequestScope; +import org.jooby.internal.RouteMetadata; +import org.jooby.internal.ServerExecutorProvider; +import org.jooby.internal.ServerLookup; +import org.jooby.internal.ServerSessionManager; +import org.jooby.internal.SessionManager; +import org.jooby.internal.SourceProvider; +import org.jooby.internal.TypeConverters; +import org.jooby.internal.handlers.HeadHandler; +import org.jooby.internal.handlers.OptionsHandler; +import org.jooby.internal.handlers.TraceHandler; +import org.jooby.internal.mvc.MvcRoutes; +import org.jooby.internal.mvc.MvcWebSocket; +import org.jooby.internal.parser.BeanParser; +import org.jooby.internal.parser.DateParser; +import org.jooby.internal.parser.LocalDateParser; +import org.jooby.internal.parser.LocaleParser; +import org.jooby.internal.parser.ParserExecutor; +import org.jooby.internal.parser.StaticMethodParser; +import org.jooby.internal.parser.StringConstructorParser; +import org.jooby.internal.parser.ZonedDateTimeParser; +import org.jooby.internal.ssl.SslContextProvider; +import org.jooby.mvc.Consumes; +import org.jooby.mvc.Produces; +import org.jooby.scope.Providers; +import org.jooby.scope.RequestScoped; +import org.jooby.spi.HttpHandler; +import org.jooby.spi.Server; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import javax.inject.Singleton; +import javax.net.ssl.SSLContext; +import java.io.File; +import java.lang.reflect.Type; +import java.nio.charset.Charset; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.text.DecimalFormat; +import java.text.NumberFormat; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.TimeZone; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +/** + *

jooby

+ *

getting started

+ * + *
+ * public class MyApp extends Jooby {
+ *
+ *   {
+ *      use(new Jackson()); // 1. JSON serializer.
+ *
+ *      // 2. Define a route
+ *      get("/", req {@literal ->} {
+ *        Map{@literal <}String, Object{@literal >} model = ...;
+ *        return model;
+ *      }
+ *   }
+ *
+ *  public static void main(String[] args) {
+ *    run(MyApp::new, args); // 3. Done!
+ *  }
+ * }
+ * 
+ * + *

application.conf

+ *

+ * Jooby delegate configuration management to TypeSafe Config. + *

+ * + *

+ * By default Jooby looks for an application.conf. If + * you want to specify a different file or location, you can do it with {@link #conf(String)}. + *

+ * + *

+ * TypeSafe Config uses a hierarchical model to + * define and override properties. + *

+ *

+ * A {@link Jooby.Module} might provides his own set of properties through the + * {@link Jooby.Module#config()} method. By default, this method returns an empty config object. + *

+ * For example: + * + *
+ *   use(new M1());
+ *   use(new M2());
+ *   use(new M3());
+ * 
+ * + * Previous example had the following order (first-listed are higher priority): + *
    + *
  • arguments properties
  • + *
  • System properties
  • + *
  • application.conf
  • + *
  • M3 properties
  • + *
  • M2 properties
  • + *
  • M1 properties
  • + *
+ *

+ * Command line argmuents or system properties takes precedence over any application specific + * property. + *

+ * + *

env

+ *

+ * Jooby defines one mode or environment: dev. In Jooby, dev + * is special and some modules could apply special settings while running in dev. + * Any other env is usually considered a prod like env. But that depends on module + * implementor. + *

+ *

+ * An environment can be defined in your .conf file using the + * application.env property. If missing, Jooby set the env for you to + * dev. + *

+ *

+ * There is more at {@link Env} please read the {@link Env} javadoc. + *

+ * + *

modules: the jump to full-stack framework

+ *

+ * {@link Jooby.Module Modules} are quite similar to a Guice modules except that the configure + * callback has been complementing with {@link Env} and {@link Config}. + *

+ * + *
+ *   public class MyModule implements Jooby.Module {
+ *     public void configure(Env env, Config config, Binder binder) {
+ *     }
+ *   }
+ * 
+ * + * From the configure callback you can bind your services as you usually do in a Guice app. + *

+ * There is more at {@link Jooby.Module} so please read the {@link Jooby.Module} javadoc. + *

+ * + *

path patterns

+ *

+ * Jooby supports Ant-style path patterns: + *

+ *

+ * Some examples: + *

+ *
    + *
  • {@code com/t?st.html} - matches {@code com/test.html} but also {@code com/tast.html} or + * {@code com/txst.html}
  • + *
  • {@code com/*.html} - matches all {@code .html} files in the {@code com} directory
  • + *
  • com/{@literal **}/test.html - matches all {@code test.html} files underneath the + * {@code com} path
  • + *
  • {@code **}/{@code *} - matches any path at any level.
  • + *
  • {@code *} - matches any path at any level, shorthand for {@code **}/{@code *}.
  • + *
+ * + *

variables

+ *

+ * Jooby supports path parameters too: + *

+ *

+ * Some examples: + *

+ *
    + *
  • /user/{id} - /user/* and give you access to the id var.
  • + *
  • /user/:id - /user/* and give you access to the id var.
  • + *
  • /user/{id:\\d+} - /user/[digits] and give you access to the numeric + * id var.
  • + *
+ * + *

routes

+ *

+ * Routes perform actions in response to a server HTTP request. + *

+ *

+ * Routes are executed in the order they are defined, for example: + *

+ * + *
+ *   get("/", (req, rsp) {@literal ->} {
+ *     log.info("first"); // start here and go to second
+ *   });
+ *
+ *   get("/", (req, rsp) {@literal ->} {
+ *     log.info("second"); // execute after first and go to final
+ *   });
+ *
+ *   get("/", (req, rsp) {@literal ->} {
+ *     rsp.send("final"); // done!
+ *   });
+ * 
+ * + * Previous example can be rewritten using {@link Route.Filter}: + * + *
+ *   get("/", (req, rsp, chain) {@literal ->} {
+ *     log.info("first"); // start here and go to second
+ *     chain.next(req, rsp);
+ *   });
+ *
+ *   get("/", (req, rsp, chain) {@literal ->} {
+ *     log.info("second"); // execute after first and go to final
+ *     chain.next(req, rsp);
+ *   });
+ *
+ *   get("/", (req, rsp) {@literal ->} {
+ *     rsp.send("final"); // done!
+ *   });
+ * 
+ * + * Due to the use of lambdas a route is a singleton and you should NOT use global variables. For + * example this is a bad practice: + * + *
+ *  List{@literal <}String{@literal >} names = new ArrayList{@literal <}{@literal >}(); // names produces side effects
+ *  get("/", (req, rsp) {@literal ->} {
+ *     names.add(req.param("name").value();
+ *     // response will be different between calls.
+ *     rsp.send(names);
+ *   });
+ * 
+ * + *

mvc routes

+ *

+ * A Mvc route use annotations to define routes: + *

+ * + *
+ *   use(MyRoute.class);
+ *   ...
+ *
+ *   // MyRoute.java
+ *   {@literal @}Path("/")
+ *   public class MyRoute {
+ *
+ *    {@literal @}GET
+ *    public String hello() {
+ *      return "Hello Jooby";
+ *    }
+ *   }
+ * 
+ *

+ * Programming model is quite similar to JAX-RS/Jersey with some minor differences and/or + * simplifications. + *

+ * + *

+ * To learn more about Mvc Routes, please check {@link org.jooby.mvc.Path}, + * {@link org.jooby.mvc.Produces} {@link org.jooby.mvc.Consumes} javadoc. + *

+ * + *

static files

+ *

+ * Static files, like: *.js, *.css, ..., etc... can be served with: + *

+ * + *
+ *   assets("assets/**");
+ * 
+ *

+ * Classpath resources under the /assets folder will be accessible from client/browser. + *

+ * + *

lifecyle

+ *

+ * We do provide {@link #onStart(Throwing.Consumer)} and {@link #onStop(Throwing.Consumer)} callbacks. + * These callbacks are executed are application startup or shutdown time: + *

+ * + *
{@code
+ * {
+ *   onStart(() -> {
+ *     log.info("Welcome!");
+ *   });
+ *
+ *   onStop(() -> {
+ *     log.info("Bye!");
+ *   });
+ * }
+ * }
+ * + *

+ * From life cycle callbacks you can access to application services: + *

+ * + *
{@code
+ * {
+ *   onStart(registry -> {
+ *     MyDatabase db = registry.require(MyDatabase.class);
+ *     // do something with databse:
+ *   });
+ * }
+ * }
+ * + * @author edgar + * @see Jooby.Module + * @since 0.1.0 + */ +public class Jooby implements Router, LifeCycle, Registry { + + /** + *
{@code
+   * {
+   *   on("dev", () -> {
+   *     // run something on dev
+   *   }).orElse(() -> {
+   *     // run something on prod
+   *   });
+   * }
+   * }
+ */ + public interface EnvPredicate { + + /** + *
{@code
+     * {
+     *   on("dev", () -> {
+     *     // run something on dev
+     *   }).orElse(() -> {
+     *     // run something on prod
+     *   });
+     * }
+     * }
+ * + * @param callback Env callback. + */ + default void orElse(final Runnable callback) { + orElse(conf -> callback.run()); + } + + /** + *
{@code
+     * {
+     *   on("dev", () -> {
+     *     // run something on dev
+     *   }).orElse(conf -> {
+     *     // run something on prod
+     *   });
+     * }
+     * }
+ * + * @param callback Env callback. + */ + void orElse(Consumer callback); + + } + + /** + * A module can publish or produces: {@link Route.Definition routes}, {@link Parser}, + * {@link Renderer}, and any other application specific service or contract of your choice. + *

+ * It is similar to {@link com.google.inject.Module} except for the callback method receives a + * {@link Env}, {@link Config} and {@link Binder}. + *

+ * + *

+ * A module can provide his own set of properties through the {@link #config()} method. By + * default, this method returns an empty config object. + *

+ * For example: + * + *
+   *   use(new M1());
+   *   use(new M2());
+   *   use(new M3());
+   * 
+ * + * Previous example had the following order (first-listed are higher priority): + *
    + *
  • System properties
  • + *
  • application.conf
  • + *
  • M3 properties
  • + *
  • M2 properties
  • + *
  • M1 properties
  • + *
+ * + *

+ * A module can provide start/stop methods in order to start or close resources. + *

+ * + * @author edgar + * @see Jooby#use(Jooby.Module) + * @since 0.1.0 + */ + public interface Module { + + /** + * @return Produces a module config object (when need it). By default a module doesn't produce + * any configuration object. + */ + @Nonnull + default Config config() { + return ConfigFactory.empty(); + } + + /** + * Configure and produces bindings for the underlying application. A module can optimize or + * customize a service by checking current the {@link Env application env} and/or the current + * application properties available from {@link Config}. + * + * @param env The current application's env. Not null. + * @param conf The current config object. Not null. + * @param binder A guice binder. Not null. + * @throws Throwable If something goes wrong. + */ + void configure(Env env, Config conf, Binder binder) throws Throwable; + + } + + static class MvcClass implements Route.Props { + Class routeClass; + + String path; + + ImmutableMap.Builder attrs = ImmutableMap.builder(); + + private List consumes; + + private String name; + + private List produces; + + private List excludes; + + private Mapper mapper; + + private String prefix; + + private String renderer; + + public MvcClass(final Class routeClass, final String path, final String prefix) { + this.routeClass = routeClass; + this.path = path; + this.prefix = prefix; + } + + @Override + public MvcClass attr(final String name, final Object value) { + attrs.put(name, value); + return this; + } + + @Override + public MvcClass name(final String name) { + this.name = name; + return this; + } + + @Override + public MvcClass consumes(final List consumes) { + this.consumes = consumes; + return this; + } + + @Override + public MvcClass produces(final List produces) { + this.produces = produces; + return this; + } + + @Override + public MvcClass excludes(final List excludes) { + this.excludes = excludes; + return this; + } + + @Override + public MvcClass map(final Mapper mapper) { + this.mapper = mapper; + return this; + } + + @Override + public String renderer() { + return renderer; + } + + @Override + public MvcClass renderer(final String name) { + this.renderer = name; + return this; + } + + public Route.Definition apply(final Route.Definition route) { + attrs.build().forEach(route::attr); + if (name != null) { + route.name(name); + } + if (prefix != null) { + route.name(prefix + "/" + route.name()); + } + if (consumes != null) { + route.consumes(consumes); + } + if (produces != null) { + route.produces(produces); + } + if (excludes != null) { + route.excludes(excludes); + } + if (mapper != null) { + route.map(mapper); + } + if (renderer != null) { + route.renderer(renderer); + } + return route; + } + } + + private static class EnvDep { + Predicate predicate; + + Consumer callback; + + public EnvDep(final Predicate predicate, final Consumer callback) { + this.predicate = predicate; + this.callback = callback; + } + } + + static { + // set pid as system property + String pid = System.getProperty("pid", JvmInfo.pid() + ""); + System.setProperty("pid", pid); + } + + /** + * Keep track of routes. + */ + private transient Set bag = new LinkedHashSet<>(); + + /** + * The override config. Optional. + */ + private transient Config srcconf; + + private final transient AtomicBoolean started = new AtomicBoolean(false); + + /** Keep the global injector instance. */ + private transient Injector injector; + + /** Session store. */ + private transient Session.Definition session = new Session.Definition(Session.Mem.class); + + /** Env builder. */ + private transient Env.Builder env = Env.DEFAULT; + + /** Route's prefix. */ + private transient String prefix; + + /** startup callback . */ + private transient List> onStart = new ArrayList<>(); + private transient List> onStarted = new ArrayList<>(); + + /** stop callback . */ + private transient List> onStop = new ArrayList<>(); + + /** Mappers . */ + @SuppressWarnings("rawtypes") + private transient Mapper mapper; + + /** Don't add same mapper twice . */ + private transient Set mappers = new HashSet<>(); + + /** Bean parser . */ + private transient Optional beanParser = Optional.empty(); + + private transient ServerLookup server = new ServerLookup(); + + private transient String dateFormat; + + private transient Charset charset; + + private transient String[] languages; + + private transient ZoneId zoneId; + + private transient Integer port; + + private transient Integer securePort; + + private transient String numberFormat; + + private transient boolean http2; + + private transient List> executors = new ArrayList<>(); + + private transient boolean defaultExecSet; + + private boolean throwBootstrapException; + + /** + * creates the injector + */ + private transient BiFunction injectorFactory = Guice::createInjector; + + private transient List apprefs; + + private transient LinkedList path = new LinkedList<>(); + + private transient String confname; + + private transient boolean caseSensitiveRouting = true; + + private transient String classname; + + /** + * Creates a new {@link Jooby} application. + */ + public Jooby() { + this(null); + } + + /** + * Creates a new application and prefix all the names of the routes with the given prefix. Useful, + * for dynamic/advanced routing. See {@link Route.Chain#next(String, Request, Response)}. + * + * @param prefix Route name prefix. + */ + public Jooby(final String prefix) { + this.prefix = prefix; + use(server); + this.classname = classname(getClass().getName()); + } + + @Override + public Route.Collection path(String path, Runnable action) { + this.path.addLast(Route.normalize(path)); + Route.Collection collection = with(action); + this.path.removeLast(); + return collection; + } + + @Override + public Jooby use(final Jooby app) { + return use(prefixPath(null), app); + } + + private Optional prefixPath(@Nullable String tail) { + return path.size() == 0 + ? tail == null ? Optional.empty() : Optional.of(Route.normalize(tail)) + : Optional.of(path.stream() + .collect(Collectors.joining("", "", tail == null + ? "" : Route.normalize(tail)))); + } + + @Override + public Jooby use(final String path, final Jooby app) { + return use(prefixPath(path), app); + } + + /** + * Use the provided HTTP server. + * + * @param server Server. + * @return This jooby instance. + */ + public Jooby server(final Class server) { + requireNonNull(server, "Server required."); + // remove server lookup + List tmp = bag.stream() + .skip(1) + .collect(Collectors.toList()); + tmp.add(0, + (Module) (env, conf, binder) -> binder.bind(Server.class).to(server).asEagerSingleton()); + bag.clear(); + bag.addAll(tmp); + return this; + } + + private Jooby use(final Optional path, final Jooby app) { + requireNonNull(app, "App is required."); + + Function rewrite = r -> { + return path.map(p -> { + Route.Definition result = new Route.Definition(r.method(), p + r.pattern(), r.filter()); + result.consumes(r.consumes()); + result.produces(r.produces()); + result.excludes(r.excludes()); + return result; + }).orElse(r); + }; + + app.bag.forEach(it -> { + if (it instanceof Route.Definition) { + this.bag.add(rewrite.apply((Definition) it)); + } else if (it instanceof MvcClass) { + Object routes = path.map(p -> new MvcClass(((MvcClass) it).routeClass, p, prefix)) + .orElse(it); + this.bag.add(routes); + } else { + // everything else + this.bag.add(it); + } + }); + // start/stop callback + app.onStart.forEach(this.onStart::add); + app.onStarted.forEach(this.onStarted::add); + app.onStop.forEach(this.onStop::add); + // mapper + if (app.mapper != null) { + this.map(app.mapper); + } + if (apprefs == null) { + apprefs = new ArrayList<>(); + } + apprefs.add(app); + return this; + } + + /** + * Set a custom {@link Env.Builder} to use. + * + * @param env A custom env builder. + * @return This jooby instance. + */ + public Jooby env(final Env.Builder env) { + this.env = requireNonNull(env, "Env builder is required."); + return this; + } + + @Override + public Jooby onStart(final Throwing.Runnable callback) { + LifeCycle.super.onStart(callback); + return this; + } + + @Override + public Jooby onStart(final Throwing.Consumer callback) { + requireNonNull(callback, "Callback is required."); + onStart.add(callback); + return this; + } + + @Override + public Jooby onStarted(final Throwing.Runnable callback) { + LifeCycle.super.onStarted(callback); + return this; + } + + @Override + public Jooby onStarted(final Throwing.Consumer callback) { + requireNonNull(callback, "Callback is required."); + onStarted.add(callback); + return this; + } + + @Override + public Jooby onStop(final Throwing.Runnable callback) { + LifeCycle.super.onStop(callback); + return this; + } + + @Override + public Jooby onStop(final Throwing.Consumer callback) { + requireNonNull(callback, "Callback is required."); + onStop.add(callback); + return this; + } + + /** + * Run the given callback if and only if, application runs in the given environment. + * + *
+   * {
+   *   on("dev", () {@literal ->} {
+   *     use(new DevModule());
+   *   });
+   * }
+   * 
+ * + * There is an else clause which is the opposite version of the env predicate: + * + *
+   * {
+   *   on("dev", () {@literal ->} {
+   *     use(new DevModule());
+   *   }).orElse(() {@literal ->} {
+   *     use(new RealModule());
+   *   });
+   * }
+   * 
+ * + * @param env Environment where we want to run the callback. + * @param callback An env callback. + * @return This jooby instance. + */ + public EnvPredicate on(final String env, final Runnable callback) { + requireNonNull(env, "Env is required."); + return on(envpredicate(env), callback); + } + + /** + * Run the given callback if and only if, application runs in the given environment. + * + *
+   * {
+   *   on("dev", () {@literal ->} {
+   *     use(new DevModule());
+   *   });
+   * }
+   * 
+ * + * There is an else clause which is the opposite version of the env predicate: + * + *
+   * {
+   *   on("dev", conf {@literal ->} {
+   *     use(new DevModule());
+   *   }).orElse(conf {@literal ->} {
+   *     use(new RealModule());
+   *   });
+   * }
+   * 
+ * + * @param env Environment where we want to run the callback. + * @param callback An env callback. + * @return This jooby instance. + */ + public EnvPredicate on(final String env, final Consumer callback) { + requireNonNull(env, "Env is required."); + return on(envpredicate(env), callback); + } + + /** + * Run the given callback if and only if, application runs in the given envirobment. + * + *
+   * {
+   *   on("dev", "test", () {@literal ->} {
+   *     use(new DevModule());
+   *   });
+   * }
+   * 
+ * + * There is an else clause which is the opposite version of the env predicate: + * + *
+   * {
+   *   on(env {@literal ->} env.equals("dev"), () {@literal ->} {
+   *     use(new DevModule());
+   *   }).orElse(() {@literal ->} {
+   *     use(new RealModule());
+   *   });
+   * }
+   * 
+ * + * @param predicate Predicate to check the environment. + * @param callback An env callback. + * @return This jooby instance. + */ + public EnvPredicate on(final Predicate predicate, final Runnable callback) { + requireNonNull(predicate, "Predicate is required."); + requireNonNull(callback, "Callback is required."); + + return on(predicate, conf -> callback.run()); + } + + /** + * Run the given callback if and only if, application runs in the given environment. + * + *
+   * {
+   *   on(env {@literal ->} env.equals("dev"), conf {@literal ->} {
+   *     use(new DevModule());
+   *   });
+   * }
+   * 
+ * + * @param predicate Predicate to check the environment. + * @param callback An env callback. + * @return This jooby instance. + */ + public EnvPredicate on(final Predicate predicate, final Consumer callback) { + requireNonNull(predicate, "Predicate is required."); + requireNonNull(callback, "Callback is required."); + this.bag.add(new EnvDep(predicate, callback)); + + return otherwise -> this.bag.add(new EnvDep(predicate.negate(), otherwise)); + } + + /** + * Run the given callback if and only if, application runs in the given environment. + * + *
+   * {
+   *   on("dev", "test", "mock", () {@literal ->} {
+   *     use(new DevModule());
+   *   });
+   * }
+   * 
+ * + * @param env1 Environment where we want to run the callback. + * @param env2 Environment where we want to run the callback. + * @param env3 Environment where we want to run the callback. + * @param callback An env callback. + * @return This jooby instance. + */ + public Jooby on(final String env1, final String env2, final String env3, + final Runnable callback) { + on(envpredicate(env1).or(envpredicate(env2)).or(envpredicate(env3)), callback); + return this; + } + + @Override + public T require(final Key type) { + checkState(injector != null, + "Registry is not ready. Require calls are available at application startup time, see http://jooby.org/doc/#application-life-cycle"); + try { + return injector.getInstance(type); + } catch (ProvisionException x) { + Throwable cause = x.getCause(); + if (cause instanceof Err) { + throw (Err) cause; + } + throw x; + } + } + + @Override + public Route.OneArgHandler promise(final Deferred.Initializer initializer) { + return req -> { + return new Deferred(initializer); + }; + } + + @Override + public Route.OneArgHandler promise(final String executor, + final Deferred.Initializer initializer) { + return req -> new Deferred(executor, initializer); + } + + @Override + public Route.OneArgHandler promise(final Deferred.Initializer0 initializer) { + return req -> { + return new Deferred(initializer); + }; + } + + @Override + public Route.OneArgHandler promise(final String executor, + final Deferred.Initializer0 initializer) { + return req -> new Deferred(executor, initializer); + } + + /** + * Setup a session store to use. Useful if you want/need to persist sessions between shutdowns, + * apply timeout policies, etc... + * + * Jooby comes with a dozen of {@link Session.Store}, checkout the + * session modules. + * + * This method returns a {@link Session.Definition} objects that let you customize the session + * cookie. + * + * @param store A session store. + * @return A session store definition. + */ + public Session.Definition session(final Class store) { + this.session = new Session.Definition(requireNonNull(store, "A session store is required.")); + return this.session; + } + + /** + * Setup a session store that saves data in a the session cookie. It makes the application + * stateless, which help to scale easily. Keep in mind that a cookie has a limited size (up to + * 4kb) so you must pay attention to what you put in the session object (don't use as cache). + * + * Cookie session signed data using the application.secret property, so you must + * provide an application.secret value. On dev environment you can set it in your + * .conf file. In prod is probably better to provide as command line argument and/or + * environment variable. Just make sure to keep it private. + * + * Please note {@link Session#id()}, {@link Session#accessedAt()}, etc.. make no sense for cookie + * sessions, just the {@link Session#attributes()}. + * + * This method returns a {@link Session.Definition} objects that let you customize the session + * cookie. + * + * @return A session definition/configuration object. + */ + public Session.Definition cookieSession() { + this.session = new Session.Definition(); + return this.session; + } + + /** + * Setup a session store to use. Useful if you want/need to persist sessions between shutdowns, + * apply timeout policies, etc... + * + * Jooby comes with a dozen of {@link Session.Store}, checkout the + * session modules. + * + * This method returns a {@link Session.Definition} objects that let you customize the session + * cookie. + * + * @param store A session store. + * @return A session store definition. + */ + public Session.Definition session(final Session.Store store) { + this.session = new Session.Definition(requireNonNull(store, "A session store is required.")); + return this.session; + } + + /** + * Register a new param/body converter. See {@link Parser} for more details. + * + * @param parser A parser. + * @return This jooby instance. + */ + public Jooby parser(final Parser parser) { + if (parser instanceof BeanParser) { + beanParser = Optional.of(parser); + } else { + bag.add(requireNonNull(parser, "A parser is required.")); + } + return this; + } + + /** + * Append a response {@link Renderer} for write HTTP messages. + * + * @param renderer A renderer renderer. + * @return This jooby instance. + */ + public Jooby renderer(final Renderer renderer) { + this.bag.add(requireNonNull(renderer, "A renderer is required.")); + return this; + } + + @Override + public Route.Definition before(final String method, final String pattern, + final Route.Before handler) { + return appendDefinition(method, pattern, handler); + } + + @Override + public Route.Definition after(final String method, final String pattern, + final Route.After handler) { + return appendDefinition(method, pattern, handler); + } + + @Override + public Route.Definition complete(final String method, final String pattern, + final Route.Complete handler) { + return appendDefinition(method, pattern, handler); + } + + @Override + public Route.Definition use(final String path, final Route.Filter filter) { + return appendDefinition("*", path, filter); + } + + @Override + public Route.Definition use(final String verb, final String path, final Route.Filter filter) { + return appendDefinition(verb, path, filter); + } + + @Override + public Route.Definition use(final String verb, final String path, final Route.Handler handler) { + return appendDefinition(verb, path, handler); + } + + @Override + public Route.Definition use(final String path, final Route.Handler handler) { + return appendDefinition("*", path, handler); + } + + @Override + public Route.Definition use(final String path, final Route.OneArgHandler handler) { + return appendDefinition("*", path, handler); + } + + @Override + public Route.Definition get(final String path, final Route.Handler handler) { + if (handler instanceof AssetHandler) { + return assets(path, (AssetHandler) handler); + } else { + return appendDefinition(GET, path, handler); + } + } + + @Override + public Route.Collection get(final String path1, final String path2, final Route.Handler handler) { + return new Route.Collection( + new Route.Definition[]{get(path1, handler), get(path2, handler)}); + } + + @Override + public Route.Collection get(final String path1, final String path2, final String path3, + final Route.Handler handler) { + return new Route.Collection( + new Route.Definition[]{get(path1, handler), get(path2, handler), get(path3, handler)}); + } + + @Override + public Route.Definition get(final String path, final Route.OneArgHandler handler) { + return appendDefinition(GET, path, handler); + } + + @Override + public Route.Collection get(final String path1, final String path2, + final Route.OneArgHandler handler) { + return new Route.Collection( + new Route.Definition[]{get(path1, handler), get(path2, handler)}); + } + + @Override + public Route.Collection get(final String path1, final String path2, + final String path3, final Route.OneArgHandler handler) { + return new Route.Collection( + new Route.Definition[]{get(path1, handler), get(path2, handler), get(path3, handler)}); + } + + @Override + public Route.Definition get(final String path, final Route.ZeroArgHandler handler) { + return appendDefinition(GET, path, handler); + } + + @Override + public Route.Collection get(final String path1, final String path2, + final Route.ZeroArgHandler handler) { + return new Route.Collection( + new Route.Definition[]{get(path1, handler), get(path2, handler)}); + } + + @Override + public Route.Collection get(final String path1, final String path2, + final String path3, final Route.ZeroArgHandler handler) { + return new Route.Collection( + new Route.Definition[]{get(path1, handler), get(path2, handler), get(path3, handler)}); + } + + @Override + public Route.Definition get(final String path, final Route.Filter filter) { + return appendDefinition(GET, path, filter); + } + + @Override + public Route.Collection get(final String path1, final String path2, final Route.Filter filter) { + return new Route.Collection(new Route.Definition[]{get(path1, filter), get(path2, filter)}); + } + + @Override + public Route.Collection get(final String path1, final String path2, + final String path3, final Route.Filter filter) { + return new Route.Collection( + new Route.Definition[]{get(path1, filter), get(path2, filter), get(path3, filter)}); + } + + @Override + public Route.Definition post(final String path, final Route.Handler handler) { + return appendDefinition(POST, path, handler); + } + + @Override + public Route.Collection post(final String path1, final String path2, + final Route.Handler handler) { + return new Route.Collection( + new Route.Definition[]{post(path1, handler), post(path2, handler)}); + } + + @Override + public Route.Collection post(final String path1, final String path2, + final String path3, final Route.Handler handler) { + return new Route.Collection( + new Route.Definition[]{post(path1, handler), post(path2, handler), post(path3, handler)}); + } + + @Override + public Route.Definition post(final String path, final Route.OneArgHandler handler) { + return appendDefinition(POST, path, handler); + } + + @Override + public Route.Collection post(final String path1, final String path2, + final Route.OneArgHandler handler) { + return new Route.Collection( + new Route.Definition[]{post(path1, handler), post(path2, handler)}); + } + + @Override + public Route.Collection post(final String path1, final String path2, + final String path3, final Route.OneArgHandler handler) { + return new Route.Collection( + new Route.Definition[]{post(path1, handler), post(path2, handler), post(path3, handler)}); + } + + @Override + public Route.Definition post(final String path, final Route.ZeroArgHandler handler) { + return appendDefinition(POST, path, handler); + } + + @Override + public Route.Collection post(final String path1, final String path2, + final Route.ZeroArgHandler handler) { + return new Route.Collection( + new Route.Definition[]{post(path1, handler), post(path2, handler)}); + } + + @Override + public Route.Collection post(final String path1, final String path2, + final String path3, final Route.ZeroArgHandler handler) { + return new Route.Collection( + new Route.Definition[]{post(path1, handler), post(path2, handler), post(path3, handler)}); + } + + @Override + public Route.Definition post(final String path, final Route.Filter filter) { + return appendDefinition(POST, path, filter); + } + + @Override + public Route.Collection post(final String path1, final String path2, + final Route.Filter filter) { + return new Route.Collection( + new Route.Definition[]{post(path1, filter), post(path2, filter)}); + } + + @Override + public Route.Collection post(final String path1, final String path2, + final String path3, final Route.Filter filter) { + return new Route.Collection( + new Route.Definition[]{post(path1, filter), post(path2, filter), post(path3, filter)}); + } + + @Override + public Route.Definition head(final String path, final Route.Handler handler) { + return appendDefinition(HEAD, path, handler); + } + + @Override + public Route.Definition head(final String path, + final Route.OneArgHandler handler) { + return appendDefinition(HEAD, path, handler); + } + + @Override + public Route.Definition head(final String path, final Route.ZeroArgHandler handler) { + return appendDefinition(HEAD, path, handler); + } + + @Override + public Route.Definition head(final String path, final Route.Filter filter) { + return appendDefinition(HEAD, path, filter); + } + + @Override + public Route.Definition head() { + return appendDefinition(HEAD, "*", filter(HeadHandler.class)).name("*.head"); + } + + @Override + public Route.Definition options(final String path, final Route.Handler handler) { + return appendDefinition(OPTIONS, path, handler); + } + + @Override + public Route.Definition options(final String path, + final Route.OneArgHandler handler) { + return appendDefinition(OPTIONS, path, handler); + } + + @Override + public Route.Definition options(final String path, + final Route.ZeroArgHandler handler) { + return appendDefinition(OPTIONS, path, handler); + } + + @Override + public Route.Definition options(final String path, + final Route.Filter filter) { + return appendDefinition(OPTIONS, path, filter); + } + + @Override + public Route.Definition options() { + return appendDefinition(OPTIONS, "*", handler(OptionsHandler.class)).name("*.options"); + } + + @Override + public Route.Definition put(final String path, + final Route.Handler handler) { + return appendDefinition(PUT, path, handler); + } + + @Override + public Route.Collection put(final String path1, final String path2, + final Route.Handler handler) { + return new Route.Collection( + new Route.Definition[]{put(path1, handler), put(path2, handler)}); + } + + @Override + public Route.Collection put(final String path1, final String path2, + final String path3, final Route.Handler handler) { + return new Route.Collection( + new Route.Definition[]{put(path1, handler), put(path2, handler), put(path3, handler)}); + } + + @Override + public Route.Definition put(final String path, + final Route.OneArgHandler handler) { + return appendDefinition(PUT, path, handler); + } + + @Override + public Route.Collection put(final String path1, final String path2, + final Route.OneArgHandler handler) { + return new Route.Collection( + new Route.Definition[]{put(path1, handler), put(path2, handler)}); + } + + @Override + public Route.Collection put(final String path1, final String path2, + final String path3, final Route.OneArgHandler handler) { + return new Route.Collection( + new Route.Definition[]{put(path1, handler), put(path2, handler), put(path3, handler)}); + } + + @Override + public Route.Definition put(final String path, + final Route.ZeroArgHandler handler) { + return appendDefinition(PUT, path, handler); + } + + @Override + public Route.Collection put(final String path1, final String path2, + final Route.ZeroArgHandler handler) { + return new Route.Collection( + new Route.Definition[]{put(path1, handler), put(path2, handler)}); + } + + @Override + public Route.Collection put(final String path1, final String path2, + final String path3, final Route.ZeroArgHandler handler) { + return new Route.Collection( + new Route.Definition[]{put(path1, handler), put(path2, handler), put(path3, handler)}); + } + + @Override + public Route.Definition put(final String path, + final Route.Filter filter) { + return appendDefinition(PUT, path, filter); + } + + @Override + public Route.Collection put(final String path1, final String path2, + final Route.Filter filter) { + return new Route.Collection( + new Route.Definition[]{put(path1, filter), put(path2, filter)}); + } + + @Override + public Route.Collection put(final String path1, final String path2, + final String path3, final Route.Filter filter) { + return new Route.Collection( + new Route.Definition[]{put(path1, filter), put(path2, filter), put(path3, filter)}); + } + + @Override + public Route.Definition patch(final String path, final Route.Handler handler) { + return appendDefinition(PATCH, path, handler); + } + + @Override + public Route.Collection patch(final String path1, final String path2, + final Route.Handler handler) { + return new Route.Collection( + new Route.Definition[]{patch(path1, handler), patch(path2, handler)}); + } + + @Override + public Route.Collection patch(final String path1, final String path2, + final String path3, final Route.Handler handler) { + return new Route.Collection( + new Route.Definition[]{patch(path1, handler), patch(path2, handler), + patch(path3, handler)}); + } + + @Override + public Route.Definition patch(final String path, final Route.OneArgHandler handler) { + return appendDefinition(PATCH, path, handler); + } + + @Override + public Route.Collection patch(final String path1, final String path2, + final Route.OneArgHandler handler) { + return new Route.Collection( + new Route.Definition[]{patch(path1, handler), patch(path2, handler)}); + } + + @Override + public Route.Collection patch(final String path1, final String path2, + final String path3, final Route.OneArgHandler handler) { + return new Route.Collection( + new Route.Definition[]{patch(path1, handler), patch(path2, handler), + patch(path3, handler)}); + } + + @Override + public Route.Definition patch(final String path, final Route.ZeroArgHandler handler) { + return appendDefinition(PATCH, path, handler); + } + + @Override + public Route.Collection patch(final String path1, final String path2, + final Route.ZeroArgHandler handler) { + return new Route.Collection( + new Route.Definition[]{patch(path1, handler), patch(path2, handler)}); + } + + @Override + public Route.Collection patch(final String path1, final String path2, + final String path3, final Route.ZeroArgHandler handler) { + return new Route.Collection( + new Route.Definition[]{patch(path1, handler), patch(path2, handler), + patch(path3, handler)}); + } + + @Override + public Route.Definition patch(final String path, + final Route.Filter filter) { + return appendDefinition(PATCH, path, filter); + } + + @Override + public Route.Collection patch(final String path1, final String path2, + final Route.Filter filter) { + return new Route.Collection( + new Route.Definition[]{patch(path1, filter), patch(path2, filter)}); + } + + @Override + public Route.Collection patch(final String path1, final String path2, + final String path3, final Route.Filter filter) { + return new Route.Collection( + new Route.Definition[]{patch(path1, filter), patch(path2, filter), + patch(path3, filter)}); + } + + @Override + public Route.Definition delete(final String path, final Route.Handler handler) { + return appendDefinition(DELETE, path, handler); + } + + @Override + public Route.Collection delete(final String path1, final String path2, + final Route.Handler handler) { + return new Route.Collection( + new Route.Definition[]{delete(path1, handler), delete(path2, handler)}); + } + + @Override + public Route.Collection delete(final String path1, final String path2, final String path3, + final Route.Handler handler) { + return new Route.Collection( + new Route.Definition[]{delete(path1, handler), delete(path2, handler), + delete(path3, handler)}); + } + + @Override + public Route.Definition delete(final String path, final Route.OneArgHandler handler) { + return appendDefinition(DELETE, path, handler); + } + + @Override + public Route.Collection delete(final String path1, final String path2, + final Route.OneArgHandler handler) { + return new Route.Collection( + new Route.Definition[]{delete(path1, handler), delete(path2, handler)}); + } + + @Override + public Route.Collection delete(final String path1, final String path2, final String path3, + final Route.OneArgHandler handler) { + return new Route.Collection( + new Route.Definition[]{delete(path1, handler), delete(path2, handler), + delete(path3, handler)}); + } + + @Override + public Route.Definition delete(final String path, + final Route.ZeroArgHandler handler) { + return appendDefinition(DELETE, path, handler); + } + + @Override + public Route.Collection delete(final String path1, + final String path2, final Route.ZeroArgHandler handler) { + return new Route.Collection( + new Route.Definition[]{delete(path1, handler), delete(path2, handler)}); + } + + @Override + public Route.Collection delete(final String path1, final String path2, final String path3, + final Route.ZeroArgHandler handler) { + return new Route.Collection( + new Route.Definition[]{delete(path1, handler), delete(path2, handler), + delete(path3, handler)}); + } + + @Override + public Route.Definition delete(final String path, final Route.Filter filter) { + return appendDefinition(DELETE, path, filter); + } + + @Override + public Route.Collection delete(final String path1, final String path2, + final Route.Filter filter) { + return new Route.Collection( + new Route.Definition[]{delete(path1, filter), delete(path2, filter)}); + } + + @Override + public Route.Collection delete(final String path1, final String path2, final String path3, + final Route.Filter filter) { + return new Route.Collection( + new Route.Definition[]{delete(path1, filter), delete(path2, filter), + delete(path3, filter)}); + } + + @Override + public Route.Definition trace(final String path, final Route.Handler handler) { + return appendDefinition(TRACE, path, handler); + } + + @Override + public Route.Definition trace(final String path, final Route.OneArgHandler handler) { + return appendDefinition(TRACE, path, handler); + } + + @Override + public Route.Definition trace(final String path, final Route.ZeroArgHandler handler) { + return appendDefinition(TRACE, path, handler); + } + + @Override + public Route.Definition trace(final String path, final Route.Filter filter) { + return appendDefinition(TRACE, path, filter); + } + + @Override + public Route.Definition trace() { + return appendDefinition(TRACE, "*", handler(TraceHandler.class)).name("*.trace"); + } + + @Override + public Route.Definition connect(final String path, final Route.Handler handler) { + return appendDefinition(CONNECT, path, handler); + } + + @Override + public Route.Definition connect(final String path, final Route.OneArgHandler handler) { + return appendDefinition(CONNECT, path, handler); + } + + @Override + public Route.Definition connect(final String path, final Route.ZeroArgHandler handler) { + return appendDefinition(CONNECT, path, handler); + } + + @Override + public Route.Definition connect(final String path, final Route.Filter filter) { + return appendDefinition(CONNECT, path, filter); + } + + /** + * Creates a new {@link Route.Handler} that delegate the execution to the given handler. This is + * useful when the target handler requires some dependencies. + * + *
+   *   public class MyHandler implements Route.Handler {
+   *     @Inject
+   *     public MyHandler(Dependency d) {
+   *     }
+   *
+   *     public void handle(Request req, Response rsp) throws Exception {
+   *      // do something
+   *     }
+   *   }
+   *   ...
+   *   // external route
+   *   get("/", handler(MyHandler.class));
+   *
+   *   // inline version route
+   *   get("/", (req, rsp) {@literal ->} {
+   *     Dependency d = req.getInstance(Dependency.class);
+   *     // do something
+   *   });
+   * 
+ * + * You can access to a dependency from a in-line route too, so the use of external route it is + * more or less a matter of taste. + * + * @param handler The external handler class. + * @return A new inline route handler. + */ + private Route.Handler handler(final Class handler) { + requireNonNull(handler, "Route handler is required."); + return (req, rsp) -> req.require(handler).handle(req, rsp); + } + + /** + * Creates a new {@link Route.Filter} that delegate the execution to the given filter. This is + * useful when the target handler requires some dependencies. + * + *
+   *   public class MyFilter implements Filter {
+   *     @Inject
+   *     public MyFilter(Dependency d) {
+   *     }
+   *
+   *     public void handle(Request req, Response rsp, Route.Chain chain) throws Exception {
+   *      // do something
+   *     }
+   *   }
+   *   ...
+   *   // external filter
+   *   get("/", filter(MyFilter.class));
+   *
+   *   // inline version route
+   *   get("/", (req, rsp, chain) {@literal ->} {
+   *     Dependency d = req.getInstance(Dependency.class);
+   *     // do something
+   *   });
+   * 
+ * + * You can access to a dependency from a in-line route too, so the use of external filter it is + * more or less a matter of taste. + * + * @param filter The external filter class. + * @return A new inline route. + */ + private Route.Filter filter(final Class filter) { + requireNonNull(filter, "Filter is required."); + return (req, rsp, chain) -> req.require(filter).handle(req, rsp, chain); + } + + @Override + public Route.AssetDefinition assets(final String path, final Path basedir) { + return assets(path, new AssetHandler(basedir)); + } + + @Override + public Route.AssetDefinition assets(final String path, final String location) { + return assets(path, new AssetHandler(location)); + } + + @Override + public Route.AssetDefinition assets(final String path, final AssetHandler handler) { + Route.AssetDefinition route = appendDefinition(GET, path, handler, Route.AssetDefinition::new); + return configureAssetHandler(route); + } + + @Override + public Route.Collection use(final Class routeClass) { + return use("", routeClass); + } + + @Override + public Route.Collection use(final String path, final Class routeClass) { + requireNonNull(routeClass, "Route class is required."); + requireNonNull(path, "Path is required"); + MvcClass mvc = new MvcClass(routeClass, path, prefix); + bag.add(mvc); + return new Route.Collection(mvc); + } + + /** + * Keep track of routes in the order user define them. + * + * @param method Route method. + * @param pattern Route pattern. + * @param filter Route filter. + * @return The same route definition. + */ + private Route.Definition appendDefinition(String method, String pattern, Route.Filter filter) { + return appendDefinition(method, pattern, filter, Route.Definition::new); + } + + /** + * Keep track of routes in the order user define them. + * + * @param method Route method. + * @param pattern Route pattern. + * @param filter Route filter. + * @param creator Route creator. + * @return The same route definition. + */ + private T appendDefinition(String method, String pattern, + Route.Filter filter, Throwing.Function4 creator) { + String pathPattern = prefixPath(pattern).orElse(pattern); + T route = creator.apply(method, pathPattern, filter, caseSensitiveRouting); + if (prefix != null) { + route.prefix = prefix; + // reset name will update the name if prefix != null + route.name(route.name()); + } + bag.add(route); + return route; + } + + /** + * Import an application {@link Module}. + * + * @param module The module to import. + * @return This jooby instance. + * @see Jooby.Module + */ + public Jooby use(final Jooby.Module module) { + requireNonNull(module, "A module is required."); + bag.add(module); + return this; + } + + /** + * Set/specify a custom .conf file, useful when you don't want a application.conf + * file. + * + * @param path Classpath location. + * @return This jooby instance. + */ + public Jooby conf(final String path) { + this.confname = path; + use(ConfigFactory.parseResources(path)); + return this; + } + + /** + * Set/specify a custom .conf file, useful when you don't want a application.conf + * file. + * + * @param path File system location. + * @return This jooby instance. + */ + public Jooby conf(final File path) { + this.confname = path.getName(); + use(ConfigFactory.parseFile(path)); + return this; + } + + /** + * Set the application configuration object. You must call this method when the default file + * name: application.conf doesn't work for you or when you need/want to register two + * or more files. + * + * @param config The application configuration object. + * @return This jooby instance. + * @see Config + */ + public Jooby use(final Config config) { + this.srcconf = requireNonNull(config, "Config required."); + return this; + } + + @Override + public Jooby err(final Err.Handler err) { + this.bag.add(requireNonNull(err, "An err handler is required.")); + return this; + } + + @Override + public WebSocket.Definition ws(final String path, final WebSocket.OnOpen handler) { + WebSocket.Definition ws = new WebSocket.Definition(path, handler); + checkArgument(bag.add(ws), "Duplicated path: '%s'", path); + return ws; + } + + @Override + public WebSocket.Definition ws(final String path, + final Class> handler) { + String fpath = Optional.ofNullable(handler.getAnnotation(org.jooby.mvc.Path.class)) + .map(it -> path + "/" + it.value()[0]) + .orElse(path); + + WebSocket.Definition ws = ws(fpath, MvcWebSocket.newWebSocket(handler)); + + Optional.ofNullable(handler.getAnnotation(Consumes.class)) + .ifPresent(consumes -> Arrays.asList(consumes.value()).forEach(ws::consumes)); + Optional.ofNullable(handler.getAnnotation(Produces.class)) + .ifPresent(produces -> Arrays.asList(produces.value()).forEach(ws::produces)); + return ws; + } + + @Override + public Route.Definition sse(final String path, final Sse.Handler handler) { + return appendDefinition(GET, path, handler).consumes(MediaType.sse); + } + + @Override + public Route.Definition sse(final String path, final Sse.Handler1 handler) { + return appendDefinition(GET, path, handler).consumes(MediaType.sse); + } + + @SuppressWarnings("rawtypes") + @Override + public Route.Collection with(final Runnable callback) { + // hacky way of doing what we want... but we do simplify developer life + int size = this.bag.size(); + callback.run(); + // collect latest routes and apply route props + List local = this.bag.stream() + .skip(size) + .filter(Route.Props.class::isInstance) + .map(Route.Props.class::cast) + .collect(Collectors.toList()); + return new Route.Collection(local.toArray(new Route.Props[local.size()])); + } + + /** + * Prepare and startup a {@link Jooby} application. + * + * @param app Application supplier. + * @param args Application arguments. + */ + public static void run(final Supplier app, final String... args) { + Config conf = ConfigFactory.systemProperties() + .withFallback(args(args)); + System.setProperty("logback.configurationFile", logback(conf)); + app.get().start(args); + } + + /** + * Prepare and startup a {@link Jooby} application. + * + * @param app Application supplier. + * @param args Application arguments. + */ + public static void run(final Class app, final String... args) { + run(() -> Try.apply(() -> app.newInstance()).get(), args); + } + + /** + * Export configuration from an application. Useful for tooling, testing, debugging, etc... + * + * @param app Application to extract/collect configuration. + * @return Application conf or empty conf on error. + */ + public static Config exportConf(final Jooby app) { + AtomicReference conf = new AtomicReference<>(ConfigFactory.empty()); + app.on("*", c -> { + conf.set(c); + }); + exportRoutes(app); + return conf.get(); + } + + /** + * Export routes from an application. Useful for route analysis, testing, debugging, etc... + * + * @param app Application to extract/collect routes. + * @return Application routes. + */ + public static List exportRoutes(final Jooby app) { + @SuppressWarnings("serial") class Success extends RuntimeException { + List routes; + + Success(final List routes) { + this.routes = routes; + } + } + List routes = Collections.emptyList(); + try { + app.start(new String[0], r -> { + throw new Success(r); + }); + } catch (Success success) { + routes = success.routes; + } catch (Throwable x) { + logger(app).debug("Failed bootstrap: {}", app, x); + } + return routes; + } + + /** + * Start an application. Fire the {@link #onStart(Throwing.Runnable)} event and the + * {@link #onStarted(Throwing.Runnable)} events. + */ + public void start() { + start(new String[0]); + } + + /** + * Start an application. Fire the {@link #onStart(Throwing.Runnable)} event and the + * {@link #onStarted(Throwing.Runnable)} events. + * + * @param args Application arguments. + */ + public void start(final String... args) { + try { + start(args, null); + } catch (Throwable x) { + stop(); + String msg = "An error occurred while starting the application:"; + if (throwBootstrapException) { + throw new Err(Status.SERVICE_UNAVAILABLE, msg, x); + } else { + logger(this).error(msg, x); + } + } + } + + @SuppressWarnings("unchecked") + private void start(final String[] args, final Consumer> routes) + throws Throwable { + long start = System.currentTimeMillis(); + + started.set(true); + + this.injector = bootstrap(args(args), routes); + + // shutdown hook + Runtime.getRuntime().addShutdownHook(new Thread(this::stop)); + + Config conf = injector.getInstance(Config.class); + + Logger log = logger(this); + + // inject class + injector.injectMembers(this); + + // onStart callbacks via .conf + if (conf.hasPath("jooby.internal.onStart")) { + ClassLoader loader = getClass().getClassLoader(); + Object internalOnStart = loader.loadClass(conf.getString("jooby.internal.onStart")) + .newInstance(); + onStart.add((Throwing.Consumer) internalOnStart); + } + + // start services + for (Throwing.Consumer onStart : this.onStart) { + onStart.accept(this); + } + + // route mapper + Set routeDefs = injector.getInstance(Route.KEY); + Set sockets = injector.getInstance(WebSocket.KEY); + if (mapper != null) { + routeDefs.forEach(it -> it.map(mapper)); + } + + AppPrinter printer = new AppPrinter(routeDefs, sockets, conf); + printer.printConf(log, conf); + + // Start server + Server server = injector.getInstance(Server.class); + String serverName = server.getClass().getSimpleName().replace("Server", "").toLowerCase(); + + server.start(); + long end = System.currentTimeMillis(); + + log.info("[{}@{}]: Server started in {}ms\n\n{}\n", + conf.getString("application.env"), + serverName, + end - start, + printer); + + // started services + for (Throwing.Consumer onStarted : this.onStarted) { + onStarted.accept(this); + } + + boolean join = conf.hasPath("server.join") ? conf.getBoolean("server.join") : true; + if (join) { + server.join(); + } + } + + @Override + @SuppressWarnings("unchecked") + public Jooby map(final Mapper mapper) { + requireNonNull(mapper, "Mapper is required."); + if (mappers.add(mapper.name())) { + this.mapper = Optional.ofNullable(this.mapper) + .map(next -> Route.Mapper.chain(mapper, next)) + .orElse((Mapper) mapper); + } + return this; + } + + /** + * Use the injection provider to create the Guice injector + * + * @param injectorFactory the injection provider + * @return this instance. + */ + + public Jooby injector( + final BiFunction injectorFactory) { + this.injectorFactory = injectorFactory; + return this; + } + + /** + * Bind the provided abstract type to the given implementation: + * + *
+   * {
+   *   bind(MyInterface.class, MyImplementation.class);
+   * }
+   * 
+ * + * @param type Service interface. + * @param implementation Service implementation. + * @param Service type. + * @return This instance. + */ + public Jooby bind(final Class type, final Class implementation) { + use((env, conf, binder) -> { + binder.bind(type).to(implementation); + }); + return this; + } + + /** + * Bind the provided abstract type to the given implementation: + * + *
+   * {
+   *   bind(MyInterface.class, MyImplementation::new);
+   * }
+   * 
+ * + * @param type Service interface. + * @param implementation Service implementation. + * @param Service type. + * @return This instance. + */ + public Jooby bind(final Class type, final Supplier implementation) { + use((env, conf, binder) -> { + binder.bind(type).toInstance(implementation.get()); + }); + return this; + } + + /** + * Bind the provided type: + * + *
+   * {
+   *   bind(MyInterface.class);
+   * }
+   * 
+ * + * @param type Service interface. + * @param Service type. + * @return This instance. + */ + public Jooby bind(final Class type) { + use((env, conf, binder) -> { + binder.bind(type); + }); + return this; + } + + /** + * Bind the provided type: + * + *
+   * {
+   *   bind(new MyService());
+   * }
+   * 
+ * + * @param service Service. + * @return This instance. + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + public Jooby bind(final Object service) { + use((env, conf, binder) -> { + Class type = service.getClass(); + binder.bind(type).toInstance(service); + }); + return this; + } + + /** + * Bind the provided type and object that requires some type of configuration: + * + *
{@code
+   * {
+   *   bind(MyService.class, conf -> new MyService(conf.getString("service.url")));
+   * }
+   * }
+ * + * @param type Service type. + * @param provider Service provider. + * @param Service type. + * @return This instance. + */ + public Jooby bind(final Class type, final Function provider) { + use((env, conf, binder) -> { + T service = provider.apply(conf); + binder.bind(type).toInstance(service); + }); + return this; + } + + /** + * Bind the provided type and object that requires some type of configuration: + * + *
{@code
+   * {
+   *   bind(conf -> new MyService(conf.getString("service.url")));
+   * }
+   * }
+ * + * @param provider Service provider. + * @param Service type. + * @return This instance. + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public Jooby bind(final Function provider) { + use((env, conf, binder) -> { + Object service = provider.apply(conf); + Class type = service.getClass(); + binder.bind(type).toInstance(service); + }); + return this; + } + + /** + * Set application date format. + * + * @param dateFormat A date format. + * @return This instance. + */ + public Jooby dateFormat(final String dateFormat) { + this.dateFormat = requireNonNull(dateFormat, "DateFormat required."); + return this; + } + + /** + * Set application number format. + * + * @param numberFormat A number format. + * @return This instance. + */ + public Jooby numberFormat(final String numberFormat) { + this.numberFormat = requireNonNull(numberFormat, "NumberFormat required."); + return this; + } + + /** + * Set application/default charset. + * + * @param charset A charset. + * @return This instance. + */ + public Jooby charset(final Charset charset) { + this.charset = requireNonNull(charset, "Charset required."); + return this; + } + + /** + * Set application locale (first listed are higher priority). + * + * @param languages List of locale using the language tag format. + * @return This instance. + */ + public Jooby lang(final String... languages) { + this.languages = languages; + return this; + } + + /** + * Set application time zone. + * + * @param zoneId ZoneId. + * @return This instance. + */ + public Jooby timezone(final ZoneId zoneId) { + this.zoneId = requireNonNull(zoneId, "ZoneId required."); + return this; + } + + /** + * Set the HTTP port. + * + *

+ * Keep in mind this work as a default port and can be reset via application.port + * property. + *

+ * + * @param port HTTP port. + * @return This instance. + */ + public Jooby port(final int port) { + this.port = port; + return this; + } + + /** + *

+ * Set the HTTPS port to use. + *

+ * + *

+ * Keep in mind this work as a default port and can be reset via application.port + * property. + *

+ * + *

HTTPS

+ *

+ * Jooby comes with a self-signed certificate, useful for development and test. But of course, you + * should NEVER use it in the real world. + *

+ * + *

+ * In order to setup HTTPS with a secure certificate, you need to set these properties: + *

+ * + *
    + *
  • + * ssl.keystore.cert: An X.509 certificate chain file in PEM format. It can be an + * absolute path or a classpath resource. + *
  • + *
  • + * ssl.keystore.key: A PKCS#8 private key file in PEM format. It can be an absolute + * path or a classpath resource. + *
  • + *
+ * + *

+ * Optionally, you can set these too: + *

+ * + *
    + *
  • + * ssl.keystore.password: Password of the keystore.key (if any). Default is: + * null/empty. + *
  • + *
  • + * ssl.trust.cert: Trusted certificates for verifying the remote endpoint’s + * certificate. The file should contain an X.509 certificate chain in PEM format. Default uses the + * system default. + *
  • + *
  • + * ssl.session.cacheSize: Set the size of the cache used for storing SSL session + * objects. 0 to use the default value. + *
  • + *
  • + * ssl.session.timeout: Timeout for the cached SSL session objects, in seconds. 0 to + * use the default value. + *
  • + *
+ * + *

+ * As you can see setup is very simple. All you need is your .crt and + * .key files. + *

+ * + * @param port HTTPS port. + * @return This instance. + */ + public Jooby securePort(final int port) { + this.securePort = port; + return this; + } + + /** + *

+ * Enable HTTP/2 protocol. Some servers require special configuration, others just + * works. It is a good idea to check the server documentation about + * HTTP/2. + *

+ * + *

+ * In order to use HTTP/2 from a browser you must configure HTTPS, see {@link #securePort(int)} + * documentation. + *

+ * + *

+ * If HTTP/2 clear text is supported then you may skip the HTTPS setup, but of course you won't be + * able to use HTTP/2 with browsers. + *

+ * + * @return This instance. + */ + public Jooby http2() { + this.http2 = true; + return this; + } + + /** + * Set the default executor to use from {@link Deferred Deferred API}. + * + * Default executor runs each task in the thread that invokes {@link Executor#execute execute}, + * that's a Jooby worker thread. A worker thread in Jooby can block. + * + * The {@link ExecutorService} will automatically shutdown. + * + * @param executor Executor to use. + * @return This jooby instance. + */ + public Jooby executor(final ExecutorService executor) { + executor((Executor) executor); + onStop(r -> executor.shutdown()); + return this; + } + + /** + * Set the default executor to use from {@link Deferred Deferred API}. + * + * Default executor runs each task in the thread that invokes {@link Executor#execute execute}, + * that's a Jooby worker thread. A worker thread in Jooby can block. + * + * The {@link ExecutorService} will automatically shutdown. + * + * @param executor Executor to use. + * @return This jooby instance. + */ + public Jooby executor(final Executor executor) { + this.defaultExecSet = true; + this.executors.add(binder -> { + binder.bind(Key.get(String.class, Names.named("deferred"))).toInstance("deferred"); + binder.bind(Key.get(Executor.class, Names.named("deferred"))).toInstance(executor); + }); + return this; + } + + /** + * Set a named executor to use from {@link Deferred Deferred API}. Useful for override the + * default/global executor. + * + * Default executor runs each task in the thread that invokes {@link Executor#execute execute}, + * that's a Jooby worker thread. A worker thread in Jooby can block. + * + * The {@link ExecutorService} will automatically shutdown. + * + * @param name Name of the executor. + * @param executor Executor to use. + * @return This jooby instance. + */ + public Jooby executor(final String name, final ExecutorService executor) { + executor(name, (Executor) executor); + onStop(r -> executor.shutdown()); + return this; + } + + /** + * Set a named executor to use from {@link Deferred Deferred API}. Useful for override the + * default/global executor. + * + * Default executor runs each task in the thread that invokes {@link Executor#execute execute}, + * that's a Jooby worker thread. A worker thread in Jooby can block. + * + * The {@link ExecutorService} will automatically shutdown. + * + * @param name Name of the executor. + * @param executor Executor to use. + * @return This jooby instance. + */ + public Jooby executor(final String name, final Executor executor) { + this.executors.add(binder -> { + binder.bind(Key.get(Executor.class, Names.named(name))).toInstance(executor); + }); + return this; + } + + /** + * Set the default executor to use from {@link Deferred Deferred API}. This works as reference to + * an executor, application directly or via module must provide an named executor. + * + * Default executor runs each task in the thread that invokes {@link Executor#execute execute}, + * that's a Jooby worker thread. A worker thread in Jooby can block. + * + * @param name Executor to use. + * @return This jooby instance. + */ + public Jooby executor(final String name) { + defaultExecSet = true; + this.executors.add(binder -> { + binder.bind(Key.get(String.class, Names.named("deferred"))).toInstance(name); + }); + return this; + } + + /** + * Set a named executor to use from {@link Deferred Deferred API}. Useful for override the + * default/global executor. + * + * Default executor runs each task in the thread that invokes {@link Executor#execute execute}, + * that's a Jooby worker thread. A worker thread in Jooby can block. + * + * @param name Name of the executor. + * @param provider Provider for the executor. + * @return This jooby instance. + */ + private Jooby executor(final String name, final Class> provider) { + this.executors.add(binder -> { + binder.bind(Key.get(Executor.class, Names.named(name))).toProvider(provider) + .in(Singleton.class); + }); + return this; + } + + /** + * If the application fails to start all the services are shutdown. Also, the exception is logged + * and usually the application is going to exit. + * + * This options turn off logging and rethrow the exception as {@link Err}. Here is an example: + * + *
+   * public class App extends Jooby {
+   *   {
+   *     throwBootstrapException();
+   *     ...
+   *   }
+   * }
+   *
+   * App app = new App();
+   *
+   * try {
+   *   app.start();
+   * } catch (Err err) {
+   *   Throwable cause = err.getCause();
+   * }
+   * 
+ * + * @return This instance. + */ + public Jooby throwBootstrapException() { + this.throwBootstrapException = true; + return this; + } + + /** + * Configure case for routing algorithm. Default is case sensitive. + * + * @param enabled True for case sensitive, false otherwise. + * @return This instance. + */ + public Jooby caseSensitiveRouting(boolean enabled) { + this.caseSensitiveRouting = enabled; + return this; + } + + private static List normalize(final List services, final Env env, + final RouteMetadata classInfo, final boolean caseSensitiveRouting) { + List result = new ArrayList<>(); + List snapshot = services; + /** modules, routes, parsers, renderers and websockets */ + snapshot.forEach(candidate -> { + if (candidate instanceof Route.Definition) { + result.add(candidate); + } else if (candidate instanceof MvcClass) { + MvcClass mvcRoute = ((MvcClass) candidate); + Class mvcClass = mvcRoute.routeClass; + String path = ((MvcClass) candidate).path; + MvcRoutes.routes(env, classInfo, path, caseSensitiveRouting, mvcClass) + .forEach(route -> result.add(mvcRoute.apply(route))); + } else { + result.add(candidate); + } + }); + return result; + } + + private static List processEnvDep(final Set src, final Env env) { + List result = new ArrayList<>(); + List bag = new ArrayList<>(src); + bag.forEach(it -> { + if (it instanceof EnvDep) { + EnvDep envdep = (EnvDep) it; + if (envdep.predicate.test(env.name())) { + int from = src.size(); + envdep.callback.accept(env.config()); + int to = src.size(); + result.addAll(new ArrayList<>(src).subList(from, to)); + } + } else { + result.add(it); + } + }); + return result; + } + + private Injector bootstrap(final Config args, + final Consumer> rcallback) throws Throwable { + Config initconf = Optional.ofNullable(srcconf) + .orElseGet(() -> ConfigFactory.parseResources("application.conf")); + List modconf = modconf(this.bag); + Config conf = buildConfig(initconf, args, modconf); + + final List locales = LocaleUtils.parse(conf.getString("application.lang")); + + Env env = this.env.build(conf, this, locales.get(0)); + String envname = env.name(); + + final Charset charset = Charset.forName(conf.getString("application.charset")); + + String dateFormat = conf.getString("application.dateFormat"); + ZoneId zoneId = ZoneId.of(conf.getString("application.tz")); + DateTimeFormatter dateTimeFormatter = DateTimeFormatter + .ofPattern(dateFormat, locales.get(0)) + .withZone(zoneId); + DateTimeFormatter zonedDateTimeFormat = DateTimeFormatter + .ofPattern(conf.getString("application.zonedDateTimeFormat")); + + DecimalFormat numberFormat = new DecimalFormat(conf.getString("application.numberFormat")); + + // Guice Stage + Stage stage = "dev".equals(envname) ? Stage.DEVELOPMENT : Stage.PRODUCTION; + + // expand and normalize bag + RouteMetadata rm = new RouteMetadata(env); + List realbag = processEnvDep(this.bag, env); + List realmodconf = modconf(realbag); + List bag = normalize(realbag, env, rm, caseSensitiveRouting); + + // collect routes and fire route callback + if (rcallback != null) { + List routes = bag.stream() + .filter(it -> it instanceof Route.Definition) + .map(it -> (Route.Definition) it) + .collect(Collectors.toList()); + rcallback.accept(routes); + } + + // final config ? if we add a mod that depends on env + Config finalConfig; + Env finalEnv; + if (modconf.size() != realmodconf.size()) { + finalConfig = buildConfig(initconf, args, realmodconf); + finalEnv = this.env.build(finalConfig, this, locales.get(0)); + } else { + finalConfig = conf; + finalEnv = env; + } + + boolean cookieSession = session.store() == null; + if (cookieSession && !finalConfig.hasPath("application.secret")) { + throw new IllegalStateException("Required property 'application.secret' is missing"); + } + + /** executors: */ + if (!defaultExecSet) { + // default executor + executor(MoreExecutors.directExecutor()); + } + executor("direct", MoreExecutors.directExecutor()); + executor("server", ServerExecutorProvider.class); + + /** Some basic xss functions. */ + xss(finalEnv); + + /** dependency injection */ + @SuppressWarnings("unchecked") + com.google.inject.Module joobyModule = binder -> { + + /** type converters */ + new TypeConverters().configure(binder); + + /** bind config */ + bindConfig(binder, finalConfig); + + /** bind env */ + binder.bind(Env.class).toInstance(finalEnv); + + /** bind charset */ + binder.bind(Charset.class).toInstance(charset); + + /** bind locale */ + binder.bind(Locale.class).toInstance(locales.get(0)); + TypeLiteral> localeType = (TypeLiteral>) TypeLiteral + .get(Types.listOf(Locale.class)); + binder.bind(localeType).toInstance(locales); + + /** bind time zone */ + binder.bind(ZoneId.class).toInstance(zoneId); + binder.bind(TimeZone.class).toInstance(TimeZone.getTimeZone(zoneId)); + + /** bind date format */ + binder.bind(DateTimeFormatter.class).toInstance(dateTimeFormatter); + + /** bind number format */ + binder.bind(NumberFormat.class).toInstance(numberFormat); + binder.bind(DecimalFormat.class).toInstance(numberFormat); + + /** bind ssl provider. */ + binder.bind(SSLContext.class).toProvider(SslContextProvider.class); + + /** routes */ + Multibinder definitions = Multibinder + .newSetBinder(binder, Definition.class); + + /** web sockets */ + Multibinder sockets = Multibinder + .newSetBinder(binder, WebSocket.Definition.class); + + /** tmp dir */ + File tmpdir = new File(finalConfig.getString("application.tmpdir")); + tmpdir.mkdirs(); + binder.bind(File.class).annotatedWith(Names.named("application.tmpdir")) + .toInstance(tmpdir); + + binder.bind(ParameterNameProvider.class).toInstance(rm); + + /** err handler */ + Multibinder ehandlers = Multibinder + .newSetBinder(binder, Err.Handler.class); + + /** parsers & renderers */ + Multibinder parsers = Multibinder + .newSetBinder(binder, Parser.class); + + Multibinder renderers = Multibinder + .newSetBinder(binder, Renderer.class); + + /** basic parser */ + parsers.addBinding().toInstance(BuiltinParser.Basic); + parsers.addBinding().toInstance(BuiltinParser.Collection); + parsers.addBinding().toInstance(BuiltinParser.Optional); + parsers.addBinding().toInstance(BuiltinParser.Enum); + parsers.addBinding().toInstance(BuiltinParser.Bytes); + + /** basic render */ + renderers.addBinding().toInstance(BuiltinRenderer.asset); + renderers.addBinding().toInstance(BuiltinRenderer.bytes); + renderers.addBinding().toInstance(BuiltinRenderer.byteBuffer); + renderers.addBinding().toInstance(BuiltinRenderer.file); + renderers.addBinding().toInstance(BuiltinRenderer.charBuffer); + renderers.addBinding().toInstance(BuiltinRenderer.stream); + renderers.addBinding().toInstance(BuiltinRenderer.reader); + renderers.addBinding().toInstance(BuiltinRenderer.fileChannel); + + /** modules, routes, parsers, renderers and websockets */ + Set routeClasses = new HashSet<>(); + for (Object it : bag) { + Try.run(() -> bindService( + logger(this), + this.bag, + finalConfig, + finalEnv, + rm, + binder, + definitions, + sockets, + ehandlers, + parsers, + renderers, + routeClasses, + caseSensitiveRouting) + .accept(it)) + .throwException(); + } + + parsers.addBinding().toInstance(new DateParser(dateFormat)); + parsers.addBinding().toInstance(new LocalDateParser(dateTimeFormatter)); + parsers.addBinding().toInstance(new ZonedDateTimeParser(zonedDateTimeFormat)); + parsers.addBinding().toInstance(new LocaleParser()); + parsers.addBinding().toInstance(new StaticMethodParser("valueOf")); + parsers.addBinding().toInstance(new StaticMethodParser("fromString")); + parsers.addBinding().toInstance(new StaticMethodParser("forName")); + parsers.addBinding().toInstance(new StringConstructorParser()); + parsers.addBinding().toInstance(beanParser.orElseGet(() -> new BeanParser(false))); + + binder.bind(ParserExecutor.class).in(Singleton.class); + + /** override(able) renderer */ + renderers.addBinding().toInstance(new DefaulErrRenderer()); + renderers.addBinding().toInstance(BuiltinRenderer.text); + + binder.bind(HttpHandler.class).to(HttpHandlerImpl.class).in(Singleton.class); + + RequestScope requestScope = new RequestScope(); + binder.bind(RequestScope.class).toInstance(requestScope); + binder.bindScope(RequestScoped.class, requestScope); + + /** session manager */ + binder.bind(Session.Definition.class) + .toProvider(session(finalConfig.getConfig("session"), session)) + .asEagerSingleton(); + Object sstore = session.store(); + if (cookieSession) { + binder.bind(SessionManager.class).to(CookieSessionManager.class) + .asEagerSingleton(); + } else { + binder.bind(SessionManager.class).to(ServerSessionManager.class).asEagerSingleton(); + if (sstore instanceof Class) { + binder.bind(Store.class).to((Class) sstore) + .asEagerSingleton(); + } else { + binder.bind(Store.class).toInstance((Store) sstore); + } + } + + binder.bind(Request.class).toProvider(Providers.outOfScope(Request.class)) + .in(RequestScoped.class); + binder.bind(Route.Chain.class).toProvider(Providers.outOfScope(Route.Chain.class)) + .in(RequestScoped.class); + binder.bind(Response.class).toProvider(Providers.outOfScope(Response.class)) + .in(RequestScoped.class); + /** server sent event */ + binder.bind(Sse.class).toProvider(Providers.outOfScope(Sse.class)) + .in(RequestScoped.class); + + binder.bind(Session.class).toProvider(Providers.outOfScope(Session.class)) + .in(RequestScoped.class); + + /** def err */ + ehandlers.addBinding().toInstance(new Err.DefHandler()); + + /** executors. */ + executors.forEach(it -> it.accept(binder)); + }; + + Injector injector = injectorFactory.apply(stage, joobyModule); + if (apprefs != null) { + apprefs.forEach(app -> app.injector = injector); + apprefs.clear(); + apprefs = null; + } + + onStart.addAll(0, finalEnv.startTasks()); + onStarted.addAll(0, finalEnv.startedTasks()); + onStop.addAll(finalEnv.stopTasks()); + + // clear bag and freeze it + this.bag.clear(); + this.bag = ImmutableSet.of(); + this.executors.clear(); + this.executors = ImmutableList.of(); + + return injector; + } + + private void xss(final Env env) { + Escaper ufe = UrlEscapers.urlFragmentEscaper(); + Escaper fpe = UrlEscapers.urlFormParameterEscaper(); + Escaper pse = UrlEscapers.urlPathSegmentEscaper(); + Escaper html = HtmlEscapers.htmlEscaper(); + + env.xss("urlFragment", ufe::escape) + .xss("formParam", fpe::escape) + .xss("pathSegment", pse::escape) + .xss("html", html::escape); + } + + private static Provider session(final Config $session, + final Session.Definition session) { + return () -> { + // save interval + session.saveInterval(session.saveInterval() + .orElse($session.getDuration("saveInterval", TimeUnit.MILLISECONDS))); + + // build cookie + Cookie.Definition source = session.cookie(); + + source.name(source.name() + .orElse($session.getString("cookie.name"))); + + if (!source.comment().isPresent() && $session.hasPath("cookie.comment")) { + source.comment($session.getString("cookie.comment")); + } + if (!source.domain().isPresent() && $session.hasPath("cookie.domain")) { + source.domain($session.getString("cookie.domain")); + } + source.httpOnly(source.httpOnly() + .orElse($session.getBoolean("cookie.httpOnly"))); + + Object maxAge = $session.getAnyRef("cookie.maxAge"); + if (maxAge instanceof String) { + maxAge = $session.getDuration("cookie.maxAge", TimeUnit.SECONDS); + } + source.maxAge(source.maxAge() + .orElse(((Number) maxAge).intValue())); + + source.path(source.path() + .orElse($session.getString("cookie.path"))); + + source.secure(source.secure() + .orElse($session.getBoolean("cookie.secure"))); + + return session; + }; + } + + private static Throwing.Consumer bindService(Logger log, + final Set src, + final Config conf, + final Env env, + final RouteMetadata rm, + final Binder binder, + final Multibinder definitions, + final Multibinder sockets, + final Multibinder ehandlers, + final Multibinder parsers, + final Multibinder renderers, + final Set routeClasses, + final boolean caseSensitiveRouting) { + return it -> { + if (it instanceof Jooby.Module) { + int from = src.size(); + install(log, (Jooby.Module) it, env, conf, binder); + int to = src.size(); + // collect any route a module might add + if (to > from) { + List elements = normalize(new ArrayList<>(src).subList(from, to), env, rm, + caseSensitiveRouting); + for (Object e : elements) { + bindService(log, src, + conf, + env, + rm, + binder, + definitions, + sockets, + ehandlers, + parsers, + renderers, + routeClasses, caseSensitiveRouting).accept(e); + } + } + } else if (it instanceof Route.Definition) { + Route.Definition rdef = (Definition) it; + Route.Filter h = rdef.filter(); + if (h instanceof Route.MethodHandler) { + Class routeClass = ((Route.MethodHandler) h).implementingClass(); + if (routeClasses.add(routeClass)) { + binder.bind(routeClass); + } + definitions.addBinding().toInstance(rdef); + } else { + definitions.addBinding().toInstance(rdef); + } + } else if (it instanceof WebSocket.Definition) { + sockets.addBinding().toInstance((WebSocket.Definition) it); + } else if (it instanceof Parser) { + parsers.addBinding().toInstance((Parser) it); + } else if (it instanceof Renderer) { + renderers.addBinding().toInstance((Renderer) it); + } else { + ehandlers.addBinding().toInstance((Err.Handler) it); + } + }; + } + + private static List modconf(final Collection bag) { + return bag.stream() + .filter(it -> it instanceof Jooby.Module) + .map(it -> ((Jooby.Module) it).config()) + .filter(c -> !c.isEmpty()) + .collect(Collectors.toList()); + } + + /** + * Test if the application is up and running. + * + * @return True if the application is up and running. + */ + public boolean isStarted() { + return started.get(); + } + + /** + * Stop the application, fire the {@link #onStop(Throwing.Runnable)} event and shutdown the + * web server. + * + * Stop listeners run in the order they were added: + * + *
{@code
+   * {
+   *
+   *   onStop(() -> System.out.println("first"));
+   *
+   *   onStop(() -> System.out.println("second"));
+   *
+   *   ...
+   * }
+   * }
+ * + * + */ + public void stop() { + if (started.compareAndSet(true, false)) { + Logger log = logger(this); + + fireStop(this, log, onStop); + if (injector != null) { + try { + injector.getInstance(Server.class).stop(); + } catch (Throwable ex) { + log.debug("server.stop() resulted in exception", ex); + } + } + injector = null; + + log.info("Stopped"); + } + } + + private static void fireStop(final Jooby app, final Logger log, + final List> onStop) { + // stop services + onStop.forEach(c -> Try.run(() -> c.accept(app)) + .onFailure(x -> log.error("shutdown of {} resulted in error", c, x))); + } + + /** + * Build configuration properties, it configure system, app and modules properties. + * + * @param source Source config to use. + * @param args Args conf. + * @param modules List of modules. + * @return A configuration properties ready to use. + */ + private Config buildConfig(final Config source, final Config args, + final List modules) { + // normalize tmpdir + Config system = ConfigFactory.systemProperties(); + Config tmpdir = source.hasPath("java.io.tmpdir") ? source : system; + + // system properties + system = system + // file encoding got corrupted sometimes, override it. + .withValue("file.encoding", fromAnyRef(System.getProperty("file.encoding"))) + .withValue("java.io.tmpdir", + fromAnyRef(Paths.get(tmpdir.getString("java.io.tmpdir")).normalize().toString())); + + // set module config + Config moduleStack = ConfigFactory.empty(); + for (Config module : ImmutableList.copyOf(modules).reverse()) { + moduleStack = moduleStack.withFallback(module); + } + + String env = Arrays.asList(system, args, source).stream() + .filter(it -> it.hasPath("application.env")) + .findFirst() + .map(c -> c.getString("application.env")) + .orElse("dev"); + + String cpath = Arrays.asList(system, args, source).stream() + .filter(it -> it.hasPath("application.path")) + .findFirst() + .map(c -> c.getString("application.path")) + .orElse("/"); + + Config envconf = envConf(source, env); + + // application.[env].conf -> application.conf + Config conf = envconf.withFallback(source); + + return system + .withFallback(args) + .withFallback(conf) + .withFallback(moduleStack) + .withFallback(MediaType.types) + .withFallback(defaultConfig(conf, Route.normalize(cpath))) + .resolve(); + } + + /** + * Build a conf from arguments. + * + * @param args Application arguments. + * @return A conf. + */ + static Config args(final String[] args) { + if (args == null || args.length == 0) { + return ConfigFactory.empty(); + } + Map conf = new HashMap<>(); + for (String arg : args) { + String[] values = arg.split("="); + String name; + String value; + if (values.length == 2) { + name = values[0]; + value = values[1]; + } else { + name = "application.env"; + value = values[0]; + } + if (name.indexOf(".") == -1) { + conf.put("application." + name, value); + } + conf.put(name, value); + } + return ConfigFactory.parseMap(conf, "args"); + } + + /** + * Build a env config: [application].[env].[conf]. + * Stack looks like + * + *
+   *   (file://[origin].[env].[conf])?
+   *   (cp://[origin].[env].[conf])?
+   *   file://application.[env].[conf]
+   *   /application.[env].[conf]
+   * 
+ * + * @param source App source to use. + * @param env Application env. + * @return A config env. + */ + private Config envConf(final Config source, final String env) { + String name = Optional.ofNullable(this.confname).orElse(source.origin().resource()); + Config result = ConfigFactory.empty(); + if (name != null) { + // load [resource].[env].[ext] + int dot = name.lastIndexOf('.'); + name = name.substring(0, dot); + } else { + name = "application"; + } + String envconfname = name + "." + env + ".conf"; + Config envconf = fileConfig(envconfname); + Config appconf = fileConfig(name + ".conf"); + return result + // file system: + .withFallback(envconf) + .withFallback(appconf) + // classpath: + .withFallback(ConfigFactory.parseResources(envconfname)); + } + + /** + * Config from file system. + * + * @param fname A file name. + * @return A config for the file name. + */ + static Config fileConfig(final String fname) { + // TODO: sanitization of arguments + File dir = new File(System.getProperty("user.dir")); + // TODO: sanitization of arguments + File froot = new File(dir, fname); + if (froot.exists()) { + return ConfigFactory.parseFile(froot); + } else { + // TODO: sanitization of arguments + File fconfig = new File(new File(dir, "conf"), fname); + if (fconfig.exists()) { + return ConfigFactory.parseFile(fconfig); + } + } + return ConfigFactory.empty(); + } + + /** + * Build default application.* properties. + * + * @param conf A source config. + * @param cpath Application path. + * @return default properties. + */ + private Config defaultConfig(final Config conf, final String cpath) { + String ns = Optional.ofNullable(getClass().getPackage()) + .map(Package::getName) + .orElse("default." + getClass().getName()); + String[] parts = ns.split("\\."); + String appname = parts[parts.length - 1]; + + // locale + final List locales; + if (!conf.hasPath("application.lang")) { + locales = Optional.ofNullable(this.languages) + .map(langs -> LocaleUtils.parse(Joiner.on(",").join(langs))) + .orElse(ImmutableList.of(Locale.getDefault())); + } else { + locales = LocaleUtils.parse(conf.getString("application.lang")); + } + Locale locale = locales.iterator().next(); + String lang = locale.toLanguageTag(); + + // time zone + final String tz; + if (!conf.hasPath("application.tz")) { + tz = Optional.ofNullable(zoneId).orElse(ZoneId.systemDefault()).getId(); + } else { + tz = conf.getString("application.tz"); + } + + // number format + final String nf; + if (!conf.hasPath("application.numberFormat")) { + nf = Optional.ofNullable(numberFormat) + .orElseGet(() -> ((DecimalFormat) DecimalFormat.getInstance(locale)).toPattern()); + } else { + nf = conf.getString("application.numberFormat"); + } + + int processors = Runtime.getRuntime().availableProcessors(); + String version = Optional.ofNullable(getClass().getPackage()) + .map(Package::getImplementationVersion) + .filter(Objects::nonNull) + .orElse("0.0.0"); + Config defs = ConfigFactory.parseResources(Jooby.class, "jooby.conf") + .withValue("contextPath", fromAnyRef(cpath.equals("/") ? "" : cpath)) + .withValue("application.name", fromAnyRef(appname)) + .withValue("application.version", fromAnyRef(version)) + .withValue("application.class", fromAnyRef(classname)) + .withValue("application.ns", fromAnyRef(ns)) + .withValue("application.lang", fromAnyRef(lang)) + .withValue("application.tz", fromAnyRef(tz)) + .withValue("application.numberFormat", fromAnyRef(nf)) + .withValue("server.http2.enabled", fromAnyRef(http2)) + .withValue("runtime.processors", fromAnyRef(processors)) + .withValue("runtime.processors-plus1", fromAnyRef(processors + 1)) + .withValue("runtime.processors-plus2", fromAnyRef(processors + 2)) + .withValue("runtime.processors-x2", fromAnyRef(processors * 2)) + .withValue("runtime.processors-x4", fromAnyRef(processors * 4)) + .withValue("runtime.processors-x8", fromAnyRef(processors * 8)) + .withValue("runtime.concurrencyLevel", fromAnyRef(Math.max(4, processors))) + .withValue("server.threads.Min", fromAnyRef(Math.max(4, processors))) + .withValue("server.threads.Max", fromAnyRef(Math.max(32, processors * 8))); + + if (charset != null) { + defs = defs.withValue("application.charset", fromAnyRef(charset.name())); + } + if (port != null) { + defs = defs.withValue("application.port", fromAnyRef(port)); + } + if (securePort != null) { + defs = defs.withValue("application.securePort", fromAnyRef(securePort)); + } + if (dateFormat != null) { + defs = defs.withValue("application.dateFormat", fromAnyRef(dateFormat)); + } + return defs; + } + + /** + * Install a {@link Jooby.Module}. + * + * @param log Logger. + * @param module The module to install. + * @param env Application env. + * @param config The configuration object. + * @param binder A Guice binder. + * @throws Throwable If module bootstrap fails. + */ + private static void install(final Logger log, final Jooby.Module module, final Env env, final Config config, + final Binder binder) throws Throwable { + module.configure(env, config, binder); + try { + binder.install(ProviderMethodsModule.forObject(module)); + } catch (NoClassDefFoundError x) { + // Allow dynamic linking of optional dependencies (required by micrometer module), we ignore + // missing classes here, if there is a missing class Jooby is going to fails early (not here) + log.debug("ignoring class not found from guice provider method", x); + } + } + + /** + * Bind a {@link Config} and make it available for injection. Each property of the config is also + * binded it and ready to be injected with {@link javax.inject.Named}. + * + * @param binder Guice binder. + * @param config App config. + */ + @SuppressWarnings("unchecked") + private void bindConfig(final Binder binder, final Config config) { + // root nodes + traverse(binder, "", config.root()); + + // terminal nodes + for (Entry entry : config.entrySet()) { + String name = entry.getKey(); + Named named = Names.named(name); + Object value = entry.getValue().unwrapped(); + if (value instanceof List) { + List values = (List) value; + Type listType = values.size() == 0 + ? String.class + : Types.listOf(values.iterator().next().getClass()); + Key key = (Key) Key.get(listType, Names.named(name)); + binder.bind(key).toInstance(values); + } else { + binder.bindConstant().annotatedWith(named).to(value.toString()); + } + } + // bind config + binder.bind(Config.class).toInstance(config); + } + + private static void traverse(final Binder binder, final String p, final ConfigObject root) { + root.forEach((n, v) -> { + if (v instanceof ConfigObject) { + ConfigObject child = (ConfigObject) v; + String path = p + n; + Named named = Names.named(path); + binder.bind(Config.class).annotatedWith(named).toInstance(child.toConfig()); + traverse(binder, path + ".", child); + } + }); + } + + private static Predicate envpredicate(final String candidate) { + return env -> env.equalsIgnoreCase(candidate) || candidate.equals("*"); + } + + static String logback(final Config conf) { + // Avoid warning message from logback when multiples files are present + String logback; + if (conf.hasPath("logback.configurationFile")) { + logback = conf.getString("logback.configurationFile"); + } else { + String env = conf.hasPath("application.env") ? conf.getString("application.env") : null; + ImmutableList.Builder files = ImmutableList.builder(); + // TODO: sanitization of arguments + File userdir = new File(System.getProperty("user.dir")); + File confdir = new File(userdir, "conf"); + if (env != null) { + files.add(new File(userdir, "logback." + env + ".xml")); + files.add(new File(confdir, "logback." + env + ".xml")); + } + files.add(new File(userdir, "logback.xml")); + files.add(new File(confdir, "logback.xml")); + logback = files.build() + .stream() + .filter(File::exists) + .map(File::getAbsolutePath) + .findFirst() + .orElseGet(() -> { + return Optional.ofNullable(Jooby.class.getResource("/logback." + env + ".xml")) + .map(Objects::toString) + .orElse("logback.xml"); + }); + } + return logback; + } + + private static Logger logger(final Jooby app) { + return LoggerFactory.getLogger(app.getClass()); + } + + private Route.AssetDefinition configureAssetHandler(final Route.AssetDefinition handler) { + onStart(r -> { + Config conf = r.require(Config.class); + handler + .cdn(conf.getString("assets.cdn")) + .lastModified(conf.getBoolean("assets.lastModified")) + .etag(conf.getBoolean("assets.etag")) + .maxAge(conf.getString("assets.cache.maxAge")); + }); + return handler; + } + + /** + * Class name is this, except for script bootstrap. + * + * @param name Default classname. + * @return Classname. + */ + private String classname(String name) { + if (name.equals(Jooby.class.getName()) || name.equals("org.jooby.Kooby")) { + return SourceProvider.INSTANCE.get() + .map(StackTraceElement::getClassName) + .orElse(name); + } + return name; + } +} diff --git a/jooby/src/main/java/org/jooby/LifeCycle.java b/jooby/src/main/java/org/jooby/LifeCycle.java new file mode 100644 index 00000000..b01fd8b2 --- /dev/null +++ b/jooby/src/main/java/org/jooby/LifeCycle.java @@ -0,0 +1,332 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import com.typesafe.config.Config; +import org.jooby.funzy.Throwing; +import org.jooby.funzy.Try; + +import javax.annotation.Nonnull; +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.lang.annotation.Annotation; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Optional; + +/** + *

life cycle

+ *

+ * Listen for application start and stop events. Useful for starting/stopping services. + *

+ * + *

onStart/onStop events

+ *

+ * Start/stop callbacks are accessible via application: + *

+ *
{@code
+ * {
+ *    onStart(() -> {
+ *      log.info("starting app");
+ *    });
+ *
+ *    onStop(() -> {
+ *      log.info("stopping app");
+ *    });
+ * }
+ * }
+ * + *

+ * Or via module: + *

+ * + *
{@code
+ * public class MyModule implements Jooby.Module {
+ *
+ *   public void configure(Env env, Config conf, Binder binder) {
+ *     env.onStart(() -> {
+ *       log.info("starting module");
+ *     });
+ *
+ *     env.onStop(() -> {
+ *       log.info("stopping module");
+ *     });
+ *   }
+ * }
+ * }
+ * + *

callbacks order

+ *

+ * Callback order is preserved: + *

+ * + *
{@code
+ * {
+ *   onStart(() -> {
+ *     log.info("first");
+ *   });
+ *
+ *   onStart(() -> {
+ *     log.info("second");
+ *   });
+ *
+ *   onStart(() -> {
+ *     log.info("third");
+ *   });
+ * }
+ * }
+ *

+ * Order is useful for service dependencies, like ServiceB should be started after ServiceA. + *

+ * + *

service registry

+ *

+ * You can also request for a service and start or stop it: + *

+ * + *
{@code
+ * {
+ *   onStart(registry -> {
+ *     MyService service = registry.require(MyService.class);
+ *     service.start();
+ *   });
+ *
+ *   onStop(registry -> {
+ *     MyService service = registry.require(MyService.class);
+ *     service.stop();
+ *   });
+ * }
+ * }
+ * + *

PostConstruct/PreDestroy annotations

+ *

+ * If you prefer the annotation way... you can too: + *

+ * + *
{@code
+ *
+ * @Singleton
+ * public class MyService {
+ *
+ *   @PostConstruct
+ *   public void start() {
+ *   }
+ *
+ *   @PreDestroy
+ *   public void stop() {
+ *   }
+ * }
+ *
+ * App.java:
+ *
+ * {
+ *   lifeCycle(MyService.class);
+ * }
+ *
+ * }
+ * + *

+ * It works as expected just make sure MyService is a Singleton + * object. + *

+ * + * @author edgar + * @since 1.0.0.CR3 + */ +public interface LifeCycle { + + /** + * Find a single method annotated with the given annotation in the provided type. + * + * @param rawType The type to look for a method. + * @param annotation Annotation to look for. + * @return A callback to the method. Or empty. + */ + static Optional> lifeCycleAnnotation(final Class rawType, + final Class annotation) { + for (Method method : rawType.getDeclaredMethods()) { + if (method.getAnnotation(annotation) != null) { + int mods = method.getModifiers(); + if (Modifier.isStatic(mods)) { + throw new IllegalArgumentException(annotation.getSimpleName() + + " method should not be static: " + method); + } + if (!Modifier.isPublic(mods)) { + throw new IllegalArgumentException(annotation.getSimpleName() + + " method must be public: " + method); + } + if (method.getParameterCount() > 0) { + throw new IllegalArgumentException(annotation.getSimpleName() + + " method should not accept arguments: " + method); + } + if (method.getReturnType() != void.class) { + throw new IllegalArgumentException(annotation.getSimpleName() + + " method should not return anything: " + method); + } + return Optional.of(owner -> { + Try.run(() -> { + method.setAccessible(true); + method.invoke(owner); + }).unwrap(InvocationTargetException.class) + .throwException(); + }); + } + } + return Optional.empty(); + } + + ; + + /** + * Add to lifecycle the given service. Any method annotated with {@link PostConstruct} or + * {@link PreDestroy} will be executed at application startup or shutdown time. + * + * The service must be a Singleton object. + * + *
{@code
+   *
+   * @Singleton
+   * public class MyService {
+   *
+   *   @PostConstruct
+   *   public void start() {
+   *   }
+   *
+   *   @PreDestroy
+   *   public void stop() {
+   *   }
+   * }
+   *
+   * App.java:
+   *
+   * {
+   *   lifeCycle(MyService.class);
+   * }
+   *
+   * }
+ * + * You should ONLY call this method while the application is been initialized or while + * {@link Jooby.Module#configure(Env, Config, com.google.inject.Binder)} is been executed. + * + * The behavior of this method once application has been initialized is undefined. + * + * @param service Service type. Must be a singleton object. + * @return This instance. + */ + @Nonnull + default LifeCycle lifeCycle(final Class service) { + lifeCycleAnnotation(service, PostConstruct.class) + .ifPresent(it -> onStart(app -> it.accept(app.require(service)))); + + lifeCycleAnnotation(service, PreDestroy.class) + .ifPresent(it -> onStop(app -> it.accept(app.require(service)))); + return this; + } + + /** + * Add a start lifecycle event, useful for initialize and/or start services at startup time. + * + * You should ONLY call this method while the application is been initialized or while + * {@link Jooby.Module#configure(Env, Config, com.google.inject.Binder)}. + * + * The behavior of this method once application has been initialized is undefined. + * + * @param task Task to run. + * @return This env. + */ + @Nonnull + LifeCycle onStart(Throwing.Consumer task); + + /** + * Add a started lifecycle event. Started callbacks are executed when the application is ready: + * modules and servers has been started. + * + * You should ONLY call this method while the application is been initialized or while + * {@link Jooby.Module#configure(Env, Config, com.google.inject.Binder)}. + * + * The behavior of this method once application has been initialized is undefined. + * + * @param task Task to run. + * @return This env. + */ + @Nonnull + LifeCycle onStarted(Throwing.Consumer task); + + /** + * Add a start lifecycle event, useful for initialize and/or start services at startup time. + * + * You should ONLY call this method while the application is been initialized or from + * {@link Jooby.Module#configure(Env, Config, com.google.inject.Binder)}. + * + * The behavior of this method once application has been initialized is undefined. + * + * @param task Task to run. + * @return This env. + */ + @Nonnull + default LifeCycle onStart(final Throwing.Runnable task) { + return onStart(app -> task.run()); + } + + /** + * Add a started lifecycle event. Started callbacks are executed when the application is ready: + * modules and servers has been started. + * + * You should ONLY call this method while the application is been initialized or while + * {@link Jooby.Module#configure(Env, Config, com.google.inject.Binder)}. + * + * The behavior of this method once application has been initialized is undefined. + * + * @param task Task to run. + * @return This env. + */ + @Nonnull + default LifeCycle onStarted(final Throwing.Runnable task) { + return onStarted(app -> task.run()); + } + + /** + * Add a stop lifecycle event, useful for cleanup and/or stop service at stop time. + * + * You should ONLY call this method while the application is been initialized or from + * {@link Jooby.Module#configure(Env, Config, com.google.inject.Binder)}. + * + * The behavior of this method once application has been initialized is undefined. + * + * @param task Task to run. + * @return This env. + */ + @Nonnull + default LifeCycle onStop(final Throwing.Runnable task) { + return onStop(app -> task.run()); + } + + /** + * Add a stop lifecycle event, useful for cleanup and/or stop service at stop time. + * + * You should ONLY call this method while the application is been initialized or from + * {@link Jooby.Module#configure(Env, Config, com.google.inject.Binder)}. + * + * The behaviour of this method once application has been initialized is undefined. + * + * @param task Task to run. + * @return This env. + */ + @Nonnull + LifeCycle onStop(Throwing.Consumer task); + +} diff --git a/jooby/src/main/java/org/jooby/MediaType.java b/jooby/src/main/java/org/jooby/MediaType.java new file mode 100644 index 00000000..c23881b7 --- /dev/null +++ b/jooby/src/main/java/org/jooby/MediaType.java @@ -0,0 +1,646 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Objects.requireNonNull; + +import java.io.File; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; + +/** + * An immutable implementation of HTTP media types (a.k.a mime types). + * + * @author edgar + * @since 0.1.0 + */ +public class MediaType implements Comparable { + + /** + * A media type matcher. + * + * @see MediaType#matcher(org.jooby.MediaType) + * @see MediaType#matcher(java.util.List) + */ + public static class Matcher { + + /** + * The source of media types. + */ + private Iterable acceptable; + + /** + * Creates a new {@link Matcher}. + * + * @param acceptable The source to compare with. + */ + Matcher(final Iterable acceptable) { + this.acceptable = acceptable; + } + + /** + * Given: + * + *
+     *   text/html, application/xhtml; {@literal *}/{@literal *}
+     * 
+ * + *
+     *   matches(text/html)        // true through text/html
+     *   matches(application/json) // true through {@literal *}/{@literal *}
+     * 
+ * + * @param candidate A candidate media type. Required. + * @return True if the matcher matches the given media type. + */ + public boolean matches(final MediaType candidate) { + return doFirst(ImmutableList.of(candidate)).isPresent(); + } + + /** + * Given: + * + *
+     *   text/html, application/xhtml; {@literal *}/{@literal *}
+     * 
+ * + *
+     *   matches(text/html)        // true through text/html
+     *   matches(application/json) // true through {@literal *}/{@literal *}
+     * 
+ * + * @param candidates One ore more candidates media type. Required. + * @return True if the matcher matches the given media type. + */ + public boolean matches(final List candidates) { + return filter(candidates).size() > 0; + } + + /** + * Given: + * + *
+     *   text/html, application/xhtml; {@literal *}/{@literal *}
+     * 
+ * + *
+     *   first(text/html)        // returns text/html
+     *   first(application/json) // returns application/json
+     * 
+ * + * @param candidate A candidate media type. Required. + * @return A first most relevant media type or an empty optional. + */ + public Optional first(final MediaType candidate) { + return first(ImmutableList.of(candidate)); + } + + /** + * Given: + * + *
+     *   text/html, application/xhtml; {@literal *}/{@literal *}
+     * 
+ * + *
+     *   first(text/html)        // returns text/html
+     *   first(application/json) // returns application/json
+     * 
+ * + * @param candidates One ore more candidates media type. Required. + * @return A first most relevant media type or an empty optional. + */ + public Optional first(final List candidates) { + return doFirst(candidates); + } + + /** + * Filter the accepted types and keep the most specifics media types. + * + * Given: + * + *
+     *   text/html, application/xhtml; {@literal *}/{@literal *}
+     * 
+ * + *
+     *   filter(text/html)       // returns text/html
+     *   first(application/json) // returns application/json
+     *   filter(text/html, application/json) // returns text/html and application/json
+     * 
+ * + * @param types A types to filter + * @return Filtered types that matches the given types ordered from more specific to less + * specific. + */ + public List filter(final List types) { + checkArgument(types != null && types.size() > 0, "Media types are required"); + ImmutableList.Builder result = ImmutableList.builder(); + final List sortedTypes; + if (types.size() == 1) { + sortedTypes = ImmutableList.of(types.get(0)); + } else { + sortedTypes = new ArrayList<>(types); + Collections.sort(sortedTypes); + } + for (MediaType accept : acceptable) { + for (MediaType candidate : sortedTypes) { + if (accept.matches(candidate)) { + result.add(candidate); + } + } + } + return result.build(); + } + + /** + * Given: + * + *
+     *   text/html, application/xhtml; {@literal *}/{@literal *}
+     * 
+ * + *
+     *   first(text/html)        -> returns text/html
+     *   first(application/json) -> returns application/json
+     * 
+ * + * @param candidates One ore more candidates media type. Required. + * @return A first most relevant media type or an empty optional. + */ + private Optional doFirst(final List candidates) { + List result = filter(candidates); + return result.size() == 0 ? Optional.empty() : Optional.of(result.get(0)); + } + } + + /** + * Default parameters. + */ + private static final Map DEFAULT_PARAMS = ImmutableMap.of("q", "1"); + + /** + * A JSON media type. + */ + public static final MediaType json = new MediaType("application", "json"); + + private static final MediaType jsonLike = new MediaType("application", "*+json"); + + /** + * Any text media type. + */ + public static final MediaType text = new MediaType("text", "*"); + + /** + * Text plain media type. + */ + public static final MediaType plain = new MediaType("text", "plain"); + + /** + * Stylesheet media type. + */ + public static final MediaType css = new MediaType("text", "css"); + + /** + * Javascript media types. + */ + public static final MediaType js = new MediaType("application", "javascript"); + + /** + * HTML media type. + */ + public static final MediaType html = new MediaType("text", "html"); + + /** + * The default binary media type. + */ + public static final MediaType octetstream = new MediaType("application", "octet-stream"); + + /** + * Any media type. + */ + public static final MediaType all = new MediaType("*", "*"); + + /** Any media type. */ + public static final List ALL = ImmutableList.of(MediaType.all); + + /** Form multipart-data media type. */ + public static final MediaType multipart = new MediaType("multipart", "form-data"); + + /** Form url encoded. */ + public static final MediaType form = new MediaType("application", "x-www-form-urlencoded"); + + /** Xml media type. */ + public static final MediaType xml = new MediaType("application", "xml"); + + /** Server sent event type. */ + public static final MediaType sse = new MediaType("text", "event-stream"); + + /** Xml like media type. */ + private static final MediaType xmlLike = new MediaType("application", "*+xml"); + + /** + * Track the type of this media type. + */ + private final String type; + + /** + * Track the subtype of this media type. + */ + private final String subtype; + + /** + * Track the media type parameters. + */ + private final Map params; + + /** + * True for wild-card types. + */ + private final boolean wildcardType; + + /** + * True for wild-card sub-types. + */ + private final boolean wildcardSubtype; + + /** Name . */ + private String name; + + private int hc; + + /** + * Alias for most used types. + */ + private static final ConcurrentHashMap> cache = new ConcurrentHashMap<>(); + + static { + cache.put("html", ImmutableList.of(html)); + cache.put("json", ImmutableList.of(json)); + cache.put("css", ImmutableList.of(css)); + cache.put("js", ImmutableList.of(js)); + cache.put("octetstream", ImmutableList.of(octetstream)); + cache.put("form", ImmutableList.of(form)); + cache.put("multipart", ImmutableList.of(multipart)); + cache.put("xml", ImmutableList.of(xml)); + cache.put("plain", ImmutableList.of(plain)); + cache.put("*", ALL); + } + + static final Config types = ConfigFactory + .parseResources("mime.properties") + .withFallback(ConfigFactory.parseResources(MediaType.class, "mime.properties")); + + /** + * Creates a new {@link MediaType}. + * + * @param type The primary type. Required. + * @param subtype The secondary type. Required. + * @param parameters The parameters. Required. + */ + private MediaType(final String type, final String subtype, final Map parameters) { + this.type = requireNonNull(type, "A mime type is required."); + this.subtype = requireNonNull(subtype, "A mime subtype is required."); + this.params = ImmutableMap.copyOf(requireNonNull(parameters, "Parameters are required.")); + this.wildcardType = "*".equals(type); + this.wildcardSubtype = "*".equals(subtype); + this.name = type + "/" + subtype; + + hc = 31 + name.hashCode(); + hc = 31 * hc + params.hashCode(); + } + + /** + * Creates a new {@link MediaType}. + * + * @param type The primary type. Required. + * @param subtype The secondary type. Required. + */ + private MediaType(final String type, final String subtype) { + this(type, subtype, DEFAULT_PARAMS); + } + + /** + * @return The quality of this media type. Default is: 1. + */ + public float quality() { + return Float.valueOf(params.get("q")); + } + + /** + * @return The primary media type. + */ + public String type() { + return type; + } + + public Map params() { + return params; + } + + /** + * @return The secondary media type. + */ + public String subtype() { + return subtype; + } + + /** + * @return The qualified type {@link #type()}/{@link #subtype()}. + */ + public String name() { + return name; + } + + /** + * @return True, if this type is a well-known text type. + */ + public boolean isText() { + if (this.wildcardType) { + return false; + } + + if (this == text || text.matches(this)) { + return true; + } + if (this == js || js.matches(this)) { + return true; + } + if (jsonLike.matches(this)) { + return true; + } + if (xmlLike.matches(this)) { + return true; + } + if (this.type.equals("application") && this.subtype.equals("hocon")) { + return true; + } + + return false; + } + + @Override + public int compareTo(final MediaType that) { + requireNonNull(that, "A media type is required."); + if (this == that) { + return 0; + } + if (this.wildcardType && !that.wildcardType) { + return 1; + } + + if (that.wildcardType && !this.wildcardType) { + return -1; + } + + if (this.wildcardSubtype && !that.wildcardSubtype) { + return 1; + } + + if (that.wildcardSubtype && !this.wildcardSubtype) { + return -1; + } + + if (!this.type().equals(that.type())) { + return 0; + } + + int q = Float.compare(that.quality(), this.quality()); + if (q != 0) { + return q; + } + // param size + int paramsSize1 = this.params.size(); + int paramsSize2 = that.params.size(); + return (paramsSize2 < paramsSize1 ? -1 : (paramsSize2 == paramsSize1 ? 0 : 1)); + } + + /** + * @param that A media type to compare to. + * @return True, if the given media type matches the current one. + */ + public boolean matches(final MediaType that) { + requireNonNull(that, "A media type is required."); + if (this == that || this.wildcardType || that.wildcardType) { + // same or */* + return true; + } + if (type.equals(that.type)) { + if (subtype.equals(that.subtype) || this.wildcardSubtype || that.wildcardSubtype) { + return true; + } + if (subtype.startsWith("*+")) { + return that.subtype.endsWith(subtype.substring(2)); + } + if (subtype.startsWith("*")) { + return that.subtype.endsWith(subtype.substring(1)); + } + } + return false; + } + + /** + * @return True for * / *. + */ + public boolean isAny() { + return this.wildcardType && this.wildcardSubtype; + } + + @Override + public boolean equals(final Object obj) { + if (obj == this) { + return true; + } + if (obj instanceof MediaType) { + MediaType that = (MediaType) obj; + return type.equals(that.type) && subtype.equals(that.subtype) && params.equals(that.params); + } + return false; + } + + @Override + public int hashCode() { + return hc; + } + + @Override + public final String toString() { + return name; + } + + /** + * Convert a media type expressed as String into a {@link MediaType}. + * + * @param type A media type to parse. + * @return An immutable {@link MediaType}. + * @throws Err.BadMediaType For bad media types. + */ + public static MediaType valueOf(final String type) throws Err.BadMediaType { + return parse(type).get(0); + } + + private static List parseInternal(final String value) { + String[] types = value.split(","); + @SuppressWarnings("serial") + List result = new ArrayList<>(types.length); + for (String type : types) { + String[] parts = type.trim().split(";"); + if (parts[0].equals("*")) { + // odd and ugly media type + result.add(all); + } else { + String[] typeAndSubtype = parts[0].split("/"); + if (typeAndSubtype.length != 2) { + throw new Err.BadMediaType(value); + } + String stype = typeAndSubtype[0].trim(); + String subtype = typeAndSubtype[1].trim(); + if ("*".equals(stype) && !"*".equals(subtype)) { + throw new Err.BadMediaType(value); + } + Map parameters = DEFAULT_PARAMS; + if (parts.length > 1) { + parameters = new LinkedHashMap<>(DEFAULT_PARAMS); + for (int i = 1; i < parts.length; i++) { + String[] parameter = parts[i].split("="); + if (parameter.length > 1) { + parameters.put(parameter[0].trim(), parameter[1].trim().toLowerCase()); + } + } + } + result.add(new MediaType(stype, subtype, parameters)); + } + } + if (result.size() > 1) { + Collections.sort(result); + } + return result; + } + + /** + * Convert one or more media types expressed as String into a {@link MediaType}. + * + * @param types Media types to parse. + * @return An list of immutable {@link MediaType}. + * @throws Err.BadMediaType For bad media types. + */ + public static List valueOf(final String... types) throws Err.BadMediaType { + requireNonNull(types, "Types are required."); + List result = new ArrayList<>(); + for (String type : types) { + result.add(valueOf(type)); + } + return result; + } + + /** + * Convert a string separated by comma into one or more {@link MediaType}. + * + * @param value The string separated by commas. + * @return One ore more {@link MediaType}. + * @throws Err.BadMediaType For bad media types. + */ + public static List parse(final String value) throws Err.BadMediaType { + return cache.computeIfAbsent(value, MediaType::parseInternal); + } + + /** + * Produces a matcher for the given media type. + * + * @param acceptable The acceptable/target media type. + * @return A media type matcher. + */ + public static Matcher matcher(final MediaType acceptable) { + return matcher(ImmutableList.of(acceptable)); + } + + /** + * Produces a matcher for the given media types. + * + * @param acceptable The acceptable/target media types. + * @return A media type matcher. + */ + public static Matcher matcher(final List acceptable) { + requireNonNull(acceptable, "Acceptables media types are required."); + return new Matcher(acceptable); + } + + /** + * Get a {@link MediaType} for a file. + * + * @param file A candidate file. + * @return A {@link MediaType} or {@link MediaType#octetstream} for unknown file extensions. + */ + public static Optional byFile(final File file) { + requireNonNull(file, "A file is required."); + return byPath(file.getName()); + } + + /** + * Get a {@link MediaType} for a file path. + * + * @param path A candidate file path. + * @return A {@link MediaType} or empty optional for unknown file extensions. + */ + public static Optional byPath(final Path path) { + requireNonNull(path, "A path is required."); + return byPath(path.toString()); + } + + /** + * Get a {@link MediaType} for a file path. + * + * @param path A candidate file path: like myfile.js or /js/myfile.js. + * @return A {@link MediaType} or empty optional for unknown file extensions. + */ + public static Optional byPath(final String path) { + requireNonNull(path, "A path is required."); + int idx = path.lastIndexOf('.'); + if (idx != -1) { + String ext = path.substring(idx + 1); + return byExtension(ext); + } + return Optional.empty(); + } + + /** + * Get a {@link MediaType} for a file extension. + * + * @param ext A file extension, like js or css. + * @return A {@link MediaType} or empty optional for unknown file extensions. + */ + public static Optional byExtension(final String ext) { + requireNonNull(ext, "An ext is required."); + String key = "mime." + ext; + if (types.hasPath(key)) { + return Optional.of(MediaType.valueOf(types.getString("mime." + ext))); + } + return Optional.empty(); + } + +} diff --git a/jooby/src/main/java/org/jooby/Mutant.java b/jooby/src/main/java/org/jooby/Mutant.java new file mode 100644 index 00000000..c51b6d70 --- /dev/null +++ b/jooby/src/main/java/org/jooby/Mutant.java @@ -0,0 +1,396 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.SortedSet; + +import com.google.common.primitives.Primitives; +import com.google.inject.TypeLiteral; +import com.google.inject.util.Types; + +import javax.annotation.Nonnull; + +/** + *

+ * A type safe {@link Mutant} useful for reading parameters/headers/session attributes, etc.. + *

+ * + *
+ *   // str param
+ *   String value = request.param("str").value();
+ *
+ *   // optional str
+ *   String value = request.param("str").value("defs");
+ *
+ *   // int param
+ *   int value = request.param("some").intValue();
+ *
+ *   // optional int param
+ *   Optional{@literal <}Integer{@literal >} value = request.param("some").toOptional(Integer.class);
+
+ *   // list param
+ *   List{@literal <}String{@literal >} values = request.param("some").toList(String.class);
+ *
+ *   // file upload
+ *   Upload upload = request.param("file").to(Upload.class);
+ * 
+ * + * @author edgar + * @since 0.1.0 + * @see Request#param(String) + * @see Request#header(String) + */ +public interface Mutant { + + /** + * @return Get a boolean when possible. + */ + default boolean booleanValue() { + return to(boolean.class); + } + + /** + * @param value Default value to use. + * @return Get a boolean. + */ + default boolean booleanValue(final boolean value) { + return toOptional(Boolean.class).orElse(value); + } + + /** + * @return Get a byte when possible. + */ + default byte byteValue() { + return to(byte.class); + } + + /** + * @param value Default value to use. + * @return Get a byte. + */ + default byte byteValue(final byte value) { + return toOptional(Byte.class).orElse(value); + } + + /** + * @return Get a byte when possible. + */ + default char charValue() { + return to(char.class); + } + + /** + * @param value Default value to use. + * @return Get a char. + */ + default char charValue(final char value) { + return toOptional(Character.class).orElse(value); + } + + /** + * @return Get a short when possible. + */ + default short shortValue() { + return to(short.class); + } + + /** + * @param value Default value to use. + * @return Get a short value. + */ + default short shortValue(final short value) { + return toOptional(Short.class).orElse(value); + } + + /** + * @return Get an integer when possible. + */ + default int intValue() { + return to(int.class); + } + + /** + * @param value Default value to use. + * @return Get an integer. + */ + default int intValue(final int value) { + return toOptional(Integer.class).orElse(value); + } + + /** + * @return Get a long when possible. + */ + default long longValue() { + return to(long.class); + } + + /** + * @param value Default value to use. + * @return Get a long. + */ + default long longValue(final long value) { + return toOptional(Long.class).orElse(value); + } + + /** + * @return Get a string when possible. + */ + @Nonnull + default String value() { + return to(String.class); + } + + /** + * @param value Default value to use. + * @return Get a string. + */ + @Nonnull + default String value(final String value) { + return toOptional().orElse(value); + } + + /** + * @return Get a float when possible. + */ + default float floatValue() { + return to(float.class); + } + + /** + * @param value Default value to use. + * @return Get a float. + */ + default float floatValue(final float value) { + return toOptional(Float.class).orElse(value); + } + + /** + * @return Get a double when possible. + */ + default double doubleValue() { + return to(double.class); + } + + /** + * @param value Default value to use. + * @return Get a double. + */ + default double doubleValue(final double value) { + return toOptional(Double.class).orElse(value); + } + + /** + * @param type The enum type. + * @param Enum type. + * @return Get an enum when possible. + */ + @Nonnull + default > T toEnum(final Class type) { + return to(type); + } + + /** + * @param value Default value to use. + * @param Enum type. + * @return Get an enum. + */ + @SuppressWarnings("unchecked") + @Nonnull + default > T toEnum(final T value) { + Optional optional = (Optional) toOptional(value.getClass()); + return optional.orElse(value); + } + + /** + * @param type The element type. + * @param List type. + * @return Get list of values when possible. + */ + @SuppressWarnings("unchecked") + @Nonnull + default List toList(final Class type) { + return (List) to(TypeLiteral.get(Types.listOf(Primitives.wrap(type)))); + } + + /** + * @return Get list of values when possible. + */ + @Nonnull + default List toList() { + return toList(String.class); + } + + /** + * @return Get set of values when possible. + */ + @Nonnull + default Set toSet() { + return toSet(String.class); + } + + /** + * @param type The element type. + * @param Set type. + * @return Get set of values when possible. + */ + @SuppressWarnings("unchecked") + @Nonnull + default Set toSet(final Class type) { + return (Set) to(TypeLiteral.get(Types.setOf(Primitives.wrap(type)))); + } + + /** + * @return Get sorted set of values when possible. + */ + @Nonnull + default SortedSet toSortedSet() { + return toSortedSet(String.class); + } + + /** + * @param type The element type. + * @param Set type. + * @return Get sorted set of values when possible. + */ + @SuppressWarnings("unchecked") + @Nonnull + default > SortedSet toSortedSet(final Class type) { + return (SortedSet) to(TypeLiteral.get( + Types.newParameterizedType(SortedSet.class, Primitives.wrap(type)))); + } + + /** + * @return An optional string value. + */ + @Nonnull + default Optional toOptional() { + return toOptional(String.class); + } + + /** + * @param type The optional type. + * @param Optional type. + * @return Get an optional value when possible. + */ + @SuppressWarnings("unchecked") + @Nonnull + default Optional toOptional(final Class type) { + return (Optional) to(TypeLiteral.get( + Types.newParameterizedType(Optional.class, Primitives.wrap(type)))); + } + + /** + * Convert a raw value to the given type. + * + * @param type The type to convert to. + * @param Target type. + * @return Get a value when possible. + */ + @Nonnull + default T to(final Class type) { + return to(TypeLiteral.get(type)); + } + + /** + * Convert a raw value to the given type. + * + * @param type The type to convert to. + * @param Target type. + * @return Get a value when possible. + */ + @Nonnull + T to(TypeLiteral type); + + /** + * Convert a raw value to the given type. This method will temporary set {@link MediaType} before + * parsing a value, useful if a form field from a HTTP POST was send as json (or any other data). + * + * @param type The type to convert to. + * @param mtype A media type to hint a parser. + * @param Target type. + * @return Get a value when possible. + */ + @Nonnull + default T to(final Class type, final String mtype) { + return to(type, MediaType.valueOf(mtype)); + } + + /** + * Convert a raw value to the given type. This method will temporary set {@link MediaType} before + * parsing a value, useful if a form field from a HTTP POST was send as json (or any other data). + * + * @param type The type to convert to. + * @param mtype A media type to hint a parser. + * @param Target type. + * @return Get a value when possible. + */ + @Nonnull + default T to(final Class type, final MediaType mtype) { + return to(TypeLiteral.get(type), mtype); + } + + /** + * Convert a raw value to the given type. This method will temporary set {@link MediaType} before + * parsing a value, useful if a form field from a HTTP POST was send as json (or any other data). + * + * @param type The type to convert to. + * @param mtype A media type to hint a parser. + * @param Target type. + * @return Get a value when possible. + */ + @Nonnull + default T to(final TypeLiteral type, final String mtype) { + return to(type, MediaType.valueOf(mtype)); + } + + /** + * Convert a raw value to the given type. This method will temporary set {@link MediaType} before + * parsing a value, useful if a form field from a HTTP POST was send as json (or any other data). + * + * @param type The type to convert to. + * @param mtype A media type to hint a parser. + * @param Target type. + * @return Get a value when possible. + */ + @Nonnull + T to(TypeLiteral type, MediaType mtype); + + /** + * A map view of this mutant. + * + * If this mutant is the result of {@link Request#params()} the resulting map will have all the + * available parameter names. + * + * If the mutant is the result of {@link Request#param(String)} the resulting map will have just + * one entry, with the name as key. + * + * If the mutant is the result of {@link Request#body()} the resulting map will have just + * one entry, with a key of body. + * + * @return A map view of this mutant. + */ + @Nonnull + Map toMap(); + + /** + * @return True if this mutant has a value (param, header, body, etc...). + */ + boolean isSet(); +} diff --git a/jooby/src/main/java/org/jooby/Parser.java b/jooby/src/main/java/org/jooby/Parser.java new file mode 100644 index 00000000..1c246780 --- /dev/null +++ b/jooby/src/main/java/org/jooby/Parser.java @@ -0,0 +1,459 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.Iterator; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.function.Function; + +import org.jooby.internal.parser.BeanParser; + +import com.google.inject.Key; +import com.google.inject.TypeLiteral; + +/** + * Parse a request param (path, query, form) or body to something else. + * + *

Registering a parser

+ *

+ * There are two ways of registering a parser: + *

+ * + *
    + *
  1. Using the {@link Jooby#parser(Parser)} method
  2. + *
  3. From a Guice module: + * + *
    + *   Multibinder<Parser> pcb = Multibinder
    +        .newSetBinder(binder, Parser.class);
    +     pcb.addBinding().to(MyParser.class);
    + * 
    + *
  4. + *
+ * Parsers are executed in the order they were registered. The first converter that resolved the + * type: wins!. + * + *

Built-in parsers

+ *

+ * These are the built-in parsers: + *

+ *
    + *
  1. Primitives and String: convert to int, double, char, string, etc...
  2. + *
  3. Enums (case-sensitive)
  4. + *
  5. {@link java.util.Date}: It parses a date using the application.dateFormat + * property.
  6. + *
  7. {@link java.time.LocalDate}: It parses a date using the application.dateFormat + * property.
  8. + *
  9. {@link java.util.Locale}
  10. + *
  11. Classes with a static method: valueOf
  12. + *
  13. Classes with a static method: fromName
  14. + *
  15. Classes with a static method: fromString
  16. + *
  17. Classes with a public constructor with one String argument
  18. + *
  19. It is an Optional<T>, List<T>, Set<T> or SortedSet<T> where T + * satisfies one of previous rules
  20. + *
+ * + * @author edgar + * @see Jooby#parser(Parser) + * @since 0.6.0 + */ +public interface Parser { + + /** + * A parser callback. + * + * @author edgar + * + * @param Type of data to parse. + * @since 0.6.0 + */ + interface Callback { + + /** + * Parse a raw value to something else. + * + * @param data Data to parse. + * @return A parsed value + * @throws Exception If something goes wrong. + */ + Object invoke(T data) throws Throwable; + + } + + /** + * Expose HTTP params from path, query, form url encoded or multipart request as a raw string. + * + * @author edgar + * @since 0.6.0 + */ + interface ParamReference extends Iterable { + + /** + * @return Descriptive type: parameter, header, cookie, etc... + */ + String type(); + + /** + * @return Parameter name. + */ + String name(); + + /** + * @return Return the first param or throw {@link Err} with a bad request code when missing. + */ + T first(); + + /** + * @return Return the last param or throw {@link Err} with a bad request code when missing. + */ + T last(); + + /** + * Get the param at the given index or throw {@link Err} with a bad request code when missing. + * + * @param index Param index. + * @return Param at the given index or throw {@link Err} with a bad request code when missing. + */ + T get(int index); + + @Override + Iterator iterator(); + + /** + * @return Number of values for this parameter. + */ + int size(); + + } + + /** + * Expose the HTTP body as a series of bytes or text. + * + * @author edgar + * @since 0.6.0 + */ + interface BodyReference { + /** + * Returns the HTTP body as a byte array. + * + * @return HTTP body as byte array. + * @throws IOException If reading fails. + */ + byte[] bytes() throws IOException; + + /** + * Returns the HTTP body as text. + * + * @return HTTP body as text. + * @throws IOException If reading fails. + */ + String text() throws IOException; + + /** + * @return Body length. + */ + long length(); + + /** + * Write the content to the given output stream. This method won't close the + * {@link OutputStream}. + * + * @param output An output stream. + * @throws Exception If write fails. + */ + void writeTo(final OutputStream output) throws Exception; + } + + /** + * A parser can be executed against a simply HTTP param, a set of HTTP params, an file + * {@link Upload} or HTTP {@link BodyReference}. + * + * This class provides utility methods for selecting one of the previous source. It is possible to + * write a parser and apply it against multiple sources, like HTTP param and HTTP body. + * + * Here is an example that will parse text to an int, provided as a HTTP param or body: + * + *
+   * {
+   *   parser((type, ctx) {@literal ->} {
+   *     if (type.getRawType() == int.class) {
+   *       return ctx
+   *           .param(values {@literal ->} Integer.parseInt(values.get(0))
+   *           .body(body {@literal ->} Integer.parseInt(body.text()));
+   *     }
+   *     return ctx.next();
+   *   });
+   *
+   *   get("/", req {@literal ->} {
+   *     // use the param strategy
+   *     return req.param("p").intValue();
+   *   });
+   *
+   *   post("/", req {@literal ->} {
+   *     // use the body strategy
+   *     return req.body().intValue();
+   *   });
+   * }
+   * 
+ * + * @author edgar + * @since 0.6.0 + */ + interface Builder { + + /** + * Add a HTTP body callback. The Callback will be executed when current context is bound to the + * HTTP body via {@link Request#body()}. + * + * If current {@link Context} isn't a HTTP body a call to {@link Context#next()} is made. + * + * @param callback A body parser callback. + * @return This builder. + */ + Builder body(Callback callback); + + /** + * Like {@link #body(Callback)} but it skip the callback if the requested type is an + * {@link Optional}. + * + * @param callback A body parser callback. + * @return This builder. + */ + Builder ifbody(Callback callback); + + /** + * Add a HTTP param callback. The Callback will be executed when current context is bound to a + * HTTP param via {@link Request#param(String)}. + * + * If current {@link Context} isn't a HTTP param a call to {@link Context#next()} is made. + * + * @param callback A param parser callback. + * @return This builder. + */ + Builder param(Callback> callback); + + /** + * Like {@link #param(Callback)} but it skip the callback if the requested type is an + * {@link Optional}. + * + * @param callback A param parser callback. + * @return This builder. + */ + Builder ifparam(Callback> callback); + + /** + * Add a HTTP params callback. The Callback will be executed when current context is bound to a + * HTTP params via {@link Request#params()}. + * + * If current {@link Context} isn't a HTTP params a call to {@link Context#next()} is made. + * + * @param callback A params parser callback. + * @return This builder. + */ + Builder params(Callback> callback); + + /** + * Like {@link #params(Callback)} but it skip the callback if the requested type is an + * {@link Optional}. + * + * @param callback A params parser callback. + * @return This builder. + */ + Builder ifparams(Callback> callback); + + } + + /** + * Allows you to access to parsing strategies, content type view {@link #type()} and invoke next + * parser in the chain via {@link #next()} methods. + * + * @author edgar + * @since 0.6.0 + */ + interface Context extends Builder { + + /** + * Requires a service with the given type. + * + * @param type Service type. + * @param Service type. + * @return A service. + */ + T require(final Class type); + + /** + * Requires a service with the given type. + * + * @param type Service type. + * @param Service type. + * @return A service. + */ + T require(final TypeLiteral type); + + /** + * Requires a service with the given type. + * + * @param key Service key. + * @param Service type. + * @return A service. + */ + T require(final Key key); + + /** + * Content Type header, if current context was bind to a HTTP body via {@link Request#body()}. + * If current context was bind to a HTTP param, media type is set to text/plain. + * + * @return Current type. + */ + MediaType type(); + + /** + * Invoke next parser in the chain. + * + * @return A parsed value. + * @throws Exception An err with a 400 status. + */ + Object next() throws Throwable; + + /** + * Invoke next parser in the chain and switch/change the target type we are looking for. Useful + * for generic containers classes, like collections or optional values. + * + * @param type A new type to use. + * @return A parsed value. + * @throws Exception An err with a 400 status. + */ + Object next(TypeLiteral type) throws Throwable; + + /** + * Invoke next parser in the chain and switch/change the target type we are looking for but also + * the current value. Useful for generic containers classes, like collections or optional + * values. + * + * @param type A new type to use. + * @param data Data to be parsed. + * @return A parsed value. + * @throws Exception An err with a 400 status. + */ + Object next(TypeLiteral type, Object data) throws Throwable; + + } + + /** Utility function to handle empty values as {@link NoSuchElementException}. */ + static Function NOT_EMPTY = v -> { + if (v.length() == 0) { + throw new NoSuchElementException(); + } + return v; + }; + + /** + *

+ * Parse one or more values to the required type. If the parser doesn't support the required type + * a call to {@link Context#next(TypeLiteral, Object)} must be done. + *

+ * + * Example: + * + *
+   *  Parser converter = (type, ctx) {@literal ->} {
+   *    if (type.getRawType() == MyType.class) {
+   *      // convert to MyType
+   *      return ctx.param(values {@literal ->} new MyType(values.get(0)));
+   *    }
+   *    // no luck! move next
+   *    return ctx.next();
+   *  }
+   * 
+ * + * It's also possible to create generic/parameterized types too: + * + *
+   *  public class MyContainerType<T> {}
+   *
+   *  ParamConverter converter = (type, ctx) {@literal ->} {
+   *    if (type.getRawType() == MyContainerType.class) {
+   *      // Creates a new type from current generic type
+   *      TypeLiterale<?> paramType = TypeLiteral
+   *        .get(((ParameterizedType) toType.getType()).getActualTypeArguments()[0]);
+   *
+   *      // Ask param converter to resolve the new/next type.
+   *      Object result = ctx.next(paramType);
+   *      return new MyType(result);
+   *    }
+   *    // no luck! move next
+   *    return ctx.next();
+   *  }
+   * 
+ * + * @param type Requested type. + * @param ctx Execution context. + * @return A parsed value. + * @throws Throwable If conversion fails. + */ + Object parse(TypeLiteral type, Context ctx) throws Throwable; + + /** + * Overwrite the default bean parser with empty/null supports. The default bean + * parser doesn't allow null, so if a parameter is optional you must declare it as + * {@link Optional} otherwise parsing fails with a 404/500 status code. + * + * For example: + *
{@code
+   *
+   * public class Book {
+   *
+   *   public String title;
+   *
+   *   public Date releaseDate;
+   *
+   *   public String toString() {
+   *     return title + ":" + releaseDate;
+   *   }
+   * }
+   *
+   * {
+   *   parser(Parser.bean(true));
+   *
+   *   post("/", req -> {
+   *     return req.params(Book.class).toString();
+   *   });
+   * }
+   * }
+ * + *

+ * With /?title=Title&releaseDate= prints Title:null. + *

+ *

+ * Now, same call with lenient=false results in Bad Request: 400 + * because releaseDate if required and isn't present in the HTTP request. + *

+ * + *

+ * This feature is useful while submitting forms. + *

+ * + * @param lenient Enabled null/empty supports while parsing HTTP params as Java Beans. + * @return A new bean parser. + */ + static Parser bean(final boolean lenient) { + return new BeanParser(lenient); + } +} diff --git a/jooby/src/main/java/org/jooby/Registry.java b/jooby/src/main/java/org/jooby/Registry.java new file mode 100644 index 00000000..add1982e --- /dev/null +++ b/jooby/src/main/java/org/jooby/Registry.java @@ -0,0 +1,96 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import com.google.inject.Key; +import com.google.inject.TypeLiteral; +import com.google.inject.name.Names; + +import javax.annotation.Nonnull; + +/** + *

service registry

+ *

+ * Provides access to services registered by modules or application. The registry is powered by + * Guice. + *

+ * + * @author edgar + * @since 1.0.0.CR3 + */ +public interface Registry { + + /** + * Request a service of the given type. + * + * @param type A service type. + * @param Service type. + * @return A ready to use object. + */ + @Nonnull + default T require(final Class type) { + return require(Key.get(type)); + } + + /** + * Request a service of the given type and name. + * + * @param name A service name. + * @param type A service type. + * @param Service type. + * @return A ready to use object. + */ + @Nonnull + default T require(final String name, final Class type) { + return require(Key.get(type, Names.named(name))); + } + + /** + * Request a service of the given type. + * + * @param type A service type. + * @param Service type. + * @return A ready to use object. + */ + @Nonnull + default T require(final TypeLiteral type) { + return require(Key.get(type)); + } + + /** + * Request a service of the given key. + * + * @param key A service key. + * @param Service type. + * @return A ready to use object. + */ + @Nonnull + T require(Key key); + + /** + * Request a service of a given type by a given name. + * + * @param name A service name + * @param type A service type. + * @param Service type. + * @return A ready to use object + */ + @Nonnull + default T require(final String name, final TypeLiteral type) { + return require(Key.get(type, Names.named(name))); + } + +} diff --git a/jooby/src/main/java/org/jooby/Renderer.java b/jooby/src/main/java/org/jooby/Renderer.java new file mode 100644 index 00000000..bd03c534 --- /dev/null +++ b/jooby/src/main/java/org/jooby/Renderer.java @@ -0,0 +1,294 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import java.io.InputStream; +import java.io.Reader; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.Charset; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import com.google.common.base.CaseFormat; +import com.google.inject.Key; +import com.google.inject.TypeLiteral; + +/** + * Write a value into the HTTP response and apply a format, if need it. + * + * Renderers are executed in the order they were registered. The first renderer that write a + * response wins! + * + * There are two ways of registering a rendering: + * + *
+ * {
+ *   renderer((value, ctx) {@literal ->} {
+ *     ...
+ *   });
+ * }
+ * 
+ * + * Or from inside a module: + * + *
+ * {
+ *   use((env, conf, binder) {@literal ->} {
+ *     Multibinder.newSetBinder(binder, Renderer.class)
+ *        .addBinding()
+ *        .toInstance((value, ctx) {@literal ->} {
+ *          ...
+ *        }));
+ *   });
+ * }
+ * 
+ * + * Inside a {@link Renderer} you can do whatever you want. For example you can check for a specific + * type: + * + *
+ *   renderer((value, ctx) {@literal ->} {
+ *     if (value instanceof MyObject) {
+ *       ctx.send(value.toString());
+ *     }
+ *   });
+ * 
+ * + * Or check for the Accept header: + * + *
+ *   renderer((value, ctx) {@literal ->} {
+ *     if (ctx.accepts("json")) {
+ *       ctx.send(toJson(value));
+ *     }
+ *   });
+ * 
+ * + * API is simple and powerful! + * + * @author edgar + * @since 0.6.0 + */ +public interface Renderer { + + /** + * Contains a few utility methods for doing the actual rendering and writing. + * + * @author edgar + * @since 0.6.0 + */ + interface Context { + + /** + * Request locale or default locale. + * + * @return Request locale or default locale. + */ + Locale locale(); + + /** + * @return Request local attributes. + */ + Map locals(); + + /** + * True if the given type matches the Accept header. + * + * @param type The type to check for. + * @return True if the given type matches the Accept header. + */ + default boolean accepts(final String type) { + return accepts(MediaType.valueOf(type)); + } + + /** + * True if the given type matches the Accept header. + * + * @param type The type to check for. + * @return True if the given type matches the Accept header. + */ + boolean accepts(final MediaType type); + + /** + * Set the Content-Type header IF and ONLY IF, no Content-Type was set + * yet. + * + * @param type A suggested type to use if one is missing. + * @return This context. + */ + default Context type(final String type) { + return type(MediaType.valueOf(type)); + } + + /** + * Set the Content-Type header IF and ONLY IF, no Content-Type was set + * yet. + * + * @param type A suggested type to use if one is missing. + * @return This context. + */ + Context type(MediaType type); + + /** + * Set the Content-Length header IF and ONLY IF, no Content-Length was + * set yet. + * + * @param length A suggested length to use if one is missing. + * @return This context. + */ + Context length(long length); + + /** + * @return Charset to use while writing text responses. + */ + Charset charset(); + + /** + * Write bytes into the HTTP response body. + * + * It will set a Content-Length if none was set + * It will set a Content-Type to {@link MediaType#octetstream} if none was set. + * + * @param bytes A bytes to write. + * @throws Exception When the operation fails. + */ + void send(byte[] bytes) throws Exception; + + /** + * Write byte buffer into the HTTP response body. + * + * It will set a Content-Length if none was set. + * It will set a Content-Type to {@link MediaType#octetstream} if none was set. + * + * @param buffer A buffer to write. + * @throws Exception When the operation fails. + */ + void send(ByteBuffer buffer) throws Exception; + + /** + * Write text into the HTTP response body. + * + * It will set a Content-Length if none was set. + * It will set a Content-Type to {@link MediaType#html} if none was set. + * + * @param text A text to write. + * @throws Exception When the operation fails. + */ + void send(String text) throws Exception; + + /** + * Write bytes into the HTTP response body. + * + * It will set a Content-Length if the response size is less than the + * server.ResponseBufferSize (default is: 16k). If the response is larger than the + * buffer size, it will set a Transfer-Encoding: chunked header. + * + * It will set a Content-Type to {@link MediaType#octetstream} if none was set. + * + * This method will check if the given input stream has a {@link FileChannel} and redirect to + * file + * + * @param stream Bytes to write. + * @throws Exception When the operation fails. + */ + void send(InputStream stream) throws Exception; + + /** + * Write text into the HTTP response body. + * + * It will set a Content-Length if none was set. + * It will set a Content-Type to {@link MediaType#html} if none was set. + * + * @param buffer A text to write. + * @throws Exception When the operation fails. + */ + void send(CharBuffer buffer) throws Exception; + + /** + * Write text into the HTTP response body. + * + * It will set a Content-Length if the response size is less than the + * server.ResponseBufferSize (default is: 16k). If the response is larger than the + * buffer size, it will set a Transfer-Encoding: chunked header. + * + * It will set a Content-Type to {@link MediaType#html} if none was set. + * + * @param reader Text to write. + * @throws Exception When the operation fails. + */ + void send(Reader reader) throws Exception; + + /** + * Write file into the HTTP response body, using OS zero-copy transfer (if possible). + * + * It will set a Content-Length if none was set. + * It will set a Content-Type to {@link MediaType#html} if none was set. + * + * @param file A text to write. + * @throws Exception When the operation fails. + */ + void send(FileChannel file) throws Exception; + + } + + /** Renderer key. */ + Key> KEY = Key.get(new TypeLiteral>() { + }); + + /** + * @return Renderer's name. + */ + default String name() { + String name = getClass().getSimpleName() + .replace("renderer", "") + .replace("render", ""); + return CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_HYPHEN, name); + } + + /** + * Render the given value and write the response (if possible). If no response is written, the + * next renderer in the chain will be invoked. + * + * @param value Object to render. + * @param ctx Rendering context. + * @throws Exception If rendering fails. + */ + void render(Object value, Context ctx) throws Exception; + + /** + * Renderer factory method. + * + * @param name Renderer's name. + * @param renderer Renderer's function. + * @return A new renderer. + */ + static Renderer of(final String name, final Renderer renderer) { + return new Renderer() { + @Override + public void render(final Object value, final Context ctx) throws Exception { + renderer.render(value, ctx); + } + + @Override + public String name() { + return name; + } + }; + } +} diff --git a/jooby/src/main/java/org/jooby/Request.java b/jooby/src/main/java/org/jooby/Request.java new file mode 100644 index 00000000..e1d2e94a --- /dev/null +++ b/jooby/src/main/java/org/jooby/Request.java @@ -0,0 +1,1333 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.net.UrlEscapers; +import com.google.inject.Key; +import com.google.inject.TypeLiteral; +import static java.util.Objects.requireNonNull; +import org.jooby.scope.RequestScoped; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.List; +import java.util.Locale; +import java.util.Locale.LanguageRange; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.function.BiFunction; + +/** + * Give you access at the current HTTP request in order to read parameters, headers and body. + * + *

HTTP parameter and headers

+ *

+ * Access to HTTP parameter/header is available via {@link #param(String)} and + * {@link #header(String)} methods. See some examples: + *

+ * + *
+ *   // str param
+ *   String value = request.param("str").value();
+ *
+ *   // optional str
+ *   String value = request.param("str").value("defs");
+ *
+ *   // int param
+ *   int value = request.param("some").intValue();
+ *
+ *   // optional int param
+ *   Optional{@literal <}Integer{@literal >} value = request.param("some").toOptional(Integer.class);
+
+ *   // list param
+ *   List{@literal <}String{@literal >} values = request.param("some").toList(String.class);
+ * 
+ * + *

form post/multi-param request

+ *

+ * Due that form post are treated as HTTP params you can collect all them into a Java Object via + * {@link #params(Class)} or {@link #form(Class)} methods: + *

+ * + *
{@code
+ * {
+ *   get("/search", req -> {
+ *     Query q = req.params(Query.class);
+ *   });
+ *
+ *   post("/person", req -> {
+ *     Person person = req.form(Person.class);
+ *   });
+ * }
+ * }
+ * + *

form file upload

+ *

+ * Form post file upload are available via {@link #files(String)} or {@link #file(String)} methods: + *

+ *
{@code
+ * {
+ *   post("/upload", req  -> {
+ *     try(Upload upload = req.file("myfile")) {
+ *       File file = upload.file();
+ *       // work with file.
+ *     }
+ *   });
+ * }
+ * }
+ * + * @author edgar + * @since 0.1.0 + */ +public interface Request extends Registry { + + /** + * Flash scope. + * + * @author edgar + * @since 1.2.0 + */ + interface Flash extends Map { + /** + * Keep flash cookie for next request. + */ + void keep(); + } + + /** + * Forwarding request. + * + * @author edgar + * @since 0.1.0 + */ + class Forwarding implements Request { + + /** Target request. */ + private Request req; + + /** + * Creates a new {@link Forwarding} request. + * + * @param request A target request. + */ + public Forwarding(final Request request) { + this.req = requireNonNull(request, "A HTTP request is required."); + } + + @Override + public String path() { + return req.path(); + } + + @Override + public String rawPath() { + return req.rawPath(); + } + + @Override + public Optional queryString() { + return req.queryString(); + } + + @Override + public String path(final boolean escape) { + return req.path(escape); + } + + @Override + public boolean matches(final String pattern) { + return req.matches(pattern); + } + + @Override + public String contextPath() { + return req.contextPath(); + } + + @Override + public String method() { + return req.method(); + } + + @Override + public MediaType type() { + return req.type(); + } + + @Override + public List accept() { + return req.accept(); + } + + @Override + public Optional accepts(final List types) { + return req.accepts(types); + } + + @Override + public Optional accepts(final MediaType... types) { + return req.accepts(types); + } + + @Override + public Optional accepts(final String... types) { + return req.accepts(types); + } + + @Override + public boolean is(final List types) { + return req.is(types); + } + + @Override + public boolean is(final MediaType... types) { + return req.is(types); + } + + @Override + public boolean is(final String... types) { + return req.is(types); + } + + @Override + public boolean isSet(final String name) { + return req.isSet(name); + } + + @Override + public Mutant params() { + return req.params(); + } + + @Override + public Mutant params(final String... xss) { + return req.params(xss); + } + + @Override + public T params(final Class type) { + return req.params(type); + } + + @Override + public T params(final Class type, final String... xss) { + return req.params(type, xss); + } + + @Override + public Mutant param(final String name) { + return req.param(name); + } + + @Override + public Mutant param(final String name, final String... xss) { + return req.param(name, xss); + } + + @Override + public Upload file(final String name) throws IOException { + return req.file(name); + } + + @Override + public List files(final String name) throws IOException { + return req.files(name); + } + + @Nonnull + @Override + public List files() throws IOException { + return req.files(); + } + + @Override + public Mutant header(final String name) { + return req.header(name); + } + + @Override + public Mutant header(final String name, final String... xss) { + return req.header(name, xss); + } + + @Override + public Map headers() { + return req.headers(); + } + + @Override + public Mutant cookie(final String name) { + return req.cookie(name); + } + + @Override + public List cookies() { + return req.cookies(); + } + + @Override + public Mutant body() throws Exception { + return req.body(); + } + + @Override + public T body(final Class type) throws Exception { + return req.body(type); + } + + @Override + public T require(final Class type) { + return req.require(type); + } + + @Override + public T require(final TypeLiteral type) { + return req.require(type); + } + + @Override + public T require(final Key key) { + return req.require(key); + } + + @Override + public Charset charset() { + return req.charset(); + } + + @Override + public long length() { + return req.length(); + } + + @Override + public Locale locale() { + return req.locale(); + } + + @Override + public Locale locale(final BiFunction, List, Locale> filter) { + return req.locale(filter); + } + + @Override + public List locales( + final BiFunction, List, List> filter) { + return req.locales(filter); + } + + @Override + public List locales() { + return req.locales(); + } + + @Override + public String ip() { + return req.ip(); + } + + @Override + public int port() { + return req.port(); + } + + @Override + public Route route() { + return req.route(); + } + + @Override + public Session session() { + return req.session(); + } + + @Override + public Optional ifSession() { + return req.ifSession(); + } + + @Override + public String hostname() { + return req.hostname(); + } + + @Override + public String protocol() { + return req.protocol(); + } + + @Override + public boolean secure() { + return req.secure(); + } + + @Override + public boolean xhr() { + return req.xhr(); + } + + @Override + public Map attributes() { + return req.attributes(); + } + + @Override + public Optional ifGet(final String name) { + return req.ifGet(name); + } + + @Override + public T get(final String name) { + return req.get(name); + } + + @Override + public T get(final String name, final T def) { + return req.get(name, def); + } + + @Override + public Request set(final String name, final Object value) { + req.set(name, value); + return this; + } + + @Override + public Request set(final Key key, final Object value) { + req.set(key, value); + return this; + } + + @Override + public Request set(final Class type, final Object value) { + req.set(type, value); + return this; + } + + @Override + public Request set(final TypeLiteral type, final Object value) { + req.set(type, value); + return this; + } + + @Override + public Optional unset(final String name) { + return req.unset(name); + } + + @Override + public Flash flash() throws NoSuchElementException { + return req.flash(); + } + + @Override + public String flash(final String name) throws NoSuchElementException { + return req.flash(name); + } + + @Override + public Request flash(final String name, final Object value) { + req.flash(name, value); + return this; + } + + @Override + public Optional ifFlash(final String name) { + return req.ifFlash(name); + } + + @Override + public Request push(final String path) { + req.push(path); + return this; + } + + @Override + public Request push(final String path, final Map headers) { + req.push(path, headers); + return this; + } + + @Override + public long timestamp() { + return req.timestamp(); + } + + @Override + public String toString() { + return req.toString(); + } + + /** + * Unwrap a request in order to find out the target instance. + * + * @param req A request. + * @return A target instance (not a {@link Forwarding}). + */ + public static Request unwrap(final Request req) { + requireNonNull(req, "A request is required."); + Request root = req; + while (root instanceof Forwarding) { + root = ((Forwarding) root).req; + } + return root; + } + } + + /** + * Given: + * + *
+   *  http://domain.com/some/path.html {@literal ->} /some/path.html
+   *  http://domain.com/a.html         {@literal ->} /a.html
+   * 
+ * + * @return The request URL pathname. + */ + @Nonnull + default String path() { + return path(false); + } + + /** + * Raw path, like {@link #path()} but without decoding. + * + * @return Raw path, like {@link #path()} but without decoding. + */ + @Nonnull + String rawPath(); + + /** + * The query string, without the leading ?. + * + * @return The query string, without the leading ?. + */ + @Nonnull + Optional queryString(); + + /** + * Escape the path using {@link UrlEscapers#urlFragmentEscaper()}. + * + * Given: + * + *
{@code
+   *  http://domain.com/404

X

{@literal ->} /404%3Ch1%3EX%3C/h1%3E + * }
+ * + * @param escape True if we want to escape this path. + * @return The request URL pathname. + */ + @Nonnull + default String path(final boolean escape) { + String path = route().path(); + return escape ? UrlEscapers.urlFragmentEscaper().escape(path) : path; + } + + /** + * Application path (a.k.a context path). It is the value defined by: + * application.path. Default is: / + * + * This method returns empty string for /. Otherwise, it is identical the value of + * application.path. + * + * @return Application context path.. + */ + @Nonnull + String contextPath(); + + /** + * @return HTTP method. + */ + @Nonnull + default String method() { + return route().method(); + } + + /** + * @return The Content-Type header. Default is: {@literal*}/{@literal*}. + */ + @Nonnull + MediaType type(); + + /** + * @return The value of the Accept header. Default is: {@literal*}/{@literal*}. + */ + @Nonnull + List accept(); + + /** + * Check if the given types are acceptable, returning the best match when true, or else + * Optional.empty. + * + *
+   * // Accept: text/html
+   * req.accepts("text/html");
+   * // {@literal =>} "text/html"
+   *
+   * // Accept: text/*, application/json
+   * req.accepts("text/html");
+   * // {@literal =>} "text/html"
+   * req.accepts("text/html");
+   * // {@literal =>} "text/html"
+   * req.accepts("application/json" "text/plain");
+   * // {@literal =>} "application/json"
+   * req.accepts("application/json");
+   * // {@literal =>} "application/json"
+   *
+   * // Accept: text/*, application/json
+   * req.accepts("image/png");
+   * // {@literal =>} Optional.empty
+   *
+   * // Accept: text/*;q=.5, application/json
+   * req.accepts("text/html", "application/json");
+   * // {@literal =>} "application/json"
+   * 
+ * + * @param types Types to test. + * @return The best acceptable type. + */ + @Nonnull + default Optional accepts(final String... types) { + return accepts(MediaType.valueOf(types)); + } + + /** + * Test if the given request path matches the pattern. + * + * @param pattern A pattern to test for. + * @return True, if the request path matches the pattern. + */ + boolean matches(String pattern); + + /** + * True, if request accept any of the given types. + * + * @param types Types to test + * @return True if any of the given type is accepted. + */ + default boolean is(final String... types) { + return accepts(types).isPresent(); + } + + /** + * True, if request accept any of the given types. + * + * @param types Types to test + * @return True if any of the given type is accepted. + */ + default boolean is(final MediaType... types) { + return accepts(types).isPresent(); + } + + /** + * True, if request accept any of the given types. + * + * @param types Types to test + * @return True if any of the given type is accepted. + */ + default boolean is(final List types) { + return accepts(types).isPresent(); + } + + /** + * Check if the given types are acceptable, returning the best match when true, or else + * Optional.empty. + * + *
+   * // Accept: text/html
+   * req.accepts("text/html");
+   * // {@literal =>} "text/html"
+   *
+   * // Accept: text/*, application/json
+   * req.accepts("text/html");
+   * // {@literal =>} "text/html"
+   * req.accepts("text/html");
+   * // {@literal =>} "text/html"
+   * req.accepts("application/json" "text/plain");
+   * // {@literal =>} "application/json"
+   * req.accepts("application/json");
+   * // {@literal =>} "application/json"
+   *
+   * // Accept: text/*, application/json
+   * req.accepts("image/png");
+   * // {@literal =>} Optional.empty
+   *
+   * // Accept: text/*;q=.5, application/json
+   * req.accepts("text/html", "application/json");
+   * // {@literal =>} "application/json"
+   * 
+ * + * @param types Types to test. + * @return The best acceptable type. + */ + @Nonnull + default Optional accepts(final MediaType... types) { + return accepts(ImmutableList.copyOf(types)); + } + + /** + * Check if the given types are acceptable, returning the best match when true, or else + * Optional.empty. + * + *
+   * // Accept: text/html
+   * req.accepts("text/html");
+   * // {@literal =>} "text/html"
+   *
+   * // Accept: text/*, application/json
+   * req.accepts("text/html");
+   * // {@literal =>} "text/html"
+   * req.accepts("text/html");
+   * // {@literal =>} "text/html"
+   * req.accepts("application/json" "text/plain");
+   * // {@literal =>} "application/json"
+   * req.accepts("application/json");
+   * // {@literal =>} "application/json"
+   *
+   * // Accept: text/*, application/json
+   * req.accepts("image/png");
+   * // {@literal =>} Optional.empty
+   *
+   * // Accept: text/*;q=.5, application/json
+   * req.accepts("text/html", "application/json");
+   * // {@literal =>} "application/json"
+   * 
+ * + * @param types Types to test for. + * @return The best acceptable type. + */ + @Nonnull + Optional accepts(List types); + + /** + * Get all the available parameters. A HTTP parameter can be provided in any of + * these forms: + * + *
    + *
  • Path parameter, like: /path/:name or /path/{name}
  • + *
  • Query parameter, like: ?name=jooby
  • + *
  • Body parameter when Content-Type is + * application/x-www-form-urlencoded or multipart/form-data
  • + *
+ * + * @return All the parameters. + */ + @Nonnull + Mutant params(); + + /** + * Get all the available parameters. A HTTP parameter can be provided in any of + * these forms: + * + *
    + *
  • Path parameter, like: /path/:name or /path/{name}
  • + *
  • Query parameter, like: ?name=jooby
  • + *
  • Body parameter when Content-Type is + * application/x-www-form-urlencoded or multipart/form-data
  • + *
+ * + * @param xss Xss filter to apply. + * @return All the parameters. + */ + @Nonnull + Mutant params(String... xss); + + /** + * Short version of params().to(type). + * + * @param type Object type. + * @param Value type. + * @return Instance of object. + */ + @Nonnull + default T params(final Class type) { + return params().to(type); + } + + /** + * Short version of params().to(type). + * + * @param type Object type. + * @param Value type. + * @return Instance of object. + */ + @Nonnull + default T form(final Class type) { + return params().to(type); + } + + /** + * Short version of params(xss).to(type). + * + * @param type Object type. + * @param xss Xss filter to apply. + * @param Value type. + * @return Instance of object. + */ + @Nonnull + default T params(final Class type, final String... xss) { + return params(xss).to(type); + } + + /** + * Short version of params(xss).to(type). + * + * @param type Object type. + * @param xss Xss filter to apply. + * @param Value type. + * @return Instance of object. + */ + @Nonnull + default T form(final Class type, final String... xss) { + return params(xss).to(type); + } + + /** + * Get a HTTP request parameter under the given name. A HTTP parameter can be provided in any of + * these forms: + *
    + *
  • Path parameter, like: /path/:name or /path/{name}
  • + *
  • Query parameter, like: ?name=jooby
  • + *
  • Body parameter when Content-Type is + * application/x-www-form-urlencoded or multipart/form-data
  • + *
+ * + * The order of precedence is: path, query and body. For + * example a pattern like: GET /path/:name for /path/jooby?name=rocks + * produces: + * + *
+   *  assertEquals("jooby", req.param(name).value());
+   *
+   *  assertEquals("jooby", req.param(name).toList().get(0));
+   *  assertEquals("rocks", req.param(name).toList().get(1));
+   * 
+ * + * Uploads can be retrieved too when Content-Type is multipart/form-data + * see {@link Upload} for more information. + * + * @param name A parameter's name. + * @return A HTTP request parameter. + */ + @Nonnull + Mutant param(String name); + + /** + * Get a HTTP request parameter under the given name. A HTTP parameter can be provided in any of + * these forms: + *
    + *
  • Path parameter, like: /path/:name or /path/{name}
  • + *
  • Query parameter, like: ?name=jooby
  • + *
  • Body parameter when Content-Type is + * application/x-www-form-urlencoded or multipart/form-data
  • + *
+ * + * The order of precedence is: path, query and body. For + * example a pattern like: GET /path/:name for /path/jooby?name=rocks + * produces: + * + *
+   *  assertEquals("jooby", req.param(name).value());
+   *
+   *  assertEquals("jooby", req.param(name).toList().get(0));
+   *  assertEquals("rocks", req.param(name).toList().get(1));
+   * 
+ * + * Uploads can be retrieved too when Content-Type is multipart/form-data + * see {@link Upload} for more information. + * + * @param name A parameter's name. + * @param xss Xss filter to apply. + * @return A HTTP request parameter. + */ + @Nonnull + Mutant param(String name, String... xss); + + /** + * Get a file {@link Upload} with the given name. The request must be a POST with + * multipart/form-data content-type. + * + * @param name File's name. + * @return An {@link Upload}. + * @throws IOException + */ + @Nonnull + default Upload file(final String name) throws IOException { + List files = files(name); + if (files.size() == 0) { + throw new Err.Missing(name); + } + return files.get(0); + } + + /** + * Get a file {@link Upload} with the given name or empty. The request must be a POST with + * multipart/form-data content-type. + * + * @param name File's name. + * @return An {@link Upload}. + * @throws IOException + */ + @Nonnull + default Optional ifFile(final String name) throws IOException { + List files = files(name); + return files.size() == 0 ? Optional.empty() : Optional.of(files.get(0)); + } + + /** + * Get a list of file {@link Upload} with the given name. The request must be a POST with + * multipart/form-data content-type. + * + * @param name File's name. + * @return A list of {@link Upload}. + * @throws IOException + */ + @Nonnull + List files(final String name) throws IOException; + + /** + * Get a list of files {@link Upload} that were uploaded in the request. The request must be a POST with + * multipart/form-data content-type. + * + * @return A list of {@link Upload}. + * @throws IOException + */ + @Nonnull + List files() throws IOException; + + /** + * Get a HTTP header. + * + * @param name A header's name. + * @return A HTTP request header. + */ + @Nonnull + Mutant header(String name); + + /** + * Get a HTTP header and apply the XSS escapers. + * + * @param name A header's name. + * @param xss Xss escapers. + * @return A HTTP request header. + */ + @Nonnull + Mutant header(final String name, final String... xss); + + /** + * @return All the headers. + */ + @Nonnull + Map headers(); + + /** + * Get a cookie with the given name (if present). + * + * @param name Cookie's name. + * @return A cookie or an empty optional. + */ + @Nonnull + Mutant cookie(String name); + + /** + * @return All the cookies. + */ + @Nonnull + List cookies(); + + /** + * HTTP body. Please don't use this method for form submits. This method is used for getting + * raw data or a data like json, xml, etc... + * + * @return The HTTP body. + * @throws Exception If body can't be converted or there is no HTTP body. + */ + @Nonnull + Mutant body() throws Exception; + + /** + * Short version of body().to(type). + * + * HTTP body. Please don't use this method for form submits. This method is used for getting + * raw or a parsed data like json, xml, etc... + * + * @param type Object type. + * @param Value type. + * @return Instance of object. + * @throws Exception If body can't be converted or there is no HTTP body. + */ + @Nonnull + default T body(final Class type) throws Exception { + return body().to(type); + } + + /** + * The charset defined in the request body. If the request doesn't specify a character + * encoding, this method return the global charset: application.charset. + * + * @return A current charset. + */ + @Nonnull + Charset charset(); + + /** + * Get a list of locale that best matches the current request as per {@link Locale#filter}. + * + * @return A list of matching locales or empty list. + */ + @Nonnull + default List locales() { + return locales(Locale::filter); + } + + /** + * Get a list of locale that best matches the current request. + * + * The first filter argument is the value of Accept-Language as + * {@link Locale.LanguageRange} and filter while the second argument is a list of supported + * locales defined by the application.lang property. + * + * The next example returns a list of matching {@code Locale} instances using the filtering + * mechanism defined in RFC 4647: + * + *
{@code
+   * req.locales(Locale::filter)
+   * }
+ * + * @param filter A locale filter. + * @return A list of matching locales. + */ + @Nonnull + List locales(BiFunction, List, List> filter); + + /** + * Get a locale that best matches the current request. + * + * The first filter argument is the value of Accept-Language as + * {@link Locale.LanguageRange} and filter while the second argument is a list of supported + * locales defined by the application.lang property. + * + * The next example returns a {@code Locale} instance for the best-matching language + * tag using the lookup mechanism defined in RFC 4647. + * + *
{@code
+   * req.locale(Locale::lookup)
+   * }
+ * + * @param filter A locale filter. + * @return A matching locale. + */ + @Nonnull + Locale locale(BiFunction, List, Locale> filter); + + /** + * Get a locale that best matches the current request or the default locale as specified + * in application.lang. + * + * @return A matching locale. + */ + @Nonnull + default Locale locale() { + return locale((priorityList, locales) -> Optional.ofNullable(Locale.lookup(priorityList, locales)) + .orElse(locales.get(0))); + } + + /** + * @return The length, in bytes, of the request body and made available by the input stream, or + * -1 if the length is not known. + */ + long length(); + + /** + * @return The IP address of the client or last proxy that sent the request. + */ + @Nonnull + String ip(); + + /** + * @return Server port, from host header or the server port where the client + * connection was accepted on. + */ + int port(); + + /** + * @return The currently matched {@link Route}. + */ + @Nonnull + Route route(); + + /** + * The fully qualified name of the resource being requested, as obtained from the Host HTTP + * header. + * + * @return The fully qualified name of the server. + */ + @Nonnull + String hostname(); + + /** + * @return The current session associated with this request or if the request does not have a + * session, creates one. + */ + @Nonnull + Session session(); + + /** + * @return The current session associated with this request if there is one. + */ + @Nonnull + Optional ifSession(); + + /** + * @return True if the X-Requested-With header is set to XMLHttpRequest. + */ + default boolean xhr() { + return header("X-Requested-With") + .toOptional(String.class) + .map("XMLHttpRequest"::equalsIgnoreCase) + .orElse(Boolean.FALSE); + } + + /** + * @return The name and version of the protocol the request uses in the form + * protocol/majorVersion.minorVersion, for example, HTTP/1.1 + */ + @Nonnull + String protocol(); + + /** + * @return True if this request was made using a secure channel, such as HTTPS. + */ + boolean secure(); + + /** + * Set local attribute. + * + * @param name Attribute's name. + * @param value Attribute's local. NOT null. + * @return This request. + */ + @Nonnull + Request set(String name, Object value); + + /** + * Give you access to flash scope. Usage: + * + *
{@code
+   * {
+   *   use(new FlashScope());
+   *
+   *   get("/", req -> {
+   *     Map flash = req.flash();
+   *     return flash;
+   *   });
+   * }
+   * }
+ * + * As you can see in the example above, the {@link FlashScope} needs to be install it by calling + * {@link Jooby#use(org.jooby.Jooby.Module)} otherwise a call to this method ends in + * {@link Err BAD_REQUEST}. + * + * @return A mutable map with attributes from {@link FlashScope}. + * @throws Err Bad request error if the {@link FlashScope} was not installed it. + */ + @Nonnull + default Flash flash() throws Err { + Optional flash = ifGet(FlashScope.NAME); + return flash.orElseThrow(() -> new Err(Status.BAD_REQUEST, + "Flash scope isn't available. Install via: use(new FlashScope());")); + } + + /** + * Set a flash attribute. Flash scope attributes are accessible from template engines, by + * prefixing attributes with flash.. For example a call to + * flash("success", "OK") is accessible from template engines using + * flash.success + * + * @param name Attribute's name. + * @param value Attribute's value. + * @return This request. + */ + @Nonnull + default Request flash(final String name, @Nullable final Object value) { + requireNonNull(name, "Attribute's name is required."); + Map flash = flash(); + if (value == null) { + flash.remove(name); + } else { + flash.put(name, value.toString()); + } + return this; + } + + /** + * Get an optional for the given flash attribute's name. + * + * @param name Attribute's name. + * @return Optional flash attribute. + */ + @Nonnull + default Optional ifFlash(final String name) { + return Optional.ofNullable(flash().get(name)); + } + + /** + * Get a flash attribute value or throws {@link Err BAD_REQUEST error} if missing. + * + * @param name Attribute's name. + * @return Flash attribute. + * @throws Err Bad request error if flash attribute is missing. + */ + @Nonnull + default String flash(final String name) throws Err { + return ifFlash(name) + .orElseThrow(() -> new Err(Status.BAD_REQUEST, + "Required flash attribute: '" + name + "' is not present")); + } + + /** + * @param name Attribute's name. + * @return True if the local attribute is set. + */ + default boolean isSet(final String name) { + return ifGet(name).isPresent(); + } + + /** + * Get a request local attribute. + * + * @param name Attribute's name. + * @param Target type. + * @return A local attribute. + */ + @Nonnull + Optional ifGet(String name); + + /** + * Get a request local attribute. + * + * @param name Attribute's name. + * @param def A default value. + * @param Target type. + * @return A local attribute. + */ + @Nonnull + default T get(final String name, final T def) { + Optional opt = ifGet(name); + return opt.orElse(def); + } + + /** + * Get a request local attribute. + * + * @param name Attribute's name. + * @param Target type. + * @return A local attribute. + * @throws Err with {@link Status#BAD_REQUEST}. + */ + @Nonnull + default T get(final String name) { + Optional opt = ifGet(name); + return opt.orElseThrow( + () -> new Err(Status.BAD_REQUEST, "Required local attribute: " + name + " is not present")); + } + + /** + * Remove a request local attribute. + * + * @param name Attribute's name. + * @param Target type. + * @return A local attribute. + */ + @Nonnull + Optional unset(String name); + + /** + * A read only version of the current locals. + * + * @return Attributes locals. + */ + @Nonnull + Map attributes(); + + /** + * Seed a {@link RequestScoped} object. + * + * @param type Object type. + * @param value Actual object to bind. + * @return Current request. + */ + @Nonnull + default Request set(final Class type, final Object value) { + return set(TypeLiteral.get(type), value); + } + + /** + * Seed a {@link RequestScoped} object. + * + * @param type Seed type. + * @param value Actual object to bind. + * @return Current request. + */ + @Nonnull + default Request set(final TypeLiteral type, final Object value) { + return set(Key.get(type), value); + } + + /** + * Seed a {@link RequestScoped} object. + * + * @param key Seed key. + * @param value Actual object to bind. + * @return Current request. + */ + @Nonnull + Request set(Key key, Object value); + + /** + * Send a push promise frame to the client and push the resource identified by the given path. + * + * @param path Path of the resource to push. + * @return This request. + */ + @Nonnull + default Request push(final String path) { + return push(path, ImmutableMap.of()); + } + + /** + * Send a push promise frame to the client and push the resource identified by the given path. + * + * @param path Path of the resource to push. + * @param headers Headers to send. + * @return This request. + */ + @Nonnull + Request push(final String path, final Map headers); + + /** + * Request timestamp. + * + * @return The time that the request was received. + */ + long timestamp(); + +} diff --git a/jooby/src/main/java/org/jooby/RequestLogger.java b/jooby/src/main/java/org/jooby/RequestLogger.java new file mode 100644 index 00000000..e23ab05f --- /dev/null +++ b/jooby/src/main/java/org/jooby/RequestLogger.java @@ -0,0 +1,357 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static java.util.Objects.requireNonNull; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + *

request logger

+ *

+ * Log all the matched incoming requested using the + * NCSA format (a.k.a common log + * format). + *

+ *

usage

+ * + *
{@code
+ * {
+ *   use("*", new RequestLogger());
+ *
+ *   ...
+ * }
+ * }
+ * + *

+ * Output looks like: + *

+ * + *
+ * 127.0.0.1 - - [04/Oct/2016:17:51:42 +0000] "GET / HTTP/1.1" 200 2
+ * 
+ * + *

+ * You probably want to configure the RequestLogger logger to save output into a new file: + *

+ * + *
+ * <appender name="ACCESS" class="ch.qos.logback.core.rolling.RollingFileAppender">
+ *   <file>access.log</file>
+ *   <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+ *     <fileNamePattern>access.%d{yyyy-MM-dd}.log</fileNamePattern>
+ *   </rollingPolicy>
+ *
+ *   <encoder>
+ *     <pattern>%msg%n</pattern>
+ *   </encoder>
+ * </appender>
+ *
+ * <logger name="org.jooby.RequestLogger" additivity="false">
+ *   <appender-ref ref="ACCESS" />
+ * </logger>
+ * 
+ * + *

+ * Due that authentication is provided via module or custom filter, there is no concept of + * logged/authenticated user. Still you can log the current user by setting an user id provider at + * construction time: + *

+ * + *
{@code
+ * {
+ *
+ *   use("*", (req, rsp) -> {
+ *     // authenticate user and set local attribute
+ *     String userId = ...;
+ *     req.set("userId", userId);
+ *   });
+ *
+ *   use("*", new RequestLogger(req -> {
+ *     return req.get("userId");
+ *   }));
+ * }
+ * }
+ * + *

+ * Here an application filter set an userId request attribute and then we provide that + * userId to {@link RequestLogger}. + *

+ * + *

custom log function

+ *

+ * By default it uses the underlying logging system: logback. + * That's why we previously show how to configure the org.jooby.RequestLogger in + * logback.xml. + *

+ * + *

+ * If you want to log somewhere else and/or use a different technology then: + *

+ * + *
{@code
+ * {
+ *   use("*", new ResponseLogger()
+ *     .log(line -> {
+ *       System.out.println(line);
+ *     }));
+ * }
+ * }
+ * + *

+ * This is just an example but of course you can log the NCSA line to database, jms + * queue, etc... + *

+ * + *

latency

+ * + *
{@code
+ * {
+ *   use("*", new RequestLogger()
+ *       .latency());
+ * }
+ * }
+ * + *

+ * It add a new entry at the last of the NCSA output that represents the number of + * ms it took to process the incoming release. + * + *

extended

+ *

+ * Extend the NCSA by adding the Referer and User-Agent + * headers to the output. + *

+ * + *

dateFormatter

+ * + *
{@code
+ * {
+ *   use("*", new RequestLogger()
+ *       .dateFormatter(ts -> ...));
+ *
+ *   // OR
+ *   use("*", new RequestLogger()
+ *       .dateFormatter(DateTimeFormatter...));
+ * }
+ * }
+ * + *

+ * Override, the default formatter for the request arrival time defined by: + * {@link Request#timestamp()}. You can provide a function or an instance of + * {@link DateTimeFormatter}. + *

+ * + *

+ * The default formatter use the default server time zone, provided by + * {@link ZoneId#systemDefault()}. It's possible to just override the time zone (not the entirely + * formatter) too: + *

+ * + *
{@code
+ * {
+ *   use("*", new RequestLogger()
+ *      .dateFormatter(ZoneId.of("UTC"));
+ * }
+ * }
+ * + * @author edgar + * @since 1.0.0 + */ +public class RequestLogger implements Route.Handler { + + private static final String USER_AGENT = "User-Agent"; + + private static final String REFERER = "Referer"; + + private static final String CONTENT_LENGTH = "Content-Length"; + + private static final DateTimeFormatter FORMATTER = DateTimeFormatter + .ofPattern("dd/MMM/yyyy:HH:mm:ss Z") + .withZone(ZoneId.systemDefault()); + + private static final String DASH = "-"; + private static final char SP = ' '; + private static final char BL = '['; + private static final char BR = ']'; + private static final char Q = '\"'; + private static final char QUERY = '?'; + + private static Function ANNON = req -> DASH; + + /** The logging system. */ + private final Logger log = LoggerFactory.getLogger(getClass()); + + private final Function userId; + + private Consumer logRecord = log::info; + + private Function df; + + private boolean latency; + + private boolean queryString; + + private boolean extended; + + /** + * Creates a new {@link RequestLogger} and use the given function and userId provider. Please + * note, if the user isn't present this function is allowed to returns - (dash + * character). + * + * @param userId User ID provider. + */ + public RequestLogger(final Function userId) { + this.userId = requireNonNull(userId, "User ID provider required."); + dateFormatter(FORMATTER); + } + + /** + * Creates a new {@link RequestLogger} without user identifier. + */ + public RequestLogger() { + this(ANNON); + } + + @Override + public void handle(final Request req, final Response rsp) throws Throwable { + /** Push complete callback . */ + rsp.complete((ereq, ersp, x) -> { + StringBuilder sb = new StringBuilder(256); + long timestamp = req.timestamp(); + sb.append(req.ip()); + sb.append(SP).append(DASH).append(SP); + sb.append(userId.apply(req)); + sb.append(SP); + sb.append(BL).append(df.apply(timestamp)).append(BR); + sb.append(SP); + sb.append(Q).append(req.method()); + sb.append(SP); + sb.append(req.path()); + if (queryString) { + req.queryString().ifPresent(s -> sb.append(QUERY).append(s)); + } + sb.append(SP); + sb.append(req.protocol()); + sb.append(Q).append(SP); + int status = ersp.status().orElse(Status.OK).value(); + sb.append(status); + sb.append(SP); + sb.append(ersp.header(CONTENT_LENGTH).value(DASH)); + if (extended) { + sb.append(SP); + sb.append(Q).append(req.header(REFERER).value(DASH)).append(Q).append(SP); + sb.append(Q).append(req.header(USER_AGENT).value(DASH)).append(Q); + } + if (latency) { + long now = System.currentTimeMillis(); + sb.append(SP); + sb.append(now - timestamp); + } + logRecord.accept(sb.toString()); + }); + } + + /** + * Log an NCSA line to somewhere. + * + *
{@code
+   *  {
+   *    use("*", new RequestLogger()
+   *        .log(System.out::println)
+   *    );
+   *  }
+   * }
+ * + * @param log Log callback. + * @return This instance. + */ + public RequestLogger log(final Consumer log) { + this.logRecord = requireNonNull(log, "Logger required."); + return this; + } + + /** + * Override the default date formatter. + * + * @param formatter New formatter to use. + * @return This instance. + */ + public RequestLogger dateFormatter(final DateTimeFormatter formatter) { + requireNonNull(formatter, "Formatter required."); + return dateFormatter(ts -> formatter.format(Instant.ofEpochMilli(ts))); + } + + /** + * Override the default date formatter. + * + * @param formatter New formatter to use. + * @return This instance. + */ + public RequestLogger dateFormatter(final Function formatter) { + requireNonNull(formatter, "Formatter required."); + this.df = formatter; + return this; + } + + /** + * Keep the default formatter but use the provided timezone. + * + * @param zoneId Zone id. + * @return This instance. + */ + public RequestLogger dateFormatter(final ZoneId zoneId) { + return dateFormatter(FORMATTER.withZone(zoneId)); + } + + /** + * Log latency (how long does it takes to process an incoming request) at the end of the NCSA + * line. + * + * @return This instance. + */ + public RequestLogger latency() { + this.latency = true; + return this; + } + + /** + * Log full path of the request including query string. + * + * @return This instance. + */ + public RequestLogger queryString() { + this.queryString = true; + return this; + } + + /** + * Append Referer and User-Agent entries to the NCSA line. + * + * @return This instance. + */ + public RequestLogger extended() { + this.extended = true; + return this; + } + +} diff --git a/jooby/src/main/java/org/jooby/Response.java b/jooby/src/main/java/org/jooby/Response.java new file mode 100644 index 00000000..b0fdf40b --- /dev/null +++ b/jooby/src/main/java/org/jooby/Response.java @@ -0,0 +1,685 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static java.util.Objects.requireNonNull; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.util.Optional; + +import org.jooby.Cookie.Definition; + +import com.google.common.collect.ImmutableList; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Give you access to the actual HTTP response. You can read/write headers and write HTTP body. + * + * @author edgar + * @since 0.1.0 + */ +public interface Response { + + /** + * A forwarding response. + * + * @author edgar + * @since 0.1.0 + */ + class Forwarding implements Response { + + /** The target response. */ + protected final Response rsp; + + /** + * Creates a new {@link Forwarding} response. + * + * @param response A response object. + */ + public Forwarding(final Response response) { + this.rsp = requireNonNull(response, "A response is required."); + } + + @Override + public void download(final String filename, final InputStream stream) throws Throwable { + rsp.download(filename, stream); + } + + @Override + public void download(final File file) throws Throwable { + rsp.download(file); + } + + @Override + public void download(final String filename, final File file) throws Throwable { + rsp.download(filename, file); + } + + @Override + public void download(final String filename) throws Throwable { + rsp.download(filename); + } + + @Override + public void download(final String filename, final String location) throws Throwable { + rsp.download(filename, location); + } + + @Override + public Response cookie(final String name, final String value) { + rsp.cookie(name, value); + return this; + } + + @Override + public Response cookie(final Cookie cookie) { + rsp.cookie(cookie); + return this; + } + + @Override + public Response cookie(final Definition cookie) { + rsp.cookie(cookie); + return this; + } + + @Override + public Response clearCookie(final String name) { + rsp.clearCookie(name); + return this; + } + + @Override + public Mutant header(final String name) { + return rsp.header(name); + } + + @Override + public Response header(final String name, final Object value) { + rsp.header(name, value); + return this; + } + + @Override + public Response header(final String name, final Object... values) { + rsp.header(name, values); + return this; + } + + @Override + public Response header(final String name, final Iterable values) { + rsp.header(name, values); + return this; + } + + @Override + public Charset charset() { + return rsp.charset(); + } + + @Override + public Response charset(final Charset charset) { + rsp.charset(charset); + return this; + } + + @Override + public Response length(final long length) { + rsp.length(length); + return this; + } + + @Override + public Optional type() { + return rsp.type(); + } + + @Override + public Response type(final MediaType type) { + rsp.type(type); + return this; + } + + @Override + public Response type(final String type) { + rsp.type(type); + return this; + } + + @Override + public void send(final Object result) throws Throwable { + // Special case: let the default response to deal with Object refs. + // once resolved it will call the Result version. + Response.super.send(result); + } + + @Override + public void send(final Result result) throws Throwable { + rsp.send(result); + } + + @Override + public void end() { + rsp.end(); + } + + @Override + public void redirect(final String location) throws Throwable { + rsp.redirect(location); + } + + @Override + public void redirect(final Status status, final String location) throws Throwable { + rsp.redirect(status, location); + } + + @Override + public Optional status() { + return rsp.status(); + } + + @Override + public Response status(final Status status) { + rsp.status(status); + return this; + } + + @Override + public Response status(final int status) { + rsp.status(status); + return this; + } + + @Override + public boolean committed() { + return rsp.committed(); + } + + @Override + public void after(final Route.After handler) { + rsp.after(handler); + } + + @Override + public void complete(final Route.Complete handler) { + rsp.complete(handler); + } + + @Override + public String toString() { + return rsp.toString(); + } + + @Override public boolean isResetHeadersOnError() { + return rsp.isResetHeadersOnError(); + } + + @Override public void setResetHeadersOnError(boolean value) { + this.setResetHeadersOnError(value); + } + + /** + * Unwrap a response in order to find out the target instance. + * + * @param rsp A response. + * @return A target instance (not a {@link Response.Forwarding}). + */ + public static Response unwrap(final Response rsp) { + requireNonNull(rsp, "A response is required."); + Response root = rsp; + while (root instanceof Forwarding) { + root = ((Forwarding) root).rsp; + } + return root; + } + } + + /** + * Transfer the file at path as an "attachment". Typically, browsers will prompt the user for + * download. The Content-Disposition "filename=" parameter (i.e. the one that will + * appear in the browser dialog) is set to filename. + * + * @param filename A file name to use. + * @param stream A stream to attach. + * @throws Exception If something goes wrong. + */ + void download(String filename, InputStream stream) throws Throwable; + + /** + * Transfer the file at path as an "attachment". Typically, browsers will prompt the user for + * download. The Content-Disposition "filename=" parameter (i.e. the one that will + * appear in the browser dialog) is set to filename by default. + * + * @param location Classpath location of the file. + * @throws Exception If something goes wrong. + */ + default void download(final String location) throws Throwable { + download(location, location); + } + + /** + * Transfer the file at path as an "attachment". Typically, browsers will prompt the user for + * download. The Content-Disposition "filename=" parameter (i.e. the one that will + * appear in the browser dialog) is set to filename by default. + * + * @param filename A file name to use. + * @param location classpath location of the file. + * @throws Exception If something goes wrong. + */ + void download(final String filename, final String location) throws Throwable; + + /** + * Transfer the file at path as an "attachment". Typically, browsers will prompt the user for + * download. The Content-Disposition "filename=" parameter (i.e. the one that will + * appear in the browser dialog) is set to filename by default. + * + * @param file A file to use. + * @throws Exception If something goes wrong. + */ + default void download(final File file) throws Throwable { + download(file.getName(), file); + } + + /** + * Transfer the file at path as an "attachment". Typically, browsers will prompt the user for + * download. The Content-Disposition "filename=" parameter (i.e. the one that will + * appear in the browser dialog) is set to filename. + * + * @param filename A file name to use. + * @param file A file to use. + * @throws Exception If something goes wrong. + */ + default void download(final String filename, final File file) throws Throwable { + length(file.length()); + download(filename, new FileInputStream(file)); + } + + /** + * Adds the specified cookie to the response. + * + * @param name A cookie's name. + * @param value A cookie's value. + * @return This response. + */ + @Nonnull + default Response cookie(final String name, final String value) { + return cookie(new Cookie.Definition(name, value)); + } + + /** + * Adds the specified cookie to the response. + * + * @param cookie A cookie definition. + * @return This response. + */ + @Nonnull + Response cookie(final Cookie.Definition cookie); + + /** + * Adds the specified cookie to the response. + * + * @param cookie A cookie. + * @return This response. + */ + @Nonnull + Response cookie(Cookie cookie); + + /** + * Discard a cookie from response. Discard is done by setting maxAge=0. + * + * @param name Cookie's name. + * @return This response. + */ + @Nonnull + Response clearCookie(String name); + + /** + * Get a header with the given name. + * + * @param name A name. + * @return A HTTP header. + */ + @Nonnull + Mutant header(String name); + + /** + * Sets a response header with the given name and value. If the header had already been set, + * the new value overwrites the previous one. + * + * @param name Header's name. + * @param value Header's value. + * @return This response. + */ + @Nonnull + Response header(String name, Object value); + + /** + * Sets a response header with the given name and value. If the header had already been set, + * the new value overwrites the previous one. + * + * @param name Header's name. + * @param values Header's value. + * @return This response. + */ + @Nonnull + default Response header(final String name, final Object... values) { + return header(name, ImmutableList.builder().add(values).build()); + } + + /** + * Sets a response header with the given name and value. If the header had already been set, + * the new value overwrites the previous one. + * + * @param name Header's name. + * @param values Header's value. + * @return This response. + */ + @Nonnull + Response header(String name, Iterable values); + + /** + * If charset is not set this method returns charset defined in the request body. If the request + * doesn't specify a character encoding, this method return the global charset: + * application.charset. + * + * @return A current charset. + */ + @Nonnull + Charset charset(); + + /** + * Set the {@link Charset} to use and set the Content-Type header with the current + * charset. + * + * @param charset A charset. + * @return This response. + */ + @Nonnull + Response charset(Charset charset); + + /** + * Set the length of the response and set the Content-Length header. + * + * @param length Length of response. + * @return This response. + */ + @Nonnull + Response length(long length); + + /** + * @return Get the response type. + */ + @Nonnull + Optional type(); + + /** + * Set the response media type and set the Content-Type header. + * + * @param type A media type. + * @return This response. + */ + @Nonnull + Response type(MediaType type); + + /** + * Set the response media type and set the Content-Type header. + * + * @param type A media type. + * @return This response. + */ + @Nonnull + default Response type(final String type) { + return type(MediaType.valueOf(type)); + } + + /** + * Responsible of writing the given body into the HTTP response. + * + * @param result The HTTP body. + * @throws Exception If the response write fails. + */ + default void send(final @Nullable Object result) throws Throwable { + if (result instanceof Result) { + send((Result) result); + } else if (result != null) { + // wrap body + Result b = Results.with(result); + status().ifPresent(b::status); + type().ifPresent(b::type); + send(b); + } else { + throw new NullPointerException("Response required."); + } + } + + /** + * Responsible of writing the given body into the HTTP response. + * + * @param result A HTTP response. + * @throws Exception If the response write fails. + */ + void send(Result result) throws Throwable; + + /** + * Redirect to the given url with status code defaulting to {@link Status#FOUND}. + * + *
+   *  rsp.redirect("/foo/bar");
+   *  rsp.redirect("http://example.com");
+   *  rsp.redirect("http://example.com");
+   *  rsp.redirect("../login");
+   * 
+ * + * Redirects can be a fully qualified URI for redirecting to a different site: + * + *
+   *   rsp.redirect("http://google.com");
+   * 
+ * + * Redirects can be relative to the root of the host name. For example, if you were + * on http://example.com/admin/post/new, the following redirect to /admin would + * land you at http://example.com/admin: + * + *
+   *   rsp.redirect("/admin");
+   * 
+ * + * Redirects can be relative to the current URL. A redirection of post/new, from + * http://example.com/blog/admin/ (notice the trailing slash), would give you + * http://example.com/blog/admin/post/new. + * + *
+   *   rsp.redirect("post/new");
+   * 
+ * + * Redirecting to post/new from http://example.com/blog/admin (no trailing slash), + * will take you to http://example.com/blog/post/new. + * + *

+ * If you found the above behavior confusing, think of path segments as directories (have trailing + * slashes) and files, it will start to make sense. + *

+ * + * Pathname relative redirects are also possible. If you were on + * http://example.com/admin/post/new, the following redirect would land you at + * http//example.com/admin: + * + *
+   *   rsp.redirect("..");
+   * 
+ * + * A back redirection will redirect the request back to the Referer, defaulting to + * / when missing. + * + *
+   *   rsp.redirect("back");
+   * 
+ * + * @param location Either a relative or absolute location. + * @throws Throwable If redirection fails. + */ + default void redirect(final String location) throws Throwable { + redirect(Status.FOUND, location); + } + + /** + * Redirect to the given url with status code defaulting to {@link Status#FOUND}. + * + *
+   *  rsp.redirect("/foo/bar");
+   *  rsp.redirect("http://example.com");
+   *  rsp.redirect("http://example.com");
+   *  rsp.redirect("../login");
+   * 
+ * + * Redirects can be a fully qualified URI for redirecting to a different site: + * + *
+   *   rsp.redirect("http://google.com");
+   * 
+ * + * Redirects can be relative to the root of the host name. For example, if you were + * on http://example.com/admin/post/new, the following redirect to /admin would + * land you at http://example.com/admin: + * + *
+   *   rsp.redirect("/admin");
+   * 
+ * + * Redirects can be relative to the current URL. A redirection of post/new, from + * http://example.com/blog/admin/ (notice the trailing slash), would give you + * http://example.com/blog/admin/post/new. + * + *
+   *   rsp.redirect("post/new");
+   * 
+ * + * Redirecting to post/new from http://example.com/blog/admin (no trailing slash), + * will take you to http://example.com/blog/post/new. + * + *

+ * If you found the above behavior confusing, think of path segments as directories (have trailing + * slashes) and files, it will start to make sense. + *

+ * + * Pathname relative redirects are also possible. If you were on + * http://example.com/admin/post/new, the following redirect would land you at + * http//example.com/admin: + * + *
+   *   rsp.redirect("..");
+   * 
+ * + * A back redirection will redirect the request back to the Referer, defaulting to + * / when missing. + * + *
+   *   rsp.redirect("back");
+   * 
+ * + * @param status A redirect status. + * @param location Either a relative or absolute location. + * @throws Throwable If redirection fails. + */ + void redirect(Status status, String location) throws Throwable; + + /** + * @return A HTTP status or empty if status was not set yet. + */ + @Nonnull + Optional status(); + + /** + * Set the HTTP response status. + * + * @param status A HTTP status. + * @return This response. + */ + @Nonnull + Response status(Status status); + + /** + * Set the HTTP response status. + * + * @param status A HTTP status. + * @return This response. + */ + @Nonnull + default Response status(final int status) { + return status(Status.valueOf(status)); + } + + /** + * Returns a boolean indicating if the response has been committed. A committed response has + * already had its status code and headers written. + * + * @return a boolean indicating if the response has been committed + */ + boolean committed(); + + /** + * Ends current request/response cycle by releasing any existing resources and committing the + * response into the channel. + * + * This method is automatically call it from a send method, so you are not force to call this + * method per each request/response cycle. + * + * It's recommended for quickly ending the response without any data: + * + *
+   *   rsp.status(304).end();
+   * 
+ * + * Keep in mind that an explicit call to this method will stop the execution of handlers. So, + * any handler further in the chain won't be executed once end has been called. + */ + void end(); + + /** + * Append an after handler, will be execute before sending response. + * + * @param handler A handler + * @see Route.After + */ + void after(Route.After handler); + + /** + * Append complete handler, will be execute after sending response. + * + * @param handler A handler + * @see Route.After + */ + void complete(Route.Complete handler); + + /** + * Indicates if headers are cleared/reset on error. + * + * @return Indicates if headers are cleared/reset on error. Default is true. + */ + boolean isResetHeadersOnError(); + + /** + * Indicates if headers are cleared/reset on error. + * + * @param value True to reset. + */ + void setResetHeadersOnError(boolean value); +} diff --git a/jooby/src/main/java/org/jooby/Result.java b/jooby/src/main/java/org/jooby/Result.java new file mode 100644 index 00000000..f42d5043 --- /dev/null +++ b/jooby/src/main/java/org/jooby/Result.java @@ -0,0 +1,365 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static java.util.Objects.requireNonNull; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Supplier; + +import com.google.common.base.Joiner; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Utility class for HTTP responses. Usually you start with a result {@link Results builder} and + * then you customize (or not) one or more HTTP attribute. + * + *

+ * The following examples build the same output: + *

+ * + *
+ * {
+ *   get("/", (req, rsp) {@literal ->} {
+ *     rsp.status(200).send("Something");
+ *   });
+ *
+ *   get("/", req {@literal ->} Results.with("Something", 200);
+ * }
+ * 
+ * + * A result is also responsible for content negotiation (if required): + * + *
+ * {
+ *   get("/", () {@literal ->} {
+ *     Object model = ...;
+ *     return Results
+ *       .when("text/html", () {@literal ->} Results.html("view").put("model", model))
+ *       .when("application/json", () {@literal ->} model);
+ *   });
+ * }
+ * 
+ * + *

+ * The example above will render a view when accept header is "text/html" or just send a text + * version of model when the accept header is "application/json". + *

+ * + * @author edgar + * @since 0.5.0 + * @see Results + */ +public class Result { + + /** + * Content negotiation support. + * + * @author edgar + */ + static class ContentNegotiation extends Result { + + private final Map> data = new LinkedHashMap<>(); + + ContentNegotiation(final Result result) { + this.status = result.status; + this.type = result.type; + this.headers = result.headers; + } + + @Override + public T get() { + return get(MediaType.ALL); + } + + @Override + public Optional ifGet() { + return ifGet(MediaType.ALL); + } + + @Override + public Optional ifGet(final List types) { + return Optional.ofNullable(get(types)); + } + + @Override + @SuppressWarnings("unchecked") + public T get(final List types) { + Supplier provider = MediaType + .matcher(types) + .first(ImmutableList.copyOf(data.keySet())) + .map(it -> data.remove(it)) + .orElseThrow( + () -> new Err(Status.NOT_ACCEPTABLE, Joiner.on(", ").join(types))); + return (T) provider.get(); + } + + @Override + public Result when(final MediaType type, final Supplier supplier) { + requireNonNull(type, "A media type is required."); + requireNonNull(supplier, "A supplier fn is required."); + data.put(type, supplier); + return this; + } + + @Override + protected Result clone() { + ContentNegotiation result = new ContentNegotiation(this); + result.data.putAll(data); + return result; + } + + } + + private static Map NO_HEADERS = ImmutableMap.of(); + + /** Response headers. */ + protected Map headers = NO_HEADERS; + + /** Response status. */ + protected Status status; + + /** Response content-type. */ + protected MediaType type; + + /** Response value. */ + private Object value; + + /** + * Set response status. + * + * @param status A new response status to use. + * @return This content. + */ + @Nonnull + public Result status(final Status status) { + this.status = requireNonNull(status, "A status is required."); + return this; + } + + /** + * Set response status. + * + * @param status A new response status to use. + * @return This content. + */ + @Nonnull + public Result status(final int status) { + return status(Status.valueOf(status)); + } + + /** + * Set the content type of this content. + * + * @param type A content type. + * @return This content. + */ + @Nonnull + public Result type(final MediaType type) { + this.type = requireNonNull(type, "A content type is required."); + return this; + } + + /** + * Set the content type of this content. + * + * @param type A content type. + * @return This content. + */ + @Nonnull + public Result type(final String type) { + return type(MediaType.valueOf(type)); + } + + /** + * Set result content. + * + * @param content A result content. + * @return This content. + */ + @Nonnull + public Result set(final Object content) { + this.value = content; + return this; + } + + /** + * Add a when clause for a custom result for the given media-type. + * + * @param type A media type to test for. + * @param supplier An object supplier. + * @return This result. + */ + @Nonnull + public Result when(final String type, final Supplier supplier) { + return when(MediaType.valueOf(type), supplier); + } + + /** + * Add a when clause for a custom result for the given media-type. + * + * @param type A media type to test for. + * @param supplier An object supplier. + * @return This result. + */ + @Nonnull + public Result when(final MediaType type, final Supplier supplier) { + return new ContentNegotiation(this).when(type, supplier); + } + + /** + * @return headers for content. + */ + @Nonnull + public Map headers() { + return headers; + } + + /** + * @return Body status. + */ + @Nonnull + public Optional status() { + return Optional.ofNullable(status); + } + + /** + * @return Body type. + */ + @Nonnull + public Optional type() { + return Optional.ofNullable(type); + } + + /** + * Get a result value. + * + * @return Value or empty + */ + @Nonnull + public Optional ifGet() { + return ifGet(MediaType.ALL); + } + + /** + * Get a result value. + * + * @param Value type. + * @return Value or null + */ + @SuppressWarnings("unchecked") + @Nullable + public T get() { + return (T) value; + } + + /** + * Get a result value for the given types (accept header). + * + * @param types Accept header. + * @return Result content. + */ + @Nonnull + public Optional ifGet(final List types) { + return Optional.ofNullable(value); + } + + /** + * Get a result value for the given types (accept header). + * + * @param types Accept header. + * @param Value type. + * @return Result content or null. + */ + @SuppressWarnings("unchecked") + @Nullable + public T get(final List types) { + return (T) value; + } + + /** + * Sets a response header with the given name and value. If the header had already been set, + * the new value overwrites the previous one. + * + * @param name Header's name. + * @param value Header's value. + * @return This content. + */ + @Nonnull + public Result header(final String name, final Object value) { + requireNonNull(name, "Header's name is required."); + requireNonNull(value, "Header's value is required."); + setHeader(name, value); + return this; + } + + /** + * Sets a response header with the given name and value. If the header had already been set, + * the new value overwrites the previous one. + * + * @param name Header's name. + * @param values Header's values. + * @return This content. + */ + @Nonnull + public Result header(final String name, final Object... values) { + requireNonNull(name, "Header's name is required."); + requireNonNull(values, "Header's values are required."); + + return header(name, ImmutableList.copyOf(values)); + } + + /** + * Sets a response header with the given name and value. If the header had already been set, + * the new value overwrites the previous one. + * + * @param name Header's name. + * @param values Header's values. + * @return This content. + */ + @Nonnull + public Result header(final String name, final Iterable values) { + requireNonNull(name, "Header's name is required."); + requireNonNull(values, "Header's values are required."); + setHeader(name, values); + return this; + } + + @Override + protected Result clone() { + Result result = new Result(); + headers.forEach(result::header); + result.status = status; + result.type = type; + result.value = value; + return result; + } + + private void setHeader(final String name, final Object val) { + headers = ImmutableMap. builder() + .putAll(headers) + .put(name, val) + .build(); + } + +} diff --git a/jooby/src/main/java/org/jooby/Results.java b/jooby/src/main/java/org/jooby/Results.java new file mode 100644 index 00000000..17f218a5 --- /dev/null +++ b/jooby/src/main/java/org/jooby/Results.java @@ -0,0 +1,471 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static java.util.Objects.requireNonNull; + +import javax.annotation.Nonnull; +import java.util.function.Supplier; + +/** + * A {@link Result} builder with some utility static methods. + * + * @author edgar + * @since 0.5.0 + */ +public class Results { + + /** + * Set the result + * + * @param entity A result value. + * @return A new result. + */ + @Nonnull + public static Result with(final Object entity) { + return new Result().set(entity); + } + + /** + * Set the result + * + * @param entity A result value. + * @param status A HTTP status. + * @return A new result. + */ + @Nonnull + public static Result with(final Object entity, final Status status) { + return new Result().status(status).set(entity); + } + + /** + * Set the result + * + * @param entity A result value. + * @param status A HTTP status. + * @return A new result. + */ + @Nonnull + public static Result with(final Object entity, final int status) { + return with(entity, Status.valueOf(status)); + } + + /** + * Set the response status. + * + * @param status A status! + * @return A new result. + */ + @Nonnull + public static Result with(final Status status) { + requireNonNull(status, "A HTTP status is required."); + return new Result().status(status); + } + + /** + * Set the response status. + * + * @param status A status! + * @return A new result. + */ + @Nonnull + public static Result with(final int status) { + requireNonNull(status, "A HTTP status is required."); + return new Result().status(status); + } + + /** + * @return A new result with {@link Status#OK}. + */ + @Nonnull + public static Result ok() { + return with(Status.OK); + } + + /** + * @param view View to render. + * @return A new view. + */ + @Nonnull + public static View html(final String view) { + return new View(view); + } + + /** + * @param entity A result content! + * @return A new json result. + */ + @Nonnull + public static Result json(final Object entity) { + return with(entity, 200).type(MediaType.json); + } + + /** + * @param entity A result content! + * @return A new json result. + */ + @Nonnull + public static Result xml(final Object entity) { + return with(entity, 200).type(MediaType.xml); + } + + /** + * @param entity A result content! + * @return A new result with {@link Status#OK} and given content. + */ + @Nonnull + public static Result ok(final Object entity) { + return ok().set(entity); + } + + /** + * @return A new result with {@link Status#ACCEPTED}. + */ + @Nonnull + public static Result accepted() { + return with(Status.ACCEPTED); + } + + /** + * @param content A result content! + * @return A new result with {@link Status#ACCEPTED}. + */ + @Nonnull + public static Result accepted(final Object content) { + return accepted().set(content); + } + + /** + * @return A new result with {@link Status#NO_CONTENT}. + */ + @Nonnull + public static Result noContent() { + return with(Status.NO_CONTENT); + } + + /** + * Redirect to the given url with status code defaulting to {@link Status#FOUND}. + * + *
+   *  rsp.redirect("/foo/bar");
+   *  rsp.redirect("http://example.com");
+   *  rsp.redirect("http://example.com");
+   *  rsp.redirect("../login");
+   * 
+ * + * Redirects can be a fully qualified URI for redirecting to a different site: + * + *
+   *   rsp.redirect("http://google.com");
+   * 
+ * + * Redirects can be relative to the root of the host name. For example, if you were + * on http://example.com/admin/post/new, the following redirect to /admin would + * land you at http://example.com/admin: + * + *
+   *   rsp.redirect("/admin");
+   * 
+ * + * Redirects can be relative to the current URL. A redirection of post/new, from + * http://example.com/blog/admin/ (notice the trailing slash), would give you + * http://example.com/blog/admin/post/new. + * + *
+   *   rsp.redirect("post/new");
+   * 
+ * + * Redirecting to post/new from http://example.com/blog/admin (no trailing slash), + * will take you to http://example.com/blog/post/new. + * + *

+ * If you found the above behavior confusing, think of path segments as directories (have trailing + * slashes) and files, it will start to make sense. + *

+ * + * Pathname relative redirects are also possible. If you were on + * http://example.com/admin/post/new, the following redirect would land you at + * http//example.com/admin: + * + *
+   *   rsp.redirect("..");
+   * 
+ * + * A back redirection will redirect the request back to the Referer, defaulting to + * / when missing. + * + *
+   *   rsp.redirect("back");
+   * 
+ * + * @param location A location. + * @return A new result. + */ + @Nonnull + public static Result redirect(final String location) { + return redirect(Status.FOUND, location); + } + + /** + * Redirect to the given url with status code defaulting to {@link Status#FOUND}. + * + *
+   *  rsp.redirect("/foo/bar");
+   *  rsp.redirect("http://example.com");
+   *  rsp.redirect("http://example.com");
+   *  rsp.redirect("../login");
+   * 
+ * + * Redirects can be a fully qualified URI for redirecting to a different site: + * + *
+   *   rsp.redirect("http://google.com");
+   * 
+ * + * Redirects can be relative to the root of the host name. For example, if you were + * on http://example.com/admin/post/new, the following redirect to /admin would + * land you at http://example.com/admin: + * + *
+   *   rsp.redirect("/admin");
+   * 
+ * + * Redirects can be relative to the current URL. A redirection of post/new, from + * http://example.com/blog/admin/ (notice the trailing slash), would give you + * http://example.com/blog/admin/post/new. + * + *
+   *   rsp.redirect("post/new");
+   * 
+ * + * Redirecting to post/new from http://example.com/blog/admin (no trailing slash), + * will take you to http://example.com/blog/post/new. + * + *

+ * If you found the above behavior confusing, think of path segments as directories (have trailing + * slashes) and files, it will start to make sense. + *

+ * + * Pathname relative redirects are also possible. If you were on + * http://example.com/admin/post/new, the following redirect would land you at + * http//example.com/admin: + * + *
+   *   rsp.redirect("..");
+   * 
+ * + * A back redirection will redirect the request back to the Referer, defaulting to + * / when missing. + * + *
+   *   rsp.redirect("back");
+   * 
+ * + * @param location A location. + * @return A new result. + */ + @Nonnull + public static Result tempRedirect(final String location) { + return redirect(Status.TEMPORARY_REDIRECT, location); + } + + /** + * Redirect to the given url with status code defaulting to {@link Status#FOUND}. + * + *
+   *  rsp.redirect("/foo/bar");
+   *  rsp.redirect("http://example.com");
+   *  rsp.redirect("http://example.com");
+   *  rsp.redirect("../login");
+   * 
+ * + * Redirects can be a fully qualified URI for redirecting to a different site: + * + *
+   *   rsp.redirect("http://google.com");
+   * 
+ * + * Redirects can be relative to the root of the host name. For example, if you were + * on http://example.com/admin/post/new, the following redirect to /admin would + * land you at http://example.com/admin: + * + *
+   *   rsp.redirect("/admin");
+   * 
+ * + * Redirects can be relative to the current URL. A redirection of post/new, from + * http://example.com/blog/admin/ (notice the trailing slash), would give you + * http://example.com/blog/admin/post/new. + * + *
+   *   rsp.redirect("post/new");
+   * 
+ * + * Redirecting to post/new from http://example.com/blog/admin (no trailing slash), + * will take you to http://example.com/blog/post/new. + * + *

+ * If you found the above behavior confusing, think of path segments as directories (have trailing + * slashes) and files, it will start to make sense. + *

+ * + * Pathname relative redirects are also possible. If you were on + * http://example.com/admin/post/new, the following redirect would land you at + * http//example.com/admin: + * + *
+   *   rsp.redirect("..");
+   * 
+ * + * A back redirection will redirect the request back to the Referer, defaulting to + * / when missing. + * + *
+   *   rsp.redirect("back");
+   * 
+ * + * @param location A location. + * @return A new result. + */ + @Nonnull + public static Result moved(final String location) { + return redirect(Status.MOVED_PERMANENTLY, location); + } + + /** + * Redirect to the given url with status code defaulting to {@link Status#FOUND}. + * + *
+   *  rsp.redirect("/foo/bar");
+   *  rsp.redirect("http://example.com");
+   *  rsp.redirect("http://example.com");
+   *  rsp.redirect("../login");
+   * 
+ * + * Redirects can be a fully qualified URI for redirecting to a different site: + * + *
+   *   rsp.redirect("http://google.com");
+   * 
+ * + * Redirects can be relative to the root of the host name. For example, if you were + * on http://example.com/admin/post/new, the following redirect to /admin would + * land you at http://example.com/admin: + * + *
+   *   rsp.redirect("/admin");
+   * 
+ * + * Redirects can be relative to the current URL. A redirection of post/new, from + * http://example.com/blog/admin/ (notice the trailing slash), would give you + * http://example.com/blog/admin/post/new. + * + *
+   *   rsp.redirect("post/new");
+   * 
+ * + * Redirecting to post/new from http://example.com/blog/admin (no trailing slash), + * will take you to http://example.com/blog/post/new. + * + *

+ * If you found the above behavior confusing, think of path segments as directories (have trailing + * slashes) and files, it will start to make sense. + *

+ * + * Pathname relative redirects are also possible. If you were on + * http://example.com/admin/post/new, the following redirect would land you at + * http//example.com/admin: + * + *
+   *   rsp.redirect("..");
+   * 
+ * + * A back redirection will redirect the request back to the Referer, defaulting to + * / when missing. + * + *
+   *   rsp.redirect("back");
+   * 
+ * + * @param location A location. + * @return A new result. + */ + @Nonnull + public static Result seeOther(final String location) { + return redirect(Status.SEE_OTHER, location); + } + + /** + * Performs content-negotiation on the Accept HTTP header on the request object. It select a + * handler for the request, based on the acceptable types ordered by their quality values. + * If the header is not specified, the first callback is invoked. When no match is found, + * the server responds with 406 "Not Acceptable", or invokes the default callback: {@code ** / *}. + * + *
+   *   get("/jsonOrHtml", () {@literal ->}
+   *     Results
+   *         .when("text/html", () {@literal ->} Results.html("view").put("model", model)))
+   *         .when("application/json", () {@literal ->} model)
+   *         .when("*", () {@literal ->} {throw new Err(Status.NOT_ACCEPTABLE);})
+   *   );
+   * 
+ * + * @param type A media type. + * @param supplier A result supplier. + * @return A new result. + */ + @Nonnull + public static Result when(final String type, final Supplier supplier) { + return new Result().when(type, supplier); + } + + /** + * Performs content-negotiation on the Accept HTTP header on the request object. It select a + * handler for the request, based on the acceptable types ordered by their quality values. + * If the header is not specified, the first callback is invoked. When no match is found, + * the server responds with 406 "Not Acceptable", or invokes the default callback: {@code ** / *}. + * + *
+   *   get("/jsonOrHtml", () {@literal ->}
+   *     Results
+   *         .when("text/html", () {@literal ->} Results.html("view").put("model", model)))
+   *         .when("application/json", () {@literal ->} model)
+   *         .when("*", () {@literal ->} {throw new Err(Status.NOT_ACCEPTABLE);})
+   *   );
+   * 
+ * + * @param type A media type. + * @param supplier A result supplier. + * @return A new result. + */ + @Nonnull + public static Result when(final MediaType type, final Supplier supplier) { + return new Result().when(type, supplier); + } + + /** + * Produces a redirect (302) status code and set the Location header too. + * + * @param status A HTTP redirect status. + * @param location A location. + * @return A new result. + */ + private static Result redirect(final Status status, final String location) { + requireNonNull(location, "A location is required."); + return with(status).header("location", location); + } + +} diff --git a/jooby/src/main/java/org/jooby/Route.java b/jooby/src/main/java/org/jooby/Route.java new file mode 100644 index 00000000..6f36695e --- /dev/null +++ b/jooby/src/main/java/org/jooby/Route.java @@ -0,0 +1,2252 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import com.google.common.base.CaseFormat; +import static com.google.common.base.Preconditions.checkArgument; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; +import com.google.common.primitives.Primitives; +import com.google.inject.Key; +import com.google.inject.TypeLiteral; +import static java.util.Objects.requireNonNull; +import org.jooby.funzy.Throwing; +import org.jooby.handlers.AssetHandler; +import org.jooby.internal.RouteImpl; +import org.jooby.internal.RouteMatcher; +import org.jooby.internal.RoutePattern; +import org.jooby.internal.RouteSourceImpl; +import org.jooby.internal.SourceProvider; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.lang.reflect.Array; +import java.lang.reflect.Method; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Routes are a key concept in Jooby. Routes are executed in the same order they are defined. + * + *

handlers

+ *

+ * There are few type of handlers: {@link Route.Handler}, {@link Route.OneArgHandler} + * {@link Route.ZeroArgHandler} and {@link Route.Filter}. They behave very similar, except that a + * {@link Route.Filter} can decide if the next route handler can be executed or not. For example: + *

+ * + *
+ *   get("/filter", (req, rsp, chain) {@literal ->} {
+ *     if (someCondition) {
+ *       chain.next(req, rsp);
+ *     } else {
+ *       // respond, throw err, etc...
+ *     }
+ *   });
+ * 
+ * + * While a {@link Route.Handler} always execute the next handler: + * + *
+ *   get("/path", (req, rsp) {@literal ->} {
+ *     rsp.send("handler");
+ *   });
+ *
+ *   // filter version
+ *   get("/path", (req, rsp, chain) {@literal ->} {
+ *     rsp.send("handler");
+ *     chain.next(req, rsp);
+ *   });
+ * 
+ * + * The {@link Route.OneArgHandler} and {@link Route.ZeroArgHandler} offers a functional version of + * generating a response: + * + *
{@code
+ * {
+ *   get("/path", req -> "handler");
+ *
+ *   get("/path", () -> "handler");
+ * }
+ * }
+ * + * There is no need to call {@link Response#send(Object)}. + * + *

path patterns

+ *

+ * Jooby supports Ant-style path patterns: + *

+ *

+ * Some examples: + *

+ *
    + *
  • {@code com/t?st.html} - matches {@code com/test.html} but also {@code com/tast.jsp} or + * {@code com/txst.html}
  • + *
  • {@code com/*.html} - matches all {@code .html} files in the {@code com} directory
  • + *
  • com/{@literal **}/test.html - matches all {@code test.html} files underneath the + * {@code com} path
  • + *
  • {@code **}/{@code *} - matches any path at any level.
  • + *
  • {@code *} - matches any path at any level, shorthand for {@code **}/{@code *}.
  • + *
+ * + *

variables

+ *

+ * Jooby supports path parameters too: + *

+ *

+ * Some examples: + *

+ *
    + *
  • /user/{id} - /user/* and give you access to the id var.
  • + *
  • /user/:id - /user/* and give you access to the id var.
  • + *
  • /user/{id:\\d+} - /user/[digits] and give you access to the numeric + * id var.
  • + *
+ * + *

routes semantic

+ *

+ * Routes are executed in the order they are defined, for example: + *

+ * + *
+ *   get("/", (req, rsp) {@literal ->} {
+ *     log.info("first"); // start here and go to second
+ *   });
+ *
+ *   get("/", (req, rsp) {@literal ->} {
+ *     log.info("second"); // execute after first and go to final
+ *   });
+ *
+ *   get("/", (req, rsp) {@literal ->} {
+ *     rsp.send("final"); // done!
+ *   });
+ * 
+ * + * Please note first and second routes are converted to a filter, so previous example is the same + * as: + * + *
+ *   get("/", (req, rsp, chain) {@literal ->} {
+ *     log.info("first"); // start here and go to second
+ *     chain.next(req, rsp);
+ *   });
+ *
+ *   get("/", (req, rsp, chain) {@literal ->} {
+ *     log.info("second"); // execute after first and go to final
+ *     chain.next(req, rsp);
+ *   });
+ *
+ *   get("/", (req, rsp) {@literal ->} {
+ *     rsp.send("final"); // done!
+ *   });
+ * 
+ * + *

script route

+ *

+ * A script route can be defined using Lambda expressions, like: + *

+ * + *
+ *   get("/", (request, response) {@literal ->} {
+ *     response.send("Hello Jooby");
+ *   });
+ * 
+ * + * Due to the use of lambdas a route is a singleton and you should NOT use global variables. + * For example this is a bad practice: + * + *
+ *  List{@literal <}String{@literal >} names = new ArrayList{@literal <>}(); // names produces side effects
+ *  get("/", (req, rsp) {@literal ->} {
+ *     names.add(req.param("name").value();
+ *     // response will be different between calls.
+ *     rsp.send(names);
+ *   });
+ * 
+ * + *

mvc Route

+ *

+ * A Mvc Route use annotations to define routes: + *

+ * + *
+ * {
+ *   use(MyRoute.class);
+ * }
+ * 
+ * + * MyRoute.java: + *
+ *   {@literal @}Path("/")
+ *   public class MyRoute {
+ *
+ *    {@literal @}GET
+ *    public String hello() {
+ *      return "Hello Jooby";
+ *    }
+ *   }
+ * 
+ *

+ * Programming model is quite similar to JAX-RS/Jersey with some minor differences and/or + * simplifications. + *

+ * + *

+ * To learn more about Mvc Routes, please check {@link org.jooby.mvc.Path}, + * {@link org.jooby.mvc.Produces} {@link org.jooby.mvc.Consumes}. + *

+ * + * @author edgar + * @since 0.1.0 + */ +public interface Route { + + /** + * Provides useful information about where the route was defined. + * + * See {@link Definition#source()} and {@link Route#source()}. + * + * @author edgar + * @since 1.0.0.CR4 + */ + interface Source { + + /** + * There is no source information. + */ + Source BUILTIN = new Source() { + + @Override + public int line() { + return -1; + } + + @Override + public Optional declaringClass() { + return Optional.empty(); + } + + @Override + public String toString() { + return "~builtin"; + } + }; + + /** + * @return Line number where the route was defined or -1 when not available. + */ + int line(); + + /** + * @return Class where the route + */ + @Nonnull + Optional declaringClass(); + } + + /** + * Converts a route output to something else, see {@link Router#map(Mapper)}. + * + *
{@code
+   * {
+   *   // we got bar.. not foo
+   *   get("/foo", () -> "foo")
+   *       .map(value -> "bar");
+   *
+   *   // we got foo.. not bar
+   *   get("/bar", () -> "bar")
+   *       .map(value -> "foo");
+   * }
+   * }
+ * + * If you want to apply a single map to several routes: + * + *
{@code
+   * {
+   *    with(() -> {
+   *      get("/foo", () -> "foo");
+   *
+   *      get("/bar", () -> "bar");
+   *
+   *    }).map(v -> "foo or bar");
+   * }
+   * }
+ * + * You can apply a {@link Mapper} to specific return type: + * + *
{@code
+   * {
+   *    with(() -> {
+   *      get("/str", () -> "str");
+   *
+   *      get("/int", () -> 1);
+   *
+   *    }).map(String v -> "{" + v + "}");
+   * }
+   * }
+ * + * A call to /str produces {str}, while /int just + * 1. + * + * NOTE: You can apply the map operator to routes that produces an output. + * + * For example, the map operator will be silently ignored here: + * + *
{@code
+   * {
+   *    get("/", (req, rsp) -> {
+   *      rsp.send(...);
+   *    }).map(v -> ..);
+   * }
+   * }
+ * + * @author edgar + * @param Type to map. + */ + interface Mapper { + + /** + * Produces a new mapper by combining the two mapper into one. + * + * @param it The first mapper to apply. + * @param next The second mapper to apply. + * @return A new mapper. + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + @Nonnull + static Mapper chain(final Mapper it, final Mapper next) { + return create(it.name() + ">" + next.name(), v -> next.map(it.map(v))); + } + + /** + * Creates a new named mapper (just syntax suggar for creating a new mapper). + * + * @param name Mapper's name. + * @param fn Map function. + * @param Value type. + * @return A new mapper. + */ + @Nonnull + static Mapper create(final String name, final Throwing.Function fn) { + return new Route.Mapper() { + @Override + public String name() { + return name; + } + + @Override + public Object map(final T value) throws Throwable { + return fn.apply(value); + } + + @Override + public String toString() { + return name(); + } + }; + } + + /** + * @return Mapper's name. + */ + @Nonnull + default String name() { + String name = Optional.ofNullable(Strings.emptyToNull(getClass().getSimpleName())) + .orElseGet(() -> { + String classname = getClass().getName(); + return classname.substring(classname.lastIndexOf('.') + 1); + }); + return CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_HYPHEN, name); + } + + /** + * Map the type to something else. + * + * @param value Value to map. + * @return Mapped value. + * @throws Throwable If mapping fails. + */ + @Nonnull + Object map(T value) throws Throwable; + } + + /** + * Common route properties, like static and global metadata via attributes, path exclusion, + * produces and consumes types. + * + * @author edgar + * @since 1.0.0.CR + * @param Attribute subtype. + */ + interface Props> { + /** + * Set route attribute. Only primitives, string, class, enum or array of previous types are + * allowed as attributes values. + * + * @param name Attribute's name. + * @param value Attribute's value. + * @return This instance. + */ + @Nonnull + T attr(String name, Object value); + + /** + * Tell jooby what renderer should use to render the output. + * + * @param name A renderer's name. + * @return This instance. + */ + @Nonnull + T renderer(final String name); + + /** + * Explicit renderer to use or null. + * + * @return Explicit renderer to use or null. + */ + @Nullable + String renderer(); + + /** + * Set the route name. Route's name, helpful for debugging but also to implement dynamic and + * advanced routing. See {@link Route.Chain#next(String, Request, Response)} + * + * + * @param name A route's name. + * @return This instance. + */ + @Nonnull + T name(final String name); + + /** + * Set the media types the route can consume. + * + * @param consumes The media types to test for. + * @return This instance. + */ + @Nonnull + default T consumes(final MediaType... consumes) { + return consumes(Arrays.asList(consumes)); + } + + /** + * Set the media types the route can consume. + * + * @param consumes The media types to test for. + * @return This instance. + */ + @Nonnull + default T consumes(final String... consumes) { + return consumes(MediaType.valueOf(consumes)); + } + + /** + * Set the media types the route can consume. + * + * @param consumes The media types to test for. + * @return This instance. + */ + @Nonnull + T consumes(final List consumes); + + /** + * Set the media types the route can produces. + * + * @param produces The media types to test for. + * @return This instance. + */ + @Nonnull + default T produces(final MediaType... produces) { + return produces(Arrays.asList(produces)); + } + + /** + * Set the media types the route can produces. + * + * @param produces The media types to test for. + * @return This instance. + */ + @Nonnull + default T produces(final String... produces) { + return produces(MediaType.valueOf(produces)); + } + + /** + * Set the media types the route can produces. + * + * @param produces The media types to test for. + * @return This instance. + */ + @Nonnull + T produces(final List produces); + + /** + * Excludes one or more path pattern from this route, useful for filter: + * + *
+     * {
+     *   use("*", req {@literal ->} {
+     *    ...
+     *   }).excludes("/logout");
+     * }
+     * 
+ * + * @param excludes A path pattern. + * @return This instance. + */ + @Nonnull + default T excludes(final String... excludes) { + return excludes(Arrays.asList(excludes)); + } + + /** + * Excludes one or more path pattern from this route, useful for filter: + * + *
+     * {
+     *   use("*", req {@literal ->} {
+     *    ...
+     *   }).excludes("/logout");
+     * }
+     * 
+ * + * @param excludes A path pattern. + * @return This instance. + */ + @Nonnull + T excludes(final List excludes); + + @Nonnull + T map(Mapper mapper); + } + + /** + * Collection of {@link Route.Props} useful for registering/setting route options at once. + * + * See {@link Router#get(String, String, String, OneArgHandler)} and variants. + * + * @author edgar + * @since 0.5.0 + */ + @SuppressWarnings({"unchecked", "rawtypes"}) class Collection implements Props { + + /** List of definitions. */ + private final Route.Props[] routes; + + /** + * Creates a new collection of route definitions. + * + * @param definitions Collection of route definitions. + */ + public Collection(final Route.Props... definitions) { + this.routes = requireNonNull(definitions, "Route definitions are required."); + } + + @Override + public Collection name(final String name) { + for (Props definition : routes) { + definition.name(name); + } + return this; + } + + @Override + public String renderer() { + return routes[0].renderer(); + } + + @Override + public Collection renderer(final String name) { + for (Props definition : routes) { + definition.renderer(name); + } + return this; + } + + @Override + public Collection consumes(final List types) { + for (Props definition : routes) { + definition.consumes(types); + } + return this; + } + + @Override + public Collection produces(final List types) { + for (Props definition : routes) { + definition.produces(types); + } + return this; + } + + @Override + public Collection attr(final String name, final Object value) { + for (Props definition : routes) { + definition.attr(name, value); + } + return this; + } + + @Override + public Collection excludes(final List excludes) { + for (Props definition : routes) { + definition.excludes(excludes); + } + return this; + } + + @Override + public Collection map(final Mapper mapper) { + for (Props route : routes) { + route.map(mapper); + } + return this; + } + } + + /** + * DSL for customize routes. + * + *

+ * Some examples: + *

+ * + *
+   *   public class MyApp extends Jooby {
+   *     {
+   *        get("/", () {@literal ->} "GET");
+   *
+   *        post("/", req {@literal ->} "POST");
+   *
+   *        put("/", (req, rsp) {@literal ->} rsp.send("PUT"));
+   *     }
+   *   }
+   * 
+ * + *

Setting what a route can consumes

+ * + *
+   *   public class MyApp extends Jooby {
+   *     {
+   *        post("/", (req, resp) {@literal ->} resp.send("POST"))
+   *          .consumes(MediaType.json);
+   *     }
+   *   }
+   * 
+ * + *

Setting what a route can produces

+ * + *
+   *   public class MyApp extends Jooby {
+   *     {
+   *        post("/", (req, resp) {@literal ->} resp.send("POST"))
+   *          .produces(MediaType.json);
+   *     }
+   *   }
+   * 
+ * + *

Adding a name

+ * + *
+   *   public class MyApp extends Jooby {
+   *     {
+   *        post("/", (req, resp) {@literal ->} resp.send("POST"))
+   *          .name("My Root");
+   *     }
+   *   }
+   * 
+ * + * @author edgar + * @since 0.1.0 + */ + class Definition implements Props { + + /** + * Route's name. + */ + private String name = "/anonymous"; + + /** + * A route pattern. + */ + private RoutePattern cpattern; + + /** + * The target route. + */ + private Filter filter; + + /** + * Defines the media types that the methods of a resource class or can accept. Default is: + * {@code *}/{@code *}. + */ + private List consumes = MediaType.ALL; + + /** + * Defines the media types that the methods of a resource class or can produces. Default is: + * {@code *}/{@code *}. + */ + private List produces = MediaType.ALL; + + /** + * A HTTP verb or *. + */ + private String method; + + /** + * A path pattern. + */ + private String pattern; + + private List excludes = Collections.emptyList(); + + private Map attributes = ImmutableMap.of(); + + private Mapper mapper; + + private int line; + + private String declaringClass; + + String prefix; + + private String renderer; + + /** + * Creates a new route definition. + * + * @param verb A HTTP verb or *. + * @param pattern A path pattern. + * @param handler A route handler. + */ + public Definition(final String verb, final String pattern, + final Route.Handler handler) { + this(verb, pattern, (Route.Filter) handler); + } + + /** + * Creates a new route definition. + * + * @param verb A HTTP verb or *. + * @param pattern A path pattern. + * @param handler A route handler. + * @param caseSensitiveRouting Configure case for routing algorithm. + */ + public Definition(final String verb, final String pattern, + final Route.Handler handler, boolean caseSensitiveRouting) { + this(verb, pattern, (Route.Filter) handler, caseSensitiveRouting); + } + + /** + * Creates a new route definition. + * + * @param verb A HTTP verb or *. + * @param pattern A path pattern. + * @param handler A route handler. + */ + public Definition(final String verb, final String pattern, + final Route.OneArgHandler handler) { + this(verb, pattern, (Route.Filter) handler); + } + + /** + * Creates a new route definition. + * + * @param verb A HTTP verb or *. + * @param pattern A path pattern. + * @param handler A route handler. + */ + public Definition(final String verb, final String pattern, + final Route.ZeroArgHandler handler) { + this(verb, pattern, (Route.Filter) handler); + } + + /** + * Creates a new route definition. + * + * @param method A HTTP verb or *. + * @param pattern A path pattern. + * @param filter A callback to execute. + */ + public Definition(final String method, final String pattern, final Filter filter) { + this(method, pattern, filter, true); + } + + /** + * Creates a new route definition. + * + * @param method A HTTP verb or *. + * @param pattern A path pattern. + * @param filter A callback to execute. + * @param caseSensitiveRouting Configure case for routing algorithm. + */ + public Definition(final String method, final String pattern, + final Filter filter, boolean caseSensitiveRouting) { + requireNonNull(pattern, "A route path is required."); + requireNonNull(filter, "A filter is required."); + + this.method = method.toUpperCase(); + this.cpattern = new RoutePattern(method, pattern, !caseSensitiveRouting); + // normalized pattern + this.pattern = cpattern.pattern(); + this.filter = filter; + SourceProvider.INSTANCE.get().ifPresent(source -> { + this.line = source.getLineNumber(); + this.declaringClass = source.getClassName(); + }); + } + + /** + *

Path Patterns

+ *

+ * Jooby supports Ant-style path patterns: + *

+ *

+ * Some examples: + *

+ *
    + *
  • {@code com/t?st.html} - matches {@code com/test.html} but also {@code com/tast.jsp} or + * {@code com/txst.html}
  • + *
  • {@code com/*.html} - matches all {@code .html} files in the {@code com} directory
  • + *
  • com/{@literal **}/test.html - matches all {@code test.html} files underneath + * the {@code com} path
  • + *
  • {@code **}/{@code *} - matches any path at any level.
  • + *
  • {@code *} - matches any path at any level, shorthand for {@code **}/{@code *}.
  • + *
+ * + *

Variables

+ *

+ * Jooby supports path parameters too: + *

+ *

+ * Some examples: + *

+ *
    + *
  • /user/{id} - /user/* and give you access to the id var.
  • + *
  • /user/:id - /user/* and give you access to the id var.
  • + *
  • /user/{id:\\d+} - /user/[digits] and give you access to the numeric + * id var.
  • + *
+ * + * @return A path pattern. + */ + @Nonnull + public String pattern() { + return pattern; + } + + @Nullable + public String renderer() { + return renderer; + } + + @Override + public Definition renderer(final String name) { + this.renderer = name; + return this; + } + + /** + * @return List of path variables (if any). + */ + @Nonnull + public List vars() { + return cpattern.vars(); + } + + /** + * Indicates if the {@link #pattern()} contains a glob charecter, like ?, + * * or **. + * + * @return Indicates if the {@link #pattern()} contains a glob charecter, like ?, + * * or **. + */ + @Nonnull + public boolean glob() { + return cpattern.glob(); + } + + /** + * Source information (where the route was defined). + * + * @return Source information (where the route was defined). + */ + @Nonnull + public Route.Source source() { + return new RouteSourceImpl(declaringClass, line); + } + + /** + * Recreate a route path and apply the given variables. + * + * @param vars Path variables. + * @return A route pattern. + */ + @Nonnull + public String reverse(final Map vars) { + return cpattern.reverse(vars); + } + + /** + * Recreate a route path and apply the given variables. + * + * @param values Path variable values. + * @return A route pattern. + */ + @Nonnull + public String reverse(final Object... values) { + return cpattern.reverse(values); + } + + @Override + @Nonnull + public Definition attr(final String name, final Object value) { + requireNonNull(name, "Attribute name is required."); + requireNonNull(value, "Attribute value is required."); + + if (valid(value)) { + attributes = ImmutableMap.builder() + .putAll(attributes) + .put(name, value) + .build(); + } + return this; + } + + private boolean valid(final Object value) { + if (Primitives.isWrapperType(Primitives.wrap(value.getClass()))) { + return true; + } + if (value instanceof String || value instanceof Enum || value instanceof Class) { + return true; + } + if (value.getClass().isArray() && Array.getLength(value) > 0) { + return valid(Array.get(value, 0)); + } + if (value instanceof Map && ((Map) value).size() > 0) { + Map.Entry e = (Map.Entry) ((Map) value).entrySet().iterator().next(); + return valid(e.getKey()) && valid(e.getValue()); + } + return false; + } + + /** + * Get an attribute by name. + * + * @param name Attribute's name. + * @param Attribute's type. + * @return Attribute's value or null. + */ + @SuppressWarnings("unchecked") + @Nonnull + public T attr(final String name) { + return (T) attributes.get(name); + } + + /** + * @return A read only view of attributes. + */ + @Nonnull + public Map attributes() { + return attributes; + } + + /** + * Test if the route matches the given verb, path, content type and accept header. + * + * @param method A HTTP verb. + * @param path Current HTTP path. + * @param contentType The Content-Type header. + * @param accept The Accept header. + * @return A route or an empty optional. + */ + @Nonnull + public Optional matches(final String method, + final String path, final MediaType contentType, + final List accept) { + String fpath = method + path; + if (excludes.size() > 0 && excludes(fpath)) { + return Optional.empty(); + } + RouteMatcher matcher = cpattern.matcher(fpath); + if (matcher.matches()) { + List result = MediaType.matcher(accept).filter(this.produces); + if (result.size() > 0 && canConsume(contentType)) { + // keep accept when */* + List produces = result.size() == 1 && result.get(0).name().equals("*/*") + ? accept : this.produces; + return Optional + .of(asRoute(method, matcher, produces, new RouteSourceImpl(declaringClass, line))); + } + } + return Optional.empty(); + } + + /** + * @return HTTP method or *. + */ + @Nonnull + public String method() { + return method; + } + + /** + * @return Handler behind this route. + */ + @Nonnull + public Route.Filter filter() { + return filter; + } + + /** + * Route's name, helpful for debugging but also to implement dynamic and advanced routing. See + * {@link Route.Chain#next(String, Request, Response)} + * + * @return Route name. Default is: anonymous. + */ + @Nonnull + public String name() { + return name; + } + + /** + * Set the route name. Route's name, helpful for debugging but also to implement dynamic and + * advanced routing. See {@link Route.Chain#next(String, Request, Response)} + * + * + * @param name A route's name. + * @return This definition. + */ + @Override + @Nonnull + public Definition name(final String name) { + checkArgument(!Strings.isNullOrEmpty(name), "A route's name is required."); + this.name = normalize(prefix != null ? prefix + "/" + name : name); + return this; + } + + /** + * Test if the route definition can consume a media type. + * + * @param type A media type to test. + * @return True, if the route can consume the given media type. + */ + public boolean canConsume(final MediaType type) { + return MediaType.matcher(Arrays.asList(type)).matches(consumes); + } + + /** + * Test if the route definition can consume a media type. + * + * @param type A media type to test. + * @return True, if the route can consume the given media type. + */ + public boolean canConsume(final String type) { + return MediaType.matcher(MediaType.valueOf(type)).matches(consumes); + } + + /** + * Test if the route definition can consume a media type. + * + * @param types A media types to test. + * @return True, if the route can produces the given media type. + */ + public boolean canProduce(final List types) { + return MediaType.matcher(types).matches(produces); + } + + /** + * Test if the route definition can consume a media type. + * + * @param types A media types to test. + * @return True, if the route can produces the given media type. + */ + public boolean canProduce(final MediaType... types) { + return canProduce(Arrays.asList(types)); + } + + /** + * Test if the route definition can consume a media type. + * + * @param types A media types to test. + * @return True, if the route can produces the given media type. + */ + public boolean canProduce(final String... types) { + return canProduce(MediaType.valueOf(types)); + } + + @Override + public Definition consumes(final List types) { + checkArgument(types != null && types.size() > 0, "Consumes types are required"); + if (types.size() > 1) { + this.consumes = Lists.newLinkedList(types); + Collections.sort(this.consumes); + } else { + this.consumes = ImmutableList.of(types.get(0)); + } + return this; + } + + @Override + public Definition produces(final List types) { + checkArgument(types != null && types.size() > 0, "Produces types are required"); + if (types.size() > 1) { + this.produces = Lists.newLinkedList(types); + Collections.sort(this.produces); + } else { + this.produces = ImmutableList.of(types.get(0)); + } + return this; + } + + @Override + public Definition excludes(final List excludes) { + this.excludes = excludes.stream() + .map(it -> new RoutePattern(method, it)) + .collect(Collectors.toList()); + return this; + } + + /** + * @return List of exclusion filters (if any). + */ + @Nonnull + public List excludes() { + return excludes.stream().map(r -> r.pattern()).collect(Collectors.toList()); + } + + private boolean excludes(final String path) { + for (RoutePattern pattern : excludes) { + if (pattern.matcher(path).matches()) { + return true; + } + } + return false; + } + + /** + * @return All the types this route can consumes. + */ + @Nonnull + public List consumes() { + return Collections.unmodifiableList(this.consumes); + } + + /** + * @return All the types this route can produces. + */ + @Nonnull + public List produces() { + return Collections.unmodifiableList(this.produces); + } + + @Override + @Nonnull + public Definition map(final Mapper mapper) { + this.mapper = requireNonNull(mapper, "Mapper is required."); + return this; + } + + /** + * Set the line where this route is defined. + * + * @param line Line number. + * @return This instance. + */ + @Nonnull + public Definition line(final int line) { + this.line = line; + return this; + } + + /** + * Set the class where this route is defined. + * + * @param declaringClass A source class. + * @return This instance. + */ + @Nonnull + public Definition declaringClass(final String declaringClass) { + this.declaringClass = declaringClass; + return this; + } + + @Override + public String toString() { + StringBuilder buffer = new StringBuilder(); + buffer.append(method()).append(" ").append(pattern()).append("\n"); + buffer.append(" name: ").append(name()).append("\n"); + buffer.append(" excludes: ").append(excludes).append("\n"); + buffer.append(" consumes: ").append(consumes()).append("\n"); + buffer.append(" produces: ").append(produces()).append("\n"); + return buffer.toString(); + } + + /** + * Creates a new route. + * + * @param method A HTTP verb. + * @param matcher A route matcher. + * @param produces List of produces types. + * @param source Route source. + * @return A new route. + */ + private Route asRoute(final String method, final RouteMatcher matcher, + final List produces, final Route.Source source) { + return new RouteImpl(filter, this, method, matcher.path(), produces, + matcher.vars(), mapper, source); + } + + } + + /** + * A forwarding route. + * + * @author edgar + * @since 0.1.0 + */ + class Forwarding implements Route { + + /** + * Target route. + */ + private final Route route; + + /** + * Creates a new {@link Forwarding} route. + * + * @param route A target route. + */ + public Forwarding(final Route route) { + this.route = route; + } + + @Override + public String renderer() { + return route.renderer(); + } + + @Override + public String path() { + return route.path(); + } + + @Override + public String method() { + return route.method(); + } + + @Override + public String pattern() { + return route.pattern(); + } + + @Override + public String name() { + return route.name(); + } + + @Override + public Map vars() { + return route.vars(); + } + + @Override + public List consumes() { + return route.consumes(); + } + + @Override + public List produces() { + return route.produces(); + } + + @Override + public Map attributes() { + return route.attributes(); + } + + @Override + public T attr(final String name) { + return route.attr(name); + } + + @Override + public boolean glob() { + return route.glob(); + } + + @Override + public String reverse(final Map vars) { + return route.reverse(vars); + } + + @Override + public String reverse(final Object... values) { + return route.reverse(values); + } + + @Override + public Source source() { + return route.source(); + } + + @Override + public String print() { + return route.print(); + } + + @Override + public String print(final int indent) { + return route.print(indent); + } + + @Override + public String toString() { + return route.toString(); + } + + /** + * Find a target route. + * + * @param route A route to check. + * @return A target route. + */ + public static Route unwrap(final Route route) { + Route root = route; + while (root instanceof Forwarding) { + root = ((Forwarding) root).route; + } + return root; + } + + } + + /** + * The most advanced route handler which let you decided if the next route handler in the chain + * can be executed or not. Example of filters are: + * + *

+ * Auth handler example: + *

+ * + *
+   *   String token = req.header("token").value();
+   *   if (token != null) {
+   *     // validate token...
+   *     if (valid(token)) {
+   *       chain.next(req, rsp);
+   *     }
+   *   } else {
+   *     rsp.status(403);
+   *   }
+   * 
+ * + *

+ * Logging/Around handler example: + *

+ * + *
+   *   long start = System.currentTimeMillis();
+   *   chain.next(req, rsp);
+   *   long end = System.currentTimeMillis();
+   *   log.info("Request: {} took {}ms", req.path(), end - start);
+   * 
+ * + * NOTE: Don't forget to call {@link Route.Chain#next(Request, Response)} if next route handler + * need to be executed. + * + * @author edgar + * @since 0.1.0 + */ + public interface Filter { + + /** + * The handle method of the Filter is called by the server each time a + * request/response pair is passed through the chain due to a client request for a resource at + * the end of the chain. + * The {@link Route.Chain} passed in to this method allows the Filter to pass on the request and + * response to the next entity in the chain. + * + *

+ * A typical implementation of this method would follow the following pattern: + *

+ *
    + *
  • Examine the request
  • + *
  • Optionally wrap the request object with a custom implementation to filter content or + * headers for input filtering
  • + *
  • Optionally wrap the response object with a custom implementation to filter content or + * headers for output filtering
  • + *
  • + *
      + *
    • Either invoke the next entity in the chain using the {@link Route.Chain} + * object (chain.next(req, rsp)),
    • + *
    • or not pass on the request/response pair to the next entity in the + * filter chain to block the request processing
    • + *
    + *
  • Directly set headers on the response after invocation of the next entity in the filter + * chain.
  • + *
+ * + * @param req A HTTP request. + * @param rsp A HTTP response. + * @param chain A route chain. + * @throws Throwable If something goes wrong. + */ + void handle(Request req, Response rsp, Route.Chain chain) throws Throwable; + } + + /** + * Allow to customize an asset handler. + * + * @author edgar + */ + class AssetDefinition extends Definition { + + private Boolean etag; + + private String cdn; + + private Object maxAge; + + private Boolean lastModifiedSince; + + private Integer statusCode; + + /** + * Creates a new route definition. + * + * @param method A HTTP verb or *. + * @param pattern A path pattern. + * @param handler A callback to execute. + * @param caseSensitiveRouting Configure case for routing algorithm. + */ + public AssetDefinition(final String method, final String pattern, + final Route.Filter handler, boolean caseSensitiveRouting) { + super(method, pattern, handler, caseSensitiveRouting); + filter().setRoute(this); + } + + @Nonnull + @Override + public AssetHandler filter() { + return (AssetHandler) super.filter(); + } + + /** + * Indicates what to do when an asset is missing (not resolved). Default action is to resolve them + * as 404 (NOT FOUND) request. + * + * If you specify a status code <= 0, missing assets are ignored and the next handler on pipeline + * will be executed. + * + * @param statusCode HTTP code or 0. + * @return This route definition. + */ + public AssetDefinition onMissing(final int statusCode) { + if (this.statusCode == null) { + filter().onMissing(statusCode); + this.statusCode = statusCode; + } + return this; + } + + /** + * @param etag Turn on/off etag support. + * @return This route definition. + */ + public AssetDefinition etag(final boolean etag) { + if (this.etag == null) { + filter().etag(etag); + this.etag = etag; + } + return this; + } + + /** + * @param enabled Turn on/off last modified support. + * @return This route definition. + */ + public AssetDefinition lastModified(final boolean enabled) { + if (this.lastModifiedSince == null) { + filter().lastModified(enabled); + this.lastModifiedSince = enabled; + } + return this; + } + + /** + * @param cdn If set, every resolved asset will be serve from it. + * @return This route definition. + */ + public AssetDefinition cdn(final String cdn) { + if (this.cdn == null) { + filter().cdn(cdn); + this.cdn = cdn; + } + return this; + } + + /** + * @param maxAge Set the cache header max-age value. + * @return This route definition. + */ + public AssetDefinition maxAge(final Duration maxAge) { + if (this.maxAge == null) { + filter().maxAge(maxAge); + this.maxAge = maxAge; + } + return this; + } + + /** + * @param maxAge Set the cache header max-age value in seconds. + * @return This route definition. + */ + public AssetDefinition maxAge(final long maxAge) { + if (this.maxAge == null) { + filter().maxAge(maxAge); + this.maxAge = maxAge; + } + return this; + } + + /** + * Parse value as {@link Duration}. If the value is already a number then it uses as seconds. + * Otherwise, it parse expressions like: 8m, 1h, 365d, etc... + * + * @param maxAge Set the cache header max-age value in seconds. + * @return This route definition. + */ + public AssetDefinition maxAge(final String maxAge) { + if (this.maxAge == null) { + filter().maxAge(maxAge); + this.maxAge = maxAge; + } + return this; + } + } + + /** + * A route handler that always call {@link Chain#next(Request, Response)}. + * + *
+   * public class MyApp extends Jooby {
+   *   {
+   *      get("/", (req, rsp) {@literal ->} rsp.send("Hello"));
+   *   }
+   * }
+   * 
+ * + * @author edgar + * @since 0.1.0 + */ + interface Handler extends Filter { + + @Override + default void handle(final Request req, final Response rsp, final Route.Chain chain) + throws Throwable { + handle(req, rsp); + chain.next(req, rsp); + } + + /** + * Callback method for a HTTP request. + * + * @param req A HTTP request. + * @param rsp A HTTP response. + * @throws Throwable If something goes wrong. The exception will processed by Jooby. + */ + void handle(Request req, Response rsp) throws Throwable; + + } + + /** + * A handler for a MVC route, it extends {@link Handler} by adding a reference to the method + * and class behind this route. + * + * @author edgar + * @since 0.6.2 + */ + interface MethodHandler extends Handler { + /** + * Target method. + * + * @return Target method. + */ + @Nonnull + Method method(); + + /** + * Target class. + * + * @return Target class. + */ + @Nonnull + Class implementingClass(); + } + + /** + * A functional route handler that use the return value as HTTP response. + * + *
+   *   {
+   *      get("/",(req {@literal ->} "Hello");
+   *   }
+   * 
+ * + * @author edgar + * @since 0.1.1 + */ + interface OneArgHandler extends Filter { + + @Override + default void handle(final Request req, final Response rsp, final Route.Chain chain) + throws Throwable { + Object result = handle(req); + rsp.send(result); + chain.next(req, rsp); + } + + /** + * Callback method for a HTTP request. + * + * @param req A HTTP request. + * @return Message to send. + * @throws Throwable If something goes wrong. The exception will processed by Jooby. + */ + Object handle(Request req) throws Throwable; + } + + /** + * A functional handler that use the return value as HTTP response. + * + *
+   * public class MyApp extends Jooby {
+   *   {
+   *      get("/", () {@literal ->} "Hello");
+   *   }
+   * }
+   * 
+ * + * @author edgar + * @since 0.1.1 + */ + interface ZeroArgHandler extends Filter { + + @Override + default void handle(final Request req, final Response rsp, final Route.Chain chain) + throws Throwable { + Object result = handle(); + rsp.send(result); + chain.next(req, rsp); + } + + /** + * Callback method for a HTTP request. + * + * @return Message to send. + * @throws Throwable If something goes wrong. The exception will processed by Jooby. + */ + Object handle() throws Throwable; + } + + /** + *

before

+ * + * Allows for customized handler execution chains. It will be invoked before the actual handler. + * + *
{@code
+   * {
+   *   before((req, rsp) -> {
+   *     // your code goes here
+   *   });
+   * }
+   * }
+ * + * You are allowed to modify the request and response objects. + * + * Please note that the before handler is just syntax sugar for {@link Route.Filter}. + * For example, the before handler was implemented as: + * + *
{@code
+   * {
+   *   use("*", "*", (req, rsp, chain) -> {
+   *     before(req, rsp);
+   *     // your code goes here
+   *     chain.next(req, rsp);
+   *   });
+   * }
+   * }
+ * + * A before handler must to be registered before the actual handler you want to + * intercept. + * + *
{@code
+   * {
+   *   before((req, rsp) -> {
+   *     // your code goes here
+   *   });
+   *
+   *   get("/path", req -> {
+   *     // your code goes here
+   *     return ...;
+   *   });
+   * }
+   * }
+ * + * If you reverse the order then it won't work. + * + *

+ * Remember: routes are executed in the order they are defined and the pipeline + * is executed as long you don't generate a response. + *

+ * + * @author edgar + * @since 1.0.0.CR + */ + interface Before extends Route.Filter { + + @Override + default void handle(final Request req, final Response rsp, final Chain chain) throws Throwable { + handle(req, rsp); + chain.next(req, rsp); + } + + /** + * Allows for customized handler execution chains. It will be invoked before the actual handler. + * + * @param req Request. + * @param rsp Response + * @throws Throwable If something goes wrong. + */ + void handle(Request req, Response rsp) throws Throwable; + } + + /** + *

after

+ * + * Allows for customized response before send it. It will be invoked at the time a response need + * to be send. + * + *
{@code
+   * {
+   *   after("GET", "*", (req, rsp, result) -> {
+   *     // your code goes here
+   *     return result;
+   *   });
+   * }
+   * }
+ * + * You are allowed to modify the request, response and result objects. The handler returns a + * {@link Result} which can be the same or an entirely new {@link Result}. + * + * Please note that the after handler is just syntax sugar for + * {@link Route.Filter}. + * For example, the after handler was implemented as: + * + *
{@code
+   * {
+   *   use("GET", "*", (req, rsp, chain) -> {
+   *     chain.next(req, new Response.Forwarding(rsp) {
+   *       public void send(Result result) {
+   *         rsp.send(after(req, rsp, result);
+   *       }
+   *     });
+   *   });
+   * }
+   * }
+ * + * Due after is implemented by wrapping the {@link Response} object. A + * after handler must to be registered before the actual handler you want to + * intercept. + * + *
{@code
+   * {
+   *   after("GET", "/path", (req, rsp, result) -> {
+   *     // your code goes here
+   *     return result;
+   *   });
+   *
+   *   get("/path", req -> {
+   *     return "hello";
+   *   });
+   * }
+   * }
+ * + * If you reverse the order then it won't work. + * + *

+ * Remember: routes are executed in the order they are defined and the pipeline + * is executed as long you don't generate a response. + *

+ * + * @author edgar + * @since 1.0.0.CR + */ + interface After extends Filter { + @Override + default void handle(final Request req, final Response rsp, final Chain chain) throws Throwable { + rsp.after(this); + chain.next(req, rsp); + } + + /** + * Allows for customized response before send it. It will be invoked at the time a response need + * to be send. + * + * @param req Request. + * @param rsp Response + * @param result Result. + * @return Same or new result. + * @throws Exception If something goes wrong. + */ + Result handle(Request req, Response rsp, Result result) throws Exception; + } + + /** + *

complete

+ * + * Allows for log and cleanup a request. It will be invoked after we send a response. + * + *
{@code
+   * {
+   *   complete((req, rsp, cause) -> {
+   *     // your code goes here
+   *   });
+   * }
+   * }
+ * + * You are NOT allowed to modify the request and response objects. The cause is an + * {@link Optional} with a {@link Throwable} useful to identify problems. + * + * The goal of the complete handler is to probably cleanup request object and log + * responses. + * + * Please note that the complete handler is just syntax sugar for + * {@link Route.Filter}. + * For example, the complete handler was implemented as: + * + *
{@code
+   * {
+   *   use("*", "*", (req, rsp, chain) -> {
+   *     Optional err = Optional.empty();
+   *     try {
+   *       chain.next(req, rsp);
+   *     } catch (Throwable cause) {
+   *       err = Optional.of(cause);
+   *     } finally {
+   *       complete(req, rsp, err);
+   *     }
+   *   });
+   * }
+   * }
+ * + * An complete handler must to be registered before the actual handler you want to + * intercept. + * + *
{@code
+   * {
+   *   complete((req, rsp, cause) -> {
+   *     // your code goes here
+   *   });
+   *
+   *   get(req -> {
+   *     return "hello";
+   *   });
+   * }
+   * }
+ * + * If you reverse the order then it won't work. + * + *

+ * Remember: routes are executed in the order they are defined and the pipeline + * is executed as long you don't generate a response. + *

+ * + *

example

+ *

+ * Suppose you have a transactional resource, like a database connection. The next example shows + * you how to implement a simple and effective transaction-per-request pattern: + *

+ * + *
{@code
+   * {
+   *   // start transaction
+   *   before((req, rsp) -> {
+   *     DataSource ds = req.require(DataSource.class);
+   *     Connection connection = ds.getConnection();
+   *     Transaction trx = connection.getTransaction();
+   *     trx.begin();
+   *     req.set("connection", connection);
+   *     return true;
+   *   });
+   *
+   *   // commit/rollback transaction
+   *   complete((req, rsp, cause) -> {
+   *     // unbind connection from request
+   *     try(Connection connection = req.unset("connection").get()) {
+   *       Transaction trx = connection.getTransaction();
+   *       if (cause.ifPresent()) {
+   *         trx.rollback();
+   *       } else {
+   *         trx.commit();
+   *       }
+   *     }
+   *   });
+   *
+   *   // your transactional routes goes here
+   *   get("/api/something", req -> {
+   *     Connection connection = req.get("connection");
+   *     // work with connection
+   *   });
+   * }
+   * }
+ * + * @author edgar + * @since 1.0.0.CR + */ + interface Complete extends Filter { + + @Override + default void handle(final Request req, final Response rsp, final Chain chain) throws Throwable { + rsp.complete(this); + chain.next(req, rsp); + } + + /** + * Allows for log and cleanup a request. It will be invoked after we send a response. + * + * @param req Request. + * @param rsp Response + * @param cause Empty optional on success. Otherwise, it contains the exception. + */ + void handle(Request req, Response rsp, Optional cause); + } + + /** + * Chain of routes to be executed. It invokes the next route in the chain. + * + * @author edgar + * @since 0.1.0 + */ + interface Chain { + /** + * Invokes the next route in the chain where {@link Route#name()} starts with the given prefix. + * + * @param prefix Iterates over the route chain and keep routes that start with the given prefix. + * @param req A HTTP request. + * @param rsp A HTTP response. + * @throws Throwable If invocation goes wrong. + */ + void next(@Nullable String prefix, Request req, Response rsp) throws Throwable; + + /** + * Invokes the next route in the chain. + * + * @param req A HTTP request. + * @param rsp A HTTP response. + * @throws Throwable If invocation goes wrong. + */ + default void next(final Request req, final Response rsp) throws Throwable { + next(null, req, rsp); + } + + /** + * All the pending/next routes from pipeline. Example: + * + *
{@code
+     *   use("*", (req, rsp, chain) -> {
+     *     List routes = chain.routes();
+     *     assertEquals(2, routes.size());
+     *     assertEquals("/r2", routes.get(0).name());
+     *     assertEquals("/r3", routes.get(1).name());
+     *     assertEquals("/786/:id", routes.get(routes.size() - 1).pattern());
+     *
+     *     chain.next(req, rsp);
+     *   }).name("r1");
+     *
+     *   use("/786/**", (req, rsp, chain) -> {
+     *     List routes = chain.routes();
+     *     assertEquals(1, routes.size());
+     *     assertEquals("/r3", routes.get(0).name());
+     *     assertEquals("/786/:id", routes.get(routes.size() - 1).pattern());
+     *     chain.next(req, rsp);
+     *   }).name("r2");
+     *
+     *   get("/786/:id", req -> {
+     *     return req.param("id").value();
+     *   }).name("r3");
+     * }
+ * + * @return Next routes in the pipeline or empty list. + */ + List routes(); + } + + /** Route key. */ + Key> KEY = Key.get(new TypeLiteral>() { + }); + + char OUT_OF_PATH = '\u200B'; + + String GET = "GET"; + + String POST = "POST"; + + String PUT = "PUT"; + + String DELETE = "DELETE"; + + String PATCH = "PATCH"; + + String HEAD = "HEAD"; + + String CONNECT = "CONNECT"; + + String OPTIONS = "OPTIONS"; + + String TRACE = "TRACE"; + + /** + * Well known HTTP methods. + */ + List METHODS = ImmutableList.builder() + .add(GET, + POST, + PUT, + DELETE, + PATCH, + HEAD, + CONNECT, + OPTIONS, + TRACE) + .build(); + + /** + * @return Current request path. + */ + @Nonnull + String path(); + + /** + * @return Current HTTP method. + */ + @Nonnull + String method(); + + /** + * @return The currently matched pattern. + */ + @Nonnull + String pattern(); + + /** + * Route's name, helpful for debugging but also to implement dynamic and advanced routing. See + * {@link Route.Chain#next(String, Request, Response)} + * + * @return Route name, defaults to "anonymous" + */ + @Nonnull + String name(); + + /** + * Path variables, either named or by index (capturing group). + * + *
+   *   /path/:var
+   * 
+ * + * Variable var is accessible by name: var or index: 0. + * + * @return The currently matched path variables (if any). + */ + @Nonnull + Map vars(); + + /** + * @return List all the types this route can consumes, defaults is: {@code * / *}. + */ + @Nonnull + List consumes(); + + /** + * @return List all the types this route can produces, defaults is: {@code * / *}. + */ + @Nonnull + List produces(); + + /** + * True, when route's name starts with the given prefix. Useful for dynamic routing. See + * {@link Route.Chain#next(String, Request, Response)} + * + * @param prefix Prefix to check for. + * @return True, when route's name starts with the given prefix. + */ + default boolean apply(final String prefix) { + return name().startsWith(prefix); + } + + /** + * @return All the available attributes in the execution chain. + */ + @Nonnull + Map attributes(); + + /** + * Attribute by name. + * + * @param name Attribute's name. + * @param Attribute's type. + * @return Attribute value. + */ + @SuppressWarnings("unchecked") + @Nonnull + default T attr(final String name) { + return (T) attributes().get(name); + } + + /** + * Explicit renderer to use or null. + * + * @return Explicit renderer to use or null. + */ + @Nonnull + String renderer(); + + /** + * Indicates if the {@link #pattern()} contains a glob character, like ?, + * * or **. + * + * @return Indicates if the {@link #pattern()} contains a glob charecter, like ?, + * * or **. + */ + boolean glob(); + + /** + * Recreate a route path and apply the given variables. + * + * @param vars Path variables. + * @return A route pattern. + */ + @Nonnull + String reverse(final Map vars); + + /** + * Recreate a route path and apply the given variables. + * + * @param values Path variable values. + * @return A route pattern. + */ + @Nonnull + String reverse(final Object... values); + + /** + * Normalize a path by removing double or trailing slashes. + * + * @param path A path to normalize. + * @return A normalized path. + */ + @Nonnull + static String normalize(final String path) { + return RoutePattern.normalize(path); + } + + /** + * Remove invalid path mark when present. + * + * @param path Path. + * @return Original path. + */ + @Nonnull + static String unerrpath(final String path) { + if (path.charAt(0) == OUT_OF_PATH) { + return path.substring(1); + } + return path; + } + + /** + * Mark a path as invalid. + * + * @param path Path. + * @return Invalid path. + */ + @Nonnull + static String errpath(final String path) { + return OUT_OF_PATH + path; + } + + /** + * Source information (where the route was defined). + * + * @return Source information (where the route was defined). + */ + @Nonnull + Route.Source source(); + + /** + * Print route information like: method, path, source, etc... Useful for debugging. + * + * @param indent Indent level + * @return Output. + */ + @Nonnull + default String print(final int indent) { + StringBuilder buff = new StringBuilder(); + String[] header = {"Method", "Path", "Source", "Name", "Pattern", "Consumes", "Produces"}; + String[] values = {method(), path(), source().toString(), name(), pattern(), + consumes().toString(), produces().toString()}; + + BiConsumer, Character> format = (v, s) -> { + buff.append(Strings.padEnd("", indent, ' ')) + .append("|").append(s); + for (int i = 0; i < header.length; i++) { + buff + .append(Strings.padEnd(v.apply(i), Math.max(header[i].length(), values[i].length()), s)) + .append(s).append("|").append(s); + } + buff.setLength(buff.length() - 1); + }; + format.accept(i -> header[i], ' '); + buff.append("\n"); + format.accept(i -> "-", '-'); + buff.append("\n"); + format.accept(i -> values[i], ' '); + return buff.toString(); + } + + /** + * Print route information like: method, path, source, etc... Useful for debugging. + * + * @return Output. + */ + @Nonnull + default String print() { + return print(0); + } +} diff --git a/jooby/src/main/java/org/jooby/Router.java b/jooby/src/main/java/org/jooby/Router.java new file mode 100644 index 00000000..72608e12 --- /dev/null +++ b/jooby/src/main/java/org/jooby/Router.java @@ -0,0 +1,3721 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import org.jooby.Route.Mapper; +import org.jooby.funzy.Try; +import org.jooby.handlers.AssetHandler; + +import javax.annotation.Nonnull; +import java.net.URLDecoder; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.Executor; +import java.util.function.Predicate; + +/** + * Route DSL. Constructs and creates several flavors of jooby routes. + * + * @author edgar + * @since 0.16.0 + */ +public interface Router { + + /** + * Decode a path by delegating to {@link URLDecoder#decode(String, String)}. + * + * @param path Path to decoded. + * @return Decode a path by delegating to {@link URLDecoder#decode(String, String)}. + */ + static String decode(String path) { + return Try.apply(() -> URLDecoder.decode(path, "UTF-8")).get(); + } + + /** + * Import content from provide application (routes, parsers/renderers, start/stop callbacks, ... + * etc.). + * + * @param app Routes provider. + * @return This router. + */ + @Nonnull + Router use(final Jooby app); + + /** + * Group one or more routes under a common path. + * + *
{@code
+   *   {
+   *     path("/api/pets", () -> {
+   *
+   *     });
+   *   }
+   * }
+ * + * @param path Common path. + * @param action Router action. + * @return This router. + */ + Route.Collection path(String path, Runnable action); + + /** + * Import content from provide application (routes, parsers/renderers, start/stop callbacks, ... + * etc.). Routes will be mounted at the provided path. + * + * @param path Path to mount the given app. + * @param app Routes provider. + * @return This router. + */ + @Nonnull + Router use(final String path, final Jooby app); + + /** + * Append a new filter that matches any method under the given path. + * + * @param path A path pattern. + * @param filter A filter to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition use(String path, Route.Filter filter); + + /** + * Append a new filter that matches the given method and path. + * + * @param method A HTTP method. + * @param path A path pattern. + * @param filter A filter to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition use(String method, String path, Route.Filter filter); + + /** + * Append a new route handler that matches the given method and path. Like + * {@link #use(String, String, org.jooby.Route.Filter)} but you don't have to explicitly call + * {@link Route.Chain#next(Request, Response)}. + * + * @param method A HTTP method. + * @param path A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition use(String method, String path, Route.Handler handler); + + /** + * Append a new route handler that matches any method under the given path. Like + * {@link #use(String, org.jooby.Route.Filter)} but you don't have to explicitly call + * {@link Route.Chain#next(Request, Response)}. + * + * @param path A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition use(String path, Route.Handler handler); + + /** + * Append a new route handler that matches any method under the given path. + * + * @param path A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition use(String path, Route.OneArgHandler handler); + + /** + * Append a route that matches the HTTP GET method: + * + *
+   *   get((req, rsp) {@literal ->} {
+   *     rsp.send(something);
+   *   });
+   * 
+ * + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + default Route.Definition get(Route.Handler handler) { + return get("/", handler); + } + + /** + * Append a route that matches the HTTP GET method: + * + *
+   *   get("/", (req, rsp) {@literal ->} {
+   *     rsp.send(something);
+   *   });
+   * 
+ * + * @param path A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition get(String path, Route.Handler handler); + + /** + * Append two routes that matches the HTTP GET method on the same handler: + * + *
+   *   get("/model", "/mode/:id", (req, rsp) {@literal ->} {
+   *     rsp.send(req.param("id").toOptional(String.class));
+   *   });
+   * 
+ * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection get(String path1, String path2, Route.Handler handler); + + /** + * Append three routes that matches the HTTP GET method on the same handler: + * + *
+   *   get("/p1", "/p2", "/p3", (req, rsp) {@literal ->} {
+   *     rsp.send(req.path());
+   *   });
+   * 
+ * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param path3 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection get(String path1, String path2, String path3, Route.Handler handler); + + /** + * Append route that matches the HTTP GET method: + * + *
+   *   get(req {@literal ->} {
+   *     return "hello";
+   *   });
+   * 
+ * + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + default Route.Definition get(Route.OneArgHandler handler) { + return get("/", handler); + } + + /** + * Append route that matches the HTTP GET method: + * + *
+   *   get("/", req {@literal ->} {
+   *     return "hello";
+   *   });
+   * 
+ * + * @param path A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition get(String path, Route.OneArgHandler handler); + + /** + * Append three routes that matches the HTTP GET method on the same handler: + * + *
+   *   get("/model", "/model/:id", req {@literal ->} {
+   *     return req.param("id").toOptional(String.class);
+   *   });
+   * 
+ * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection get(String path1, String path2, Route.OneArgHandler handler); + + /** + * Append three routes that matches the HTTP GET method on the same handler: + * + *
+   *   get("/p1", "/p2", "/p3", req {@literal ->} {
+   *     return req.path();
+   *   });
+   * 
+ * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param path3 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection get(String path1, String path2, String path3, Route.OneArgHandler handler); + + /** + * Append route that matches HTTP GET method: + * + *
+   *   get(() {@literal ->}
+   *     "hello"
+   *   );
+   * 
+ * + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + default Route.Definition get(Route.ZeroArgHandler handler) { + return get("/", handler); + } + + /** + * Append route that matches HTTP GET method: + * + *
+   *   get("/", () {@literal ->}
+   *     "hello"
+   *   );
+   * 
+ * + * @param path A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition get(String path, Route.ZeroArgHandler handler); + + /** + * Append three routes that matches the HTTP GET method on the same handler: + * + *
+   *   get("/p1", "/p2", () {@literal ->} {
+   *     return "OK";
+   *   });
+   * 
+ * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection get(String path1, String path2, Route.ZeroArgHandler handler); + + /** + * Append three routes that matches HTTP GET method on the same handler: + * + *
+   *   get("/p1", "/p2", "/p3", () {@literal ->} {
+   *     return "OK";
+   *   });
+   * 
+ * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param path3 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection get(String path1, String path2, String path3, Route.ZeroArgHandler handler); + + /** + * Append a filter that matches HTTP GET method: + * + *
+   *   get("/", (req, rsp, chain) {@literal ->} {
+   *     chain.next(req, rsp);
+   *   });
+   * 
+ * + * @param path A path pattern. + * @param filter A filter to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition get(String path, Route.Filter filter); + + /** + * Append three routes that matches the HTTP GET method on the same handler: + * + *
+   *   get("/model", "/model/:id", (req, rsp, chain) {@literal ->} {
+   *     req.param("id").toOptional(String.class);
+   *     chain.next(req, rsp);
+   *   });
+   * 
+ * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param filter A filter to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection get(String path1, String path2, Route.Filter filter); + + /** + * Append three routes that supports HTTP GET method on the same handler: + * + *
+   *   get("/p1", "/p2", "/p3", (req, rsp, chain) {@literal ->} {
+   *     chain.next(req, rsp);
+   *   });
+   * 
+ * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param path3 A path pattern. + * @param filter A filter to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection get(String path1, String path2, String path3, Route.Filter filter); + + /** + * Append a route that supports HTTP POST method: + * + *
+   *   post((req, rsp) {@literal ->} {
+   *     rsp.send(something);
+   *   });
+   * 
+ * + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + default Route.Definition post(Route.Handler handler) { + return post("/", handler); + } + + /** + * Append a route that supports HTTP POST method: + * + *
+   *   post("/", (req, rsp) {@literal ->} {
+   *     rsp.send(something);
+   *   });
+   * 
+ * + * @param path A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition post(String path, Route.Handler handler); + + /** + * Append three routes that supports HTTP POST method on the same handler: + * + *
+   *   post("/p1", "/p2", (req, rsp) {@literal ->} {
+   *     rsp.send(req.path());
+   *   });
+   * 
+ * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection post(String path1, String path2, Route.Handler handler); + + /** + * Append three routes that supports HTTP POST method on the same handler: + * + *
+   *   post("/p1", "/p2", "/p3", (req, rsp) {@literal ->} {
+   *     rsp.send(req.path());
+   *   });
+   * 
+ * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param path3 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection post(String path1, String path2, String path3, Route.Handler handler); + + /** + * Append route that supports HTTP POST method: + * + *
+   *   post(req {@literal ->}
+   *     "hello"
+   *   );
+   * 
+ * + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + default Route.Definition post(Route.OneArgHandler handler) { + return post("/", handler); + } + + /** + * Append route that supports HTTP POST method: + * + *
+   *   post("/", req {@literal ->}
+   *     "hello"
+   *   );
+   * 
+ * + * @param path A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition post(String path, Route.OneArgHandler handler); + + /** + * Append three routes that supports HTTP POST method on the same handler: + * + *
+   *   post("/p1", "/p2", req {@literal ->} {
+   *     return req.path();
+   *   });
+   * 
+ * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection post(String path1, String path2, Route.OneArgHandler handler); + + /** + * Append three routes that supports HTTP POST method on the same handler: + * + *
+   *   post("/p1", "/p2", "/p3", req {@literal ->} {
+   *     return req.path();
+   *   });
+   * 
+ * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param path3 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection post(String path1, String path2, String path3, Route.OneArgHandler handler); + + /** + * Append route that supports HTTP POST method: + * + *
+   *   post(() {@literal ->}
+   *     "hello"
+   *   );
+   * 
+ * + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + default Route.Definition post(Route.ZeroArgHandler handler) { + return post("/", handler); + } + + /** + * Append route that supports HTTP POST method: + * + *
+   *   post("/", () {@literal ->}
+   *     "hello"
+   *   );
+   * 
+ * + * @param path A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition post(String path, Route.ZeroArgHandler handler); + + /** + * Append three routes that supports HTTP POST method on the same handler: + * + *
+   *   post("/p1", "/p2", {@literal ->} {
+   *     return "OK";
+   *   });
+   * 
+ * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection post(String path1, String path2, Route.ZeroArgHandler handler); + + /** + * Append three routes that supports HTTP POST method on the same handler: + * + *
+   *   post("/p1", "/p2", "/p3", () {@literal ->} {
+   *     return "OK";
+   *   });
+   * 
+ * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param path3 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection post(String path1, String path2, String path3, Route.ZeroArgHandler handler); + + /** + * Append a route that supports HTTP POST method: + * + *
+   *   post("/", (req, rsp, chain) {@literal ->} {
+   *     chain.next(req, rsp);
+   *   });
+   * 
+ * + * @param path A path pattern. + * @param filter A filter to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition post(String path, Route.Filter filter); + + /** + * Append three routes that supports HTTP POST method on the same handler: + * + *
+   *   post("/p1", "/p2",(req, rsp, chain) {@literal ->} {
+   *     chain.next(req, rsp);
+   *   });
+   * 
+ * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param filter A filter to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection post(String path1, String path2, Route.Filter filter); + + /** + * Append three routes that supports HTTP POST method on the same handler: + * + *
+   *   post("/p1", "/p2", "/p3", (req, rsp, chain) {@literal ->} {
+   *     chain.next(req, rsp);
+   *   });
+   * 
+ * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param path3 A path pattern. + * @param filter A filter to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection post(String path1, String path2, String path3, Route.Filter filter); + + /** + * Append a route that supports HTTP HEAD method: + * + *
+   *   post("/", (req, rsp) {@literal ->} {
+   *     rsp.send(something);
+   *   });
+   * 
+ * + * @param path A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition head(String path, Route.Handler handler); + + /** + * Append route that supports HTTP HEAD method: + * + *
+   *   head("/", req {@literal ->}
+   *     "hello"
+   *   );
+   * 
+ * + * @param path A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition head(String path, Route.OneArgHandler handler); + + /** + * Append route that supports HTTP HEAD method: + * + *
+   *   head("/", () {@literal ->}
+   *     "hello"
+   *   );
+   * 
+ * + * @param path A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition head(String path, Route.ZeroArgHandler handler); + + /** + * Append a route that supports HTTP HEAD method: + * + *
+   *   post("/", (req, rsp, chain) {@literal ->} {
+   *     chain.next(req, rsp);
+   *   });
+   * 
+ * + * @param path A path pattern. + * @param filter A filter to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition head(String path, Route.Filter filter); + + /** + * Append a new route that automatically handles HEAD request from existing GET routes. + * + *
+   * {
+   *   head();
+   * }
+   * 
+ * + * @return A new route definition. + */ + @Nonnull + Route.Definition head(); + + /** + * Append a route that supports HTTP OPTIONS method: + * + *
+   *   options("/", (req, rsp) {@literal ->} {
+   *     rsp.header("Allow", "GET, POST");
+   *   });
+   * 
+ * + * @param path A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition options(String path, Route.Handler handler); + + /** + * Append route that supports HTTP OPTIONS method: + * + *
+   *   options("/", req {@literal ->}
+   *     return Results.with(200).header("Allow", "GET, POST")
+   *   );
+   * 
+ * + * @param path A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition options(String path, Route.OneArgHandler handler); + + /** + * Append route that supports HTTP OPTIONS method: + * + *
+   *   options("/", () {@literal ->}
+   *     return Results.with(200).header("Allow", "GET, POST")
+   *   );
+   * 
+ * + * @param path A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition options(String path, Route.ZeroArgHandler handler); + + /** + * Append a route that supports HTTP OPTIONS method: + * + *
+   *   options("/", (req, rsp, chain) {@literal ->} {
+   *     rsp.header("Allow", "GET, POST");
+   *     chain.next(req, rsp);
+   *   });
+   * 
+ * + * @param path A path pattern. + * @param filter A callback to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition options(String path, Route.Filter filter); + + /** + * Append a new route that automatically handles OPTIONS requests. + * + *
+   *   get("/", (req, rsp) {@literal ->} {
+   *     rsp.send(something);
+   *   });
+   *
+   *   post("/", (req, rsp) {@literal ->} {
+   *     rsp.send(something);
+   *   });
+   *
+   *   options("/");
+   * 
+ * + * OPTIONS / produces a response with a Allow header set to: GET, POST. + * + * @return A new route definition. + */ + @Nonnull + Route.Definition options(); + + /** + * Append route that supports HTTP PUT method: + * + *
+   *   put((req, rsp) {@literal ->} {
+   *     rsp.send(something);
+   *   });
+   * 
+ * + * @param handler A route to execute. + * @return A new route definition. + */ + @Nonnull + default Route.Definition put(Route.Handler handler) { + return put("/", handler); + } + + /** + * Append route that supports HTTP PUT method: + * + *
+   *   put("/", (req, rsp) {@literal ->} {
+   *     rsp.send(something);
+   *   });
+   * 
+ * + * @param path A path pattern. + * @param handler A route to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition put(String path, Route.Handler handler); + + /** + * Append three routes that supports HTTP PUT method on the same handler: + * + *
+   *   put("/p1", "/p2", (req, rsp) {@literal ->} {
+   *     rsp.send(req.path());
+   *   });
+   * 
+ * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection put(String path1, String path2, Route.Handler handler); + + /** + * Append three routes that supports HTTP PUT method on the same handler: + * + *
+   *   put("/p1", "/p2", "/p3", (req, rsp) {@literal ->} {
+   *     rsp.send(req.path());
+   *   });
+   * 
+ * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param path3 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection put(String path1, String path2, String path3, Route.Handler handler); + + /** + * Append route that supports HTTP PUT method: + * + *
+   *   put(req {@literal ->}
+   *    return Results.accepted();
+   *   );
+   * 
+ * + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + default Route.Definition put(Route.OneArgHandler handler) { + return put("/", handler); + } + + /** + * Append route that supports HTTP PUT method: + * + *
+   *   put("/", req {@literal ->}
+   *    return Results.accepted();
+   *   );
+   * 
+ * + * @param path A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition put(String path, Route.OneArgHandler handler); + + /** + * Append three routes that supports HTTP PUT method on the same handler: + * + *
+   *   put("/p1", "/p2", req {@literal ->} {
+   *     return req.path();
+   *   });
+   * 
+ * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection put(String path1, String path2, Route.OneArgHandler handler); + + /** + * Append three routes that supports HTTP PUT method on the same handler: + * + *
+   *   put("/p1", "/p2", "/p3", req {@literal ->} {
+   *     return req.path();
+   *   });
+   * 
+ * + * This is a singleton route so make sure you don't share or use global variables. + * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param path3 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection put(String path1, String path2, String path3, Route.OneArgHandler handler); + + /** + * Append route that supports HTTP PUT method: + * + *
+   *   put(() {@literal ->} {
+   *     return Results.accepted()
+   *   });
+   * 
+ * + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + default Route.Definition put(Route.ZeroArgHandler handler) { + return put("/", handler); + } + + /** + * Append route that supports HTTP PUT method: + * + *
+   *   put("/", () {@literal ->} {
+   *     return Results.accepted()
+   *   });
+   * 
+ * + * @param path A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition put(String path, Route.ZeroArgHandler handler); + + /** + * Append three routes that supports HTTP PUT method on the same handler: + * + *
+   *   put("/p1", "/p2", req {@literal ->} {
+   *     return req.path();
+   *   });
+   * 
+ * + * This is a singleton route so make sure you don't share or use global variables. + * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection put(String path1, String path2, Route.ZeroArgHandler handler); + + /** + * Append three routes that supports HTTP PUT method on the same handler: + * + *
+   *   put("/p1", "/p2", "/p3", req {@literal ->} {
+   *     return req.path();
+   *   });
+   * 
+ * + * This is a singleton route so make sure you don't share or use global variables. + * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param path3 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection put(String path1, String path2, String path3, Route.ZeroArgHandler handler); + + /** + * Append route that supports HTTP PUT method: + * + *
+   *   put("/", (req, rsp, chain) {@literal ->} {
+   *     chain.next(req, rsp);
+   *   });
+   * 
+ * + * @param path A path pattern. + * @param filter A callback to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition put(String path, Route.Filter filter); + + /** + * Append three routes that supports HTTP PUT method on the same handler: + * + *
+   *   put("/p1", "/p2", (req, rsp, chain) {@literal ->} {
+   *     chain.next(req, rsp);
+   *   });
+   * 
+ * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param filter A filter to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection put(String path1, String path2, Route.Filter filter); + + /** + * Append three routes that supports HTTP PUT method on the same handler: + * + *
+   *   put("/p1", "/p2", "/p3", (req, rsp, chain) {@literal ->} {
+   *     chain.next(req, rsp);
+   *   });
+   * 
+ * + * This is a singleton route so make sure you don't share or use global variables. + * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param path3 A path pattern. + * @param filter A filter to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection put(String path1, String path2, String path3, Route.Filter filter); + + /** + * Append route that supports HTTP PATCH method: + * + *
+   *   patch((req, rsp) {@literal ->} {
+   *     rsp.send(something);
+   *   });
+   * 
+ * + * @param handler A route to execute. + * @return A new route definition. + */ + @Nonnull + default Route.Definition patch(Route.Handler handler) { + return patch("/", handler); + } + + /** + * Append route that supports HTTP PATCH method: + * + *
+   *   patch("/", (req, rsp) {@literal ->} {
+   *     rsp.send(something);
+   *   });
+   * 
+ * + * @param path A path pattern. + * @param handler A route to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition patch(String path, Route.Handler handler); + + /** + * Append three routes that supports HTTP PATCH method on the same handler: + * + *
+   *   patch("/p1", "/p2", (req, rsp) {@literal ->} {
+   *     rsp.send(something);
+   *   });
+   * 
+ * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection patch(String path1, String path2, Route.Handler handler); + + /** + * Append three routes that supports HTTP PATCH method on the same handler: + * + *
+   *   patch("/p1", "/p2", "/p3", (req, rsp) {@literal ->} {
+   *     rsp.send(something);
+   *   });
+   * 
+ * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param path3 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + Route.Collection patch(String path1, String path2, String path3, Route.Handler handler); + + /** + * Append route that supports HTTP PATCH method: + * + *
+   *   patch(req {@literal ->}
+   *    Results.ok()
+   *   );
+   * 
+ * + * This is a singleton route so make sure you don't share or use global variables. + * + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + default Route.Definition patch(Route.OneArgHandler handler) { + return patch("/", handler); + } + + /** + * Append route that supports HTTP PATCH method: + * + *
+   *   patch("/", req {@literal ->}
+   *    Results.ok()
+   *   );
+   * 
+ * + * This is a singleton route so make sure you don't share or use global variables. + * + * @param path A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition patch(String path, Route.OneArgHandler handler); + + /** + * Append three routes that supports HTTP PATCH method on the same handler: + * + *
+   *   patch("/p1", "/p2", req {@literal ->} {
+   *     return req.path();
+   *   });
+   * 
+ * + * This is a singleton route so make sure you don't share or use global variables. + * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection patch(String path1, String path2, Route.OneArgHandler handler); + + /** + * Append three routes that supports HTTP PATCH method on the same handler: + * + *
+   *   patch("/p1", "/p2", "/p3", req {@literal ->} {
+   *     return req.path();
+   *   });
+   * 
+ * + * This is a singleton route so make sure you don't share or use global variables. + * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param path3 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection patch(String path1, String path2, String path3, Route.OneArgHandler handler); + + /** + * Append route that supports HTTP PATCH method: + * + *
+   *   patch(() {@literal ->} {
+   *     return Results.ok();
+   *   });
+   * 
+ * + * This is a singleton route so make sure you don't share or use global variables. + * + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + default Route.Definition patch(Route.ZeroArgHandler handler) { + return patch("/", handler); + } + + /** + * Append route that supports HTTP PATCH method: + * + *
+   *   patch("/", () {@literal ->} {
+   *     return Results.ok();
+   *   });
+   * 
+ * + * This is a singleton route so make sure you don't share or use global variables. + * + * @param path A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition patch(String path, Route.ZeroArgHandler handler); + + /** + * Append three routes that supports HTTP PATCH method on the same handler: + * + *
+   *   patch("/p1", "/p2", () {@literal ->} {
+   *     return Results.ok();
+   *   });
+   * 
+ * + * This is a singleton route so make sure you don't share or use global variables. + * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection patch(String path1, String path2, Route.ZeroArgHandler handler); + + /** + * Append three routes that supports HTTP PATCH method on the same handler: + * + *
+   *   patch("/p1", "/p2", "/p3", () {@literal ->} {
+   *     return Results.ok();
+   *   });
+   * 
+ * + * This is a singleton route so make sure you don't share or use global variables. + * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param path3 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection patch(String path1, String path2, String path3, Route.ZeroArgHandler handler); + + /** + * Append route that supports HTTP PATCH method: + * + *
+   *   patch("/", (req, rsp, chain) {@literal ->} {
+   *     chain.next(req, rsp);
+   *   });
+   * 
+ * + * @param path A path pattern. + * @param filter A callback to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition patch(String path, Route.Filter filter); + + /** + * Append three routes that supports HTTP PATCH method on the same handler: + * + *
+   *   patch("/p1", "/p2", (req, rsp, chain) {@literal ->} {
+   *     chain.next(req, rsp);
+   *   });
+   * 
+ * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param filter A filter to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection patch(String path1, String path2, Route.Filter filter); + + /** + * Append three routes that supports HTTP PATCH method on the same handler: + * + *
+   *   patch("/p1", "/p2", "/p3", (req, rsp, chain) {@literal ->} {
+   *     chain.next(req, rsp);
+   *   });
+   * 
+ * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param path3 A path pattern. + * @param filter A filter to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection patch(String path1, String path2, String path3, Route.Filter filter); + + /** + * Append a route that supports HTTP DELETE method: + * + *
+   *   delete((req, rsp) {@literal ->} {
+   *     rsp.status(204);
+   *   });
+   * 
+ * + * This is a singleton route so make sure you don't share or use global variables. + * + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + default Route.Definition delete(Route.Handler handler) { + return delete("/", handler); + } + + /** + * Append a route that supports HTTP DELETE method: + * + *
+   *   delete("/", (req, rsp) {@literal ->} {
+   *     rsp.status(204);
+   *   });
+   * 
+ * + * This is a singleton route so make sure you don't share or use global variables. + * + * @param path A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition delete(String path, Route.Handler handler); + + /** + * Append three routes that supports HTTP DELETE method on the same handler: + * + *
+   *   delete("/p1", "/p2", (req, rsp) {@literal ->} {
+   *     rsp.status(204);
+   *   });
+   * 
+ * + * This is a singleton route so make sure you don't share or use global variables. + * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection delete(String path1, String path2, Route.Handler handler); + + /** + * Append three routes that supports HTTP DELETE method on the same handler: + * + *
+   *   delete("/p1", "/p2", "/p3", (req, rsp) {@literal ->} {
+   *     rsp.status(204);
+   *   });
+   * 
+ * + * This is a singleton route so make sure you don't share or use global variables. + * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param path3 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection delete(String path1, String path2, String path3, Route.Handler handler); + + /** + * Append route that supports HTTP DELETE method: + * + *
+   *   delete(req {@literal ->}
+   *     return Results.noContent();
+   *   );
+   * 
+ * + * This is a singleton route so make sure you don't share or use global variables. + * + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + default Route.Definition delete(Route.OneArgHandler handler) { + return delete("/", handler); + } + + /** + * Append route that supports HTTP DELETE method: + * + *
+   *   delete("/", req {@literal ->}
+   *     return Results.noContent();
+   *   );
+   * 
+ * + * This is a singleton route so make sure you don't share or use global variables. + * + * @param path A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition delete(String path, Route.OneArgHandler handler); + + /** + * Append three routes that supports HTTP DELETE method on the same handler: + * + *
+   *   delete("/p1", "/p2", req {@literal ->} {
+   *     return Results.noContent();
+   *   });
+   * 
+ * + * This is a singleton route so make sure you don't share or use global variables. + * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection delete(String path1, String path2, Route.OneArgHandler handler); + + /** + * Append three routes that supports HTTP DELETE method on the same handler: + * + *
+   *   delete("/p1", "/p2", "/p3",req {@literal ->} {
+   *     return Results.noContent();
+   *   });
+   * 
+ * + * This is a singleton route so make sure you don't share or use global variables. + * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param path3 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection delete(String path1, String path2, String path3, Route.OneArgHandler handler); + + /** + * Append route that supports HTTP DELETE method: + * + *
+   *   delete(() {@literal ->}
+   *     return Results.noContent();
+   *   );
+   * 
+ * + * This is a singleton route so make sure you don't share or use global variables. + * + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + default Route.Definition delete(Route.ZeroArgHandler handler) { + return delete("/", handler); + } + + /** + * Append route that supports HTTP DELETE method: + * + *
+   *   delete("/", () {@literal ->}
+   *     return Results.noContent();
+   *   );
+   * 
+ * + * This is a singleton route so make sure you don't share or use global variables. + * + * @param path A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition delete(String path, Route.ZeroArgHandler handler); + + /** + * Append three routes that supports HTTP DELETE method on the same handler: + * + *
+   *   delete("/p1", "/p2", () {@literal ->} {
+   *     return Results.noContent();
+   *   });
+   * 
+ * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection delete(String path1, String path2, Route.ZeroArgHandler handler); + + /** + * Append three routes that supports HTTP DELETE method on the same handler: + * + *
+   *   delete("/p1", "/p2", "/p3", req {@literal ->} {
+   *     return Results.noContent();
+   *   });
+   * 
+ * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param path3 A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection delete(String path1, String path2, String path3, Route.ZeroArgHandler handler); + + /** + * Append a route that supports HTTP DELETE method: + * + *
+   *   delete("/", (req, rsp, chain) {@literal ->} {
+   *     rsp.status(304);
+   *     chain.next(req, rsp);
+   *   });
+   * 
+ * + * @param path A path pattern. + * @param filter A callback to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition delete(String path, Route.Filter filter); + + /** + * Append three routes that supports HTTP DELETE method on the same handler: + * + *
+   *   delete("/p1", "/p2", (req, rsp, chain) {@literal ->} {
+   *     rsp.status(304);
+   *     chain.next(req, rsp);
+   *   });
+   * 
+ * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param filter A filter to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection delete(String path1, String path2, Route.Filter filter); + + /** + * Append three routes that supports HTTP DELETE method on the same handler: + * + *
+   *   delete("/p1", "/p2", "/p3", (req, rsp, chain) {@literal ->} {
+   *     rsp.status(304);
+   *     chain.next(req, rsp);
+   *   });
+   * 
+ * + * @param path1 A path pattern. + * @param path2 A path pattern. + * @param path3 A path pattern. + * @param filter A filter to execute. + * @return A new route definition. + */ + @Nonnull + Route.Collection delete(String path1, String path2, String path3, Route.Filter filter); + + /** + * Append a route that supports HTTP TRACE method: + * + *
+   *   trace("/", (req, rsp) {@literal ->} {
+   *     rsp.send(...);
+   *   });
+   * 
+ * + * @param path A path pattern. + * @param handler A callback to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition trace(String path, Route.Handler handler); + + /** + * Append route that supports HTTP TRACE method: + * + *
+   *   trace("/", req {@literal ->}
+   *     "trace"
+   *   );
+   * 
+ * + * @param path A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition trace(String path, Route.OneArgHandler handler); + + /** + * Append route that supports HTTP TRACE method: + * + *
+   *   trace("/", () {@literal ->}
+   *     "trace"
+   *   );
+   * 
+ * + * @param path A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition trace(String path, Route.ZeroArgHandler handler); + + /** + * Append a route that supports HTTP TRACE method: + * + *
+   *   trace("/", (req, rsp, chain) {@literal ->} {
+   *     chain.next(req, rsp);
+   *   });
+   * 
+ * + * @param path A path pattern. + * @param filter A callback to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition trace(String path, Route.Filter filter); + + /** + * Append a default trace implementation under the given path. Default trace response, looks + * like: + * + *
+   *  TRACE /path
+   *     header1: value
+   *     header2: value
+   * 
+ * + * @return A new route definition. + */ + @Nonnull + Route.Definition trace(); + + /** + * Append a route that supports HTTP CONNECT method: + * + *
+   *   connect("/", (req, rsp) {@literal ->} {
+   *   });
+   * 
+ * + * @param path A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition connect(String path, Route.Handler handler); + + /** + * Append route that supports HTTP CONNECT method: + * + *
+   *   connect("/", req {@literal ->}
+   *     "hello"
+   *   );
+   * 
+ * + * @param path A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition connect(String path, Route.OneArgHandler handler); + + /** + * Append route that supports HTTP CONNECT method: + * + *
+   *   connect("/", () {@literal ->}
+   *     "connected"
+   *   );
+   * 
+ * + * @param path A path pattern. + * @param handler A handler to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition connect(String path, Route.ZeroArgHandler handler); + + /** + * Append a route that supports HTTP CONNECT method: + * + *
+   *   connect("/", (req, rsp, chain) {@literal ->} {
+   *     chain.next(req, rsp);
+   *   });
+   * 
+ * + * @param path A path pattern. + * @param filter A filter to execute. + * @return A new route definition. + */ + @Nonnull + Route.Definition connect(String path, Route.Filter filter); + + /** + * Static files handler. + * + *
+   *   assets("/assets/**");
+   * 
+ * + * Resources are served from root of classpath, for example GET /assets/file.js will + * be resolve as classpath resource at the same location. + * + * The {@link AssetHandler} one step forward and add support for serving files from a CDN out of + * the box. All you have to do is to define a assets.cdn property: + * + *
+   * assets.cdn = "http://d7471vfo50fqt.cloudfront.net"
+   * 
+ * + * A GET to /assets/js/index.js will be redirected to: + * http://d7471vfo50fqt.cloudfront.net/assets/js/index.js. + * + * You can turn on/off ETag and Last-Modified headers too using + * assets.etag and assets.lastModified. These two properties are enabled + * by default. + * + * @param path The path to publish. + * @return A new route definition. + */ + @Nonnull + default Route.AssetDefinition assets(final String path) { + return assets(path, "/"); + } + + /** + * Static files handler on external location. + * + *
+   *   assets("/assets/**", Paths.get("/www"));
+   * 
+ * + * For example GET /assets/file.js will be resolve as /www/file.js on + * server file system. + * + *

+ * The {@link AssetHandler} one step forward and add support for serving files from a CDN out of + * the box. All you have to do is to define a assets.cdn property: + *

+ *
+   * assets.cdn = "http://d7471vfo50fqt.cloudfront.net"
+   * 
+ * + * A GET to /assets/js/index.js will be redirected to: + * http://d7471vfo50fqt.cloudfront.net/assets/js/index.js. + * + * You can turn on/off ETag and Last-Modified headers too using + * assets.etag and assets.lastModified. These two properties are enabled + * by default. + * + * @param path The path to publish. + * @param basedir Base directory. + * @return A new route definition. + */ + @Nonnull + Route.AssetDefinition assets(final String path, Path basedir); + + /** + * Static files handler. Like {@link #assets(String)} but let you specify a different classpath + * location. + * + *

+ * Basic example + *

+ * + *
+   *   assets("/js/**", "/");
+   * 
+ * + * A request for: /js/jquery.js will be translated to: /lib/jquery.js. + * + *

+ * Webjars example: + *

+ * + *
+   *   assets("/js/**", "/resources/webjars/{0}");
+   * 
+ * + * A request for: /js/jquery/2.1.3/jquery.js will be translated to: + * /resources/webjars/jquery/2.1.3/jquery.js. + * The {0} represent the ** capturing group. + * + *

+ * Another webjars example: + *

+ * + *
+   *   assets("/js/*-*.js", "/resources/webjars/{0}/{1}/{0}.js");
+   * 
+ * + *

+ * A request for: /js/jquery-2.1.3.js will be translated to: + * /resources/webjars/jquery/2.1.3/jquery.js. + *

+ * + * @param path The path to publish. + * @param location A resource location. + * @return A new route definition. + */ + @Nonnull + Route.AssetDefinition assets(String path, String location); + + /** + * Send static files, like {@link #assets(String)} but let you specify a custom + * {@link AssetHandler}. + * + * @param path The path to publish. + * @param handler Asset handler. + * @return A new route definition. + */ + @Nonnull + Route.AssetDefinition assets(String path, AssetHandler handler); + + /** + *

+ * Append MVC routes from a controller like class: + *

+ * + *
+   *   use(MyRoute.class);
+   * 
+ * + * Where MyRoute.java is: + * + *
+   *   {@literal @}Path("/")
+   *   public class MyRoute {
+   *
+   *    {@literal @}GET
+   *    public String hello() {
+   *      return "Hello Jooby";
+   *    }
+   *   }
+   * 
+ *

+ * Programming model is quite similar to JAX-RS/Jersey with some minor differences and/or + * simplifications. + *

+ * + *

+ * To learn more about Mvc Routes, please check {@link org.jooby.mvc.Path}, + * {@link org.jooby.mvc.Produces} {@link org.jooby.mvc.Consumes}. + *

+ * + * @param routeClass A route(s) class. + * @return This router. + */ + @Nonnull + Route.Collection use(Class routeClass); + + /** + *

+ * Append MVC routes from a controller like class: + *

+ * + *
+   *   use("/pets", MyRoute.class);
+   * 
+ * + * Where MyRoute.java is: + * + *
+   *   {@literal @}Path("/")
+   *   public class MyRoute {
+   *
+   *    {@literal @}GET
+   *    public String hello() {
+   *      return "Hello Jooby";
+   *    }
+   *   }
+   * 
+ *

+ * Programming model is quite similar to JAX-RS/Jersey with some minor differences and/or + * simplifications. + *

+ * + *

+ * To learn more about Mvc Routes, please check {@link org.jooby.mvc.Path}, + * {@link org.jooby.mvc.Produces} {@link org.jooby.mvc.Consumes}. + *

+ * + * @param path Path to mount the route. + * @param routeClass A route(s) class. + * @return This router. + */ + @Nonnull + Route.Collection use(String path, Class routeClass); + + /** + *

before

+ * + * Allows for customized handler execution chains. It will be invoked before the actual handler. + * + *
{@code
+   * {
+   *   before((req, rsp) -> {
+   *     // your code goes here
+   *   });
+   * }
+   * }
+ * + * You are allowed to modify the request and response objects. + * + * Please note that the before handler is just syntax sugar for {@link Route.Filter}. + * For example, the before handler was implemented as: + * + *
{@code
+   * {
+   *   use("*", "*", (req, rsp, chain) -> {
+   *     before(req, rsp);
+   *     // your code goes here
+   *     chain.next(req, rsp);
+   *   });
+   * }
+   * }
+ * + * A before handler must to be registered before the actual handler you want to + * intercept. + * + *
{@code
+   * {
+   *   before((req, rsp) -> {
+   *     // your code goes here
+   *   });
+   *
+   *   get("/path", req -> {
+   *     // your code goes here
+   *     return ...;
+   *   });
+   * }
+   * }
+ * + * If you reverse the order then it won't work. + * + *

+ * Remember: routes are executed in the order they are defined and the pipeline + * is executed as long you don't generate a response. + *

+ * + * @param handler Before handler. + * @return A new route definition. + */ + @Nonnull + default Route.Definition before(final Route.Before handler) { + return before("*", handler); + } + + /** + *

before

+ * + * Allows for customized handler execution chains. It will be invoked before the actual handler. + * + *
{@code
+   * {
+   *   before((req, rsp) -> {
+   *     // your code goes here
+   *   });
+   * }
+   * }
+ * + * You are allowed to modify the request and response objects. + * + * Please note that the before handler is just syntax sugar for {@link Route.Filter}. + * For example, the before handler was implemented as: + * + *
{@code
+   * {
+   *   use("*", "*", (req, rsp, chain) -> {
+   *     before(req, rsp);
+   *     // your code goes here
+   *     chain.next(req, rsp);
+   *   });
+   * }
+   * }
+ * + * A before handler must to be registered before the actual handler you want to + * intercept. + * + *
{@code
+   * {
+   *   before((req, rsp) -> {
+   *     // your code goes here
+   *   });
+   *
+   *   get("/path", req -> {
+   *     // your code goes here
+   *     return ...;
+   *   });
+   * }
+   * }
+ * + * If you reverse the order then it won't work. + * + *

+ * Remember: routes are executed in the order they are defined and the pipeline + * is executed as long you don't generate a response. + *

+ * + * @param handler Before handler. + * @param next Next handler. + * @return A new route definition. + */ + @Nonnull + default Route.Collection before(final Route.Before handler, Route.Before... next) { + return before("*", handler, next); + } + + /** + *

before

+ * + * Allows for customized handler execution chains. It will be invoked before the actual handler. + * + *
{@code
+   * {
+   *   before("*", (req, rsp) -> {
+   *     // your code goes here
+   *   });
+   * }
+   * }
+ * + * You are allowed to modify the request and response objects. + * + * Please note that the before handler is just syntax sugar for {@link Route.Filter}. + * For example, the before handler was implemented as: + * + *
{@code
+   * {
+   *   use("*", (req, rsp, chain) -> {
+   *     before(req, rsp);
+   *     chain.next(req, rsp);
+   *   });
+   * }
+   * }
+ * + * A before handler must to be registered before the actual handler you want to + * intercept. + * + *
{@code
+   * {
+   *   before("/path", (req, rsp) -> {
+   *     // your code goes here
+   *   });
+   *
+   *   get("/path", req -> {
+   *     // your code goes here
+   *     return ...;
+   *   });
+   * }
+   * }
+ * + * If you reverse the order then it won't work. + * + *

+ * Remember: routes are executed in the order they are defined and the pipeline + * is executed as long you don't generate a response. + *

+ * + * @param pattern Pattern to intercept. + * @param handler Before handler. + * @return A new route definition. + */ + @Nonnull + default Route.Definition before(final String pattern, final Route.Before handler) { + return before("*", pattern, handler); + } + + /** + *

before

+ * + * Allows for customized handler execution chains. It will be invoked before the actual handler. + * + *
{@code
+   * {
+   *   before("*", (req, rsp) -> {
+   *     // your code goes here
+   *   });
+   * }
+   * }
+ * + * You are allowed to modify the request and response objects. + * + * Please note that the before handler is just syntax sugar for {@link Route.Filter}. + * For example, the before handler was implemented as: + * + *
{@code
+   * {
+   *   use("*", (req, rsp, chain) -> {
+   *     before(req, rsp);
+   *     chain.next(req, rsp);
+   *   });
+   * }
+   * }
+ * + * A before handler must to be registered before the actual handler you want to + * intercept. + * + *
{@code
+   * {
+   *   before("/path", (req, rsp) -> {
+   *     // your code goes here
+   *   });
+   *
+   *   get("/path", req -> {
+   *     // your code goes here
+   *     return ...;
+   *   });
+   * }
+   * }
+ * + * If you reverse the order then it won't work. + * + *

+ * Remember: routes are executed in the order they are defined and the pipeline + * is executed as long you don't generate a response. + *

+ * + * @param pattern Pattern to intercept. + * @param handler Before handler. + * @param next Next handler. + * @return A new route definition. + */ + @Nonnull + default Route.Collection before(String pattern, Route.Before handler, Route.Before... next) { + return before("*", pattern, handler, next); + } + + /** + *

before

+ * + * Allows for customized handler execution chains. It will be invoked before the actual handler. + * + *
{@code
+   * {
+   *   before("GET", "*", (req, rsp) -> {
+   *     // your code goes here
+   *   });
+   * }
+   * }
+ * + * You are allowed to modify the request and response objects. + * + * Please note that the before handler is just syntax sugar for {@link Route.Filter}. + * For example, the before handler was implemented as: + * + *
{@code
+   * {
+   *   use("GET", "*", (req, rsp, chain) -> {
+   *     before(req, rsp);
+   *     chain.next(req, rsp);
+   *   });
+   * }
+   * }
+ * + * A before handler must to be registered before the actual handler you want to + * intercept. + * + *
{@code
+   * {
+   *   before("GET", "/path", (req, rsp) -> {
+   *     // your code goes here
+   *   });
+   *
+   *   get("/path", req -> {
+   *     // your code goes here
+   *     return ...;
+   *   });
+   * }
+   * }
+ * + * If you reverse the order then it won't work. + * + *

+ * Remember: routes are executed in the order they are defined and the pipeline + * is executed as long you don't generate a response. + *

+ * + * @param method HTTP method to intercept. + * @param pattern Pattern to intercept. + * @param handler Before handler. + * @return A new route definition. + */ + @Nonnull + Route.Definition before(String method, String pattern, Route.Before handler); + + /** + *

before

+ * + * Allows for customized handler execution chains. It will be invoked before the actual handler. + * + *
{@code
+   * {
+   *   before("GET", "*", (req, rsp) -> {
+   *     // your code goes here
+   *   });
+   * }
+   * }
+ * + * You are allowed to modify the request and response objects. + * + * Please note that the before handler is just syntax sugar for {@link Route.Filter}. + * For example, the before handler was implemented as: + * + *
{@code
+   * {
+   *   use("GET", "*", (req, rsp, chain) -> {
+   *     before(req, rsp);
+   *     chain.next(req, rsp);
+   *   });
+   * }
+   * }
+ * + * A before handler must to be registered before the actual handler you want to + * intercept. + * + *
{@code
+   * {
+   *   before("GET", "/path", (req, rsp) -> {
+   *     // your code goes here
+   *   });
+   *
+   *   get("/path", req -> {
+   *     // your code goes here
+   *     return ...;
+   *   });
+   * }
+   * }
+ * + * If you reverse the order then it won't work. + * + *

+ * Remember: routes are executed in the order they are defined and the pipeline + * is executed as long you don't generate a response. + *

+ * + * @param method HTTP method to intercept. + * @param pattern Pattern to intercept. + * @param handler Before handler. + * @param next Next handler. + * @return A new route definition. + */ + @Nonnull + default Route.Collection before(String method, String pattern, Route.Before handler, Route.Before... next) { + List routes = new ArrayList<>(); + routes.add(before(method, pattern, handler)); + Arrays.asList(next).stream() + .map(h -> before(method, pattern, h)) + .forEach(routes::add); + return new Route.Collection(routes.toArray(new Route.Definition[routes.size()])); + } + + /** + *

after

+ * + * Allows for customized response before sending it. It will be invoked at the time a response + * need + * to be send. + * + *
{@code
+   * {
+   *   after((req, rsp, result) -> {
+   *     // your code goes here
+   *     return result;
+   *   });
+   * }
+   * }
+ * + * You are allowed to modify the request, response and result objects. The handler returns a + * {@link Result} which can be the same or an entirely new {@link Result}. + * + * A after handler must to be registered before the actual handler you want to + * intercept. + * + *
{@code
+   * {
+   *   after((req, rsp, result) -> {
+   *     // your code goes here
+   *     return result;
+   *   });
+   *
+   *   get("/path", req -> {
+   *     return "hello";
+   *   });
+   * }
+   * }
+ * + * If you reverse the order then it won't work. + * + *

+ * Remember: routes are executed in the order they are defined and the pipeline + * is executed as long you don't generate a response. + *

+ * + * @param handler After handler. + * @return A new route definition. + */ + @Nonnull + default Route.Definition after(Route.After handler) { + return after("*", handler); + } + + /** + *

after

+ * + * Allows for customized response before sending it. It will be invoked at the time a response + * need + * to be send. + * + *
{@code
+   * {
+   *   after((req, rsp, result) -> {
+   *     // your code goes here
+   *     return result;
+   *   });
+   * }
+   * }
+ * + * You are allowed to modify the request, response and result objects. The handler returns a + * {@link Result} which can be the same or an entirely new {@link Result}. + * + * A after handler must to be registered before the actual handler you want to + * intercept. + * + *
{@code
+   * {
+   *   after((req, rsp, result) -> {
+   *     // your code goes here
+   *     return result;
+   *   });
+   *
+   *   get("/path", req -> {
+   *     return "hello";
+   *   });
+   * }
+   * }
+ * + * If you reverse the order then it won't work. + * + *

+ * Remember: routes are executed in the order they are defined and the pipeline + * is executed as long you don't generate a response. + *

+ * + * @param handler After handler. + * @param next Next handler. + * @return A new route definition. + */ + @Nonnull + default Route.Collection after(Route.After handler, Route.After... next) { + return after("*", handler, next); + } + + /** + *

after

+ * + * Allows for customized response before sending it. It will be invoked at the time a response + * need to be send. + * + *
{@code
+   * {
+   *   after("*", (req, rsp, result) -> {
+   *     // your code goes here
+   *     return result;
+   *   });
+   * }
+   * }
+ * + * You are allowed to modify the request, response and result objects. The handler returns a + * {@link Result} which can be the same or an entirely new {@link Result}. + * + * A after handler must to be registered before the actual handler you want to + * intercept. + * + *
{@code
+   * {
+   *   after("/path", (req, rsp, result) -> {
+   *     // your code goes here
+   *     return result;
+   *   });
+   *
+   *   get("/path", req -> {
+   *     return "hello";
+   *   });
+   * }
+   * }
+ * + * If you reverse the order then it won't work. + * + *

+ * Remember: routes are executed in the order they are defined and the pipeline + * is executed as long you don't generate a response. + *

+ * + * @param pattern Pattern to intercept. + * @param handler After handler. + * @return A new route definition. + */ + @Nonnull + default Route.Definition after(final String pattern, final Route.After handler) { + return after("*", pattern, handler); + } + + /** + *

after

+ * + * Allows for customized response before sending it. It will be invoked at the time a response + * need to be send. + * + *
{@code
+   * {
+   *   after("*", (req, rsp, result) -> {
+   *     // your code goes here
+   *     return result;
+   *   });
+   * }
+   * }
+ * + * You are allowed to modify the request, response and result objects. The handler returns a + * {@link Result} which can be the same or an entirely new {@link Result}. + * + * A after handler must to be registered before the actual handler you want to + * intercept. + * + *
{@code
+   * {
+   *   after("/path", (req, rsp, result) -> {
+   *     // your code goes here
+   *     return result;
+   *   });
+   *
+   *   get("/path", req -> {
+   *     return "hello";
+   *   });
+   * }
+   * }
+ * + * If you reverse the order then it won't work. + * + *

+ * Remember: routes are executed in the order they are defined and the pipeline + * is executed as long you don't generate a response. + *

+ * + * @param pattern Pattern to intercept. + * @param handler After handler. + * @param next Next handler. + * @return A new route definition. + */ + @Nonnull + default Route.Collection after(String pattern, Route.After handler, Route.After... next) { + return after("*", pattern, handler, next); + } + + /** + *

after

+ * + * Allows for customized response before sending it. It will be invoked at the time a response + * need to be send. + * + *
{@code
+   * {
+   *   after("GET", "*", (req, rsp, result) -> {
+   *     // your code goes here
+   *     return result;
+   *   });
+   * }
+   * }
+ * + * You are allowed to modify the request, response and result objects. The handler returns a + * {@link Result} which can be the same or an entirely new {@link Result}. + * + * A after handler must to be registered before the actual handler you want to + * intercept. + * + *
{@code
+   * {
+   *   after("GET", "/path", (req, rsp, result) -> {
+   *     // your code goes here
+   *     return result;
+   *   });
+   *
+   *   get("/path", req -> {
+   *     return "hello";
+   *   });
+   * }
+   * }
+ * + * If you reverse the order then it won't work. + * + *

+ * Remember: routes are executed in the order they are defined and the pipeline + * is executed as long you don't generate a response. + *

+ * + * @param method HTTP method to intercept. + * @param pattern Pattern to intercept. + * @param handler After handler. + * @return A new route definition. + */ + @Nonnull + Route.Definition after(String method, String pattern, Route.After handler); + + /** + *

after

+ * + * Allows for customized response before sending it. It will be invoked at the time a response + * need to be send. + * + *
{@code
+   * {
+   *   after("GET", "*", (req, rsp, result) -> {
+   *     // your code goes here
+   *     return result;
+   *   });
+   * }
+   * }
+ * + * You are allowed to modify the request, response and result objects. The handler returns a + * {@link Result} which can be the same or an entirely new {@link Result}. + * + * A after handler must to be registered before the actual handler you want to + * intercept. + * + *
{@code
+   * {
+   *   after("GET", "/path", (req, rsp, result) -> {
+   *     // your code goes here
+   *     return result;
+   *   });
+   *
+   *   get("/path", req -> {
+   *     return "hello";
+   *   });
+   * }
+   * }
+ * + * If you reverse the order then it won't work. + * + *

+ * Remember: routes are executed in the order they are defined and the pipeline + * is executed as long you don't generate a response. + *

+ * + * @param method HTTP method to intercept. + * @param pattern Pattern to intercept. + * @param handler After handler. + * @param next Next handler. + * @return A new route definition. + */ + @Nonnull + default Route.Collection after(String method, String pattern, Route.After handler, Route.After... next) { + List routes = new ArrayList<>(); + routes.add(after(method, pattern, handler)); + Arrays.asList(next).stream() + .map(h -> after(method, pattern, handler)) + .forEach(routes::add); + return new Route.Collection(routes.toArray(new Route.Definition[routes.size()])); + } + + /** + *

complete

+ * + * Allows for log and cleanup a request. It will be invoked after we send a response. + * + *
{@code
+   * {
+   *   complete((req, rsp, cause) -> {
+   *     // your code goes here
+   *   });
+   * }
+   * }
+ * + * You are NOT allowed to modify the request and response objects. The cause is an + * {@link Optional} with a {@link Throwable} useful to identify problems. + * + * The goal of the after handler is to probably cleanup request object and log + * responses. + * + * A complete handler must to be registered before the actual handler you want to + * intercept. + * + *
{@code
+   * {
+   *   complete((req, rsp, cause) -> {
+   *     // your code goes here
+   *   });
+   *
+   *   get(req -> {
+   *     return "hello";
+   *   });
+   * }
+   * }
+ * + * If you reverse the order then it won't work. + * + *

+ * Remember: routes are executed in the order they are defined and the pipeline + * is executed as long you don't generate a response. + *

+ * + *

example

+ *

+ * Suppose you have a transactional resource, like a database connection. The next example shows + * you how to implement a simple and effective transaction-per-request pattern: + *

+ * + *
{@code
+   * {
+   *   // start transaction
+   *   before((req, rsp) -> {
+   *     DataSource ds = req.require(DataSource.class);
+   *     Connection connection = ds.getConnection();
+   *     Transaction trx = connection.getTransaction();
+   *     trx.begin();
+   *     req.set("connection", connection);
+   *     return true;
+   *   });
+   *
+   *   // commit/rollback transaction
+   *   complete((req, rsp, cause) -> {
+   *     // unbind connection from request
+   *     try(Connection connection = req.unset("connection").get()) {
+   *       Transaction trx = connection.getTransaction();
+   *       if (cause.ifPresent()) {
+   *         trx.rollback();
+   *       } else {
+   *         trx.commit();
+   *       }
+   *     }
+   *   });
+   *
+   *   // your transactional routes goes here
+   *   get("/api/something", req -> {
+   *     Connection connection = req.get("connection");
+   *     // work with connection
+   *   });
+   * }
+   * }
+ * + * @param handler Complete handler. + * @return A new route definition. + */ + @Nonnull + default Route.Definition complete(final Route.Complete handler) { + return complete("*", handler); + } + + /** + *

complete

+ * + * Allows for log and cleanup a request. It will be invoked after we send a response. + * + *
{@code
+   * {
+   *   complete((req, rsp, cause) -> {
+   *     // your code goes here
+   *   });
+   * }
+   * }
+ * + * You are NOT allowed to modify the request and response objects. The cause is an + * {@link Optional} with a {@link Throwable} useful to identify problems. + * + * The goal of the after handler is to probably cleanup request object and log + * responses. + * + * A complete handler must to be registered before the actual handler you want to + * intercept. + * + *
{@code
+   * {
+   *   complete((req, rsp, cause) -> {
+   *     // your code goes here
+   *   });
+   *
+   *   get(req -> {
+   *     return "hello";
+   *   });
+   * }
+   * }
+ * + * If you reverse the order then it won't work. + * + *

+ * Remember: routes are executed in the order they are defined and the pipeline + * is executed as long you don't generate a response. + *

+ * + *

example

+ *

+ * Suppose you have a transactional resource, like a database connection. The next example shows + * you how to implement a simple and effective transaction-per-request pattern: + *

+ * + *
{@code
+   * {
+   *   // start transaction
+   *   before((req, rsp) -> {
+   *     DataSource ds = req.require(DataSource.class);
+   *     Connection connection = ds.getConnection();
+   *     Transaction trx = connection.getTransaction();
+   *     trx.begin();
+   *     req.set("connection", connection);
+   *     return true;
+   *   });
+   *
+   *   // commit/rollback transaction
+   *   complete((req, rsp, cause) -> {
+   *     // unbind connection from request
+   *     try(Connection connection = req.unset("connection").get()) {
+   *       Transaction trx = connection.getTransaction();
+   *       if (cause.ifPresent()) {
+   *         trx.rollback();
+   *       } else {
+   *         trx.commit();
+   *       }
+   *     }
+   *   });
+   *
+   *   // your transactional routes goes here
+   *   get("/api/something", req -> {
+   *     Connection connection = req.get("connection");
+   *     // work with connection
+   *   });
+   * }
+   * }
+ * + * @param handler Complete handler. + * @param next Next handler. + * @return A new route definition. + */ + @Nonnull + default Route.Collection complete(final Route.Complete handler, Route.Complete... next) { + return complete("*", handler, next); + } + + /** + *

complete

+ * + * Allows for log and cleanup a request. It will be invoked after we send a response. + * + *
{@code
+   * {
+   *   complete("*", (req, rsp, cause) -> {
+   *     // your code goes here
+   *   });
+   * }
+   * }
+ * + * You are NOT allowed to modify the request and response objects. The cause is an + * {@link Optional} with a {@link Throwable} useful to identify problems. + * + * The goal of the complete handler is to probably cleanup request object and log + * responses. + * + * A complete handler must to be registered before the actual handler you want to + * intercept. + * + *
{@code
+   * {
+   *   complete("/path", (req, rsp, cause) -> {
+   *     // your code goes here
+   *   });
+   *
+   *   get("/path", req -> {
+   *     return "hello";
+   *   });
+   * }
+   * }
+ * + * If you reverse the order then it won't work. + * + *

+ * Remember: routes are executed in the order they are defined and the pipeline + * is executed as long you don't generate a response. + *

+ * + *

example

+ *

+ * Suppose you have a transactional resource, like a database connection. The next example shows + * you how to implement a simple and effective transaction-per-request pattern: + *

+ * + *
{@code
+   * {
+   *   // start transaction
+   *   before("/api/*", (req, rsp) -> {
+   *     DataSource ds = req.require(DataSource.class);
+   *     Connection connection = ds.getConnection();
+   *     Transaction trx = connection.getTransaction();
+   *     trx.begin();
+   *     req.set("connection", connection);
+   *     return true;
+   *   });
+   *
+   *   // commit/rollback transaction
+   *   complete("/api/*", (req, rsp, cause) -> {
+   *     // unbind connection from request
+   *     try(Connection connection = req.unset("connection").get()) {
+   *       Transaction trx = connection.getTransaction();
+   *       if (cause.ifPresent()) {
+   *         trx.rollback();
+   *       } else {
+   *         trx.commit();
+   *       }
+   *     }
+   *   });
+   *
+   *   // your transactional routes goes here
+   *   get("/api/something", req -> {
+   *     Connection connection = req.get("connection");
+   *     // work with connection
+   *   });
+   * }
+   * }
+ * + * @param pattern Pattern to intercept. + * @param handler Complete handler. + * @return A new route definition. + */ + @Nonnull + default Route.Definition complete(final String pattern, final Route.Complete handler) { + return complete("*", pattern, handler); + } + + /** + *

complete

+ * + * Allows for log and cleanup a request. It will be invoked after we send a response. + * + *
{@code
+   * {
+   *   complete("*", (req, rsp, cause) -> {
+   *     // your code goes here
+   *   });
+   * }
+   * }
+ * + * You are NOT allowed to modify the request and response objects. The cause is an + * {@link Optional} with a {@link Throwable} useful to identify problems. + * + * The goal of the complete handler is to probably cleanup request object and log + * responses. + * + * A complete handler must to be registered before the actual handler you want to + * intercept. + * + *
{@code
+   * {
+   *   complete("/path", (req, rsp, cause) -> {
+   *     // your code goes here
+   *   });
+   *
+   *   get("/path", req -> {
+   *     return "hello";
+   *   });
+   * }
+   * }
+ * + * If you reverse the order then it won't work. + * + *

+ * Remember: routes are executed in the order they are defined and the pipeline + * is executed as long you don't generate a response. + *

+ * + *

example

+ *

+ * Suppose you have a transactional resource, like a database connection. The next example shows + * you how to implement a simple and effective transaction-per-request pattern: + *

+ * + *
{@code
+   * {
+   *   // start transaction
+   *   before("/api/*", (req, rsp) -> {
+   *     DataSource ds = req.require(DataSource.class);
+   *     Connection connection = ds.getConnection();
+   *     Transaction trx = connection.getTransaction();
+   *     trx.begin();
+   *     req.set("connection", connection);
+   *     return true;
+   *   });
+   *
+   *   // commit/rollback transaction
+   *   complete("/api/*", (req, rsp, cause) -> {
+   *     // unbind connection from request
+   *     try(Connection connection = req.unset("connection").get()) {
+   *       Transaction trx = connection.getTransaction();
+   *       if (cause.ifPresent()) {
+   *         trx.rollback();
+   *       } else {
+   *         trx.commit();
+   *       }
+   *     }
+   *   });
+   *
+   *   // your transactional routes goes here
+   *   get("/api/something", req -> {
+   *     Connection connection = req.get("connection");
+   *     // work with connection
+   *   });
+   * }
+   * }
+ * + * @param pattern Pattern to intercept. + * @param handler Complete handler. + * @param next Next handler. + * @return A new route definition. + */ + @Nonnull + default Route.Collection complete(String pattern, Route.Complete handler, Route.Complete... next) { + return complete("*", pattern, handler, next); + } + + /** + *

complete

+ * + * Allows for log and cleanup a request. It will be invoked after we send a response. + * + *
{@code
+   * {
+   *   complete("*", "*", (req, rsp, cause) -> {
+   *     // your code goes here
+   *   });
+   * }
+   * }
+ * + * You are NOT allowed to modify the request and response objects. The cause is an + * {@link Optional} with a {@link Throwable} useful to identify problems. + * + * The goal of the complete handler is to probably cleanup request object and log + * responses. + * + * A complete handler must to be registered before the actual handler you want to + * intercept. + * + *
{@code
+   * {
+   *   complete("*", "/path", (req, rsp, cause) -> {
+   *   });
+   *
+   *   get("/path", req -> {
+   *     return "hello";
+   *   });
+   * }
+   * }
+ * + * If you reverse the order then it won't work. + * + *

+ * Remember: routes are executed in the order they are defined and the pipeline + * is executed as long you don't generate a response. + *

+ * + *

example

+ *

+ * Suppose you have a transactional resource, like a database connection. The next example shows + * you how to implement a simple and effective transaction-per-request pattern: + *

+ * + *
{@code
+   * {
+   *   // start transaction
+   *   before((req, rsp) -> {
+   *     DataSource ds = req.require(DataSource.class);
+   *     Connection connection = ds.getConnection();
+   *     Transaction trx = connection.getTransaction();
+   *     trx.begin();
+   *     req.set("connection", connection);
+   *     return true;
+   *   });
+   *
+   *   // commit/rollback transaction
+   *   complete((req, rsp, cause) -> {
+   *     // unbind connection from request
+   *     try(Connection connection = req.unset("connection")) {
+   *       Transaction trx = connection.getTransaction();
+   *       if (cause.ifPresent()) {
+   *         trx.rollback();
+   *       } else {
+   *         trx.commit();
+   *       }
+   *     }
+   *   });
+   *
+   *   // your transactional routes goes here
+   *   get("/my-trx-route", req -> {
+   *     Connection connection = req.get("connection");
+   *     // work with connection
+   *   });
+   * }
+   * }
+ * + * @param method HTTP method to intercept. + * @param pattern Pattern to intercept. + * @param handler Complete handler. + * @return A new route definition. + */ + @Nonnull + Route.Definition complete(String method, String pattern, Route.Complete handler); + + /** + *

complete

+ * + * Allows for log and cleanup a request. It will be invoked after we send a response. + * + *
{@code
+   * {
+   *   complete("*", "*", (req, rsp, cause) -> {
+   *     // your code goes here
+   *   });
+   * }
+   * }
+ * + * You are NOT allowed to modify the request and response objects. The cause is an + * {@link Optional} with a {@link Throwable} useful to identify problems. + * + * The goal of the complete handler is to probably cleanup request object and log + * responses. + * + * A complete handler must to be registered before the actual handler you want to + * intercept. + * + *
{@code
+   * {
+   *   complete("*", "/path", (req, rsp, cause) -> {
+   *   });
+   *
+   *   get("/path", req -> {
+   *     return "hello";
+   *   });
+   * }
+   * }
+ * + * If you reverse the order then it won't work. + * + *

+ * Remember: routes are executed in the order they are defined and the pipeline + * is executed as long you don't generate a response. + *

+ * + *

example

+ *

+ * Suppose you have a transactional resource, like a database connection. The next example shows + * you how to implement a simple and effective transaction-per-request pattern: + *

+ * + *
{@code
+   * {
+   *   // start transaction
+   *   before((req, rsp) -> {
+   *     DataSource ds = req.require(DataSource.class);
+   *     Connection connection = ds.getConnection();
+   *     Transaction trx = connection.getTransaction();
+   *     trx.begin();
+   *     req.set("connection", connection);
+   *     return true;
+   *   });
+   *
+   *   // commit/rollback transaction
+   *   complete((req, rsp, cause) -> {
+   *     // unbind connection from request
+   *     try(Connection connection = req.unset("connection")) {
+   *       Transaction trx = connection.getTransaction();
+   *       if (cause.ifPresent()) {
+   *         trx.rollback();
+   *       } else {
+   *         trx.commit();
+   *       }
+   *     }
+   *   });
+   *
+   *   // your transactional routes goes here
+   *   get("/my-trx-route", req -> {
+   *     Connection connection = req.get("connection");
+   *     // work with connection
+   *   });
+   * }
+   * }
+ * + * @param method HTTP method to intercept. + * @param pattern Pattern to intercept. + * @param handler Complete handler. + * @param next Next handler. + * @return A new route definition. + */ + @Nonnull + default Route.Collection complete(String method, String pattern, Route.Complete handler, Route.Complete... next) { + List routes = new ArrayList<>(); + routes.add(complete(method, pattern, handler)); + Arrays.asList(next).stream() + .map(h -> complete(method, pattern, handler)) + .forEach(routes::add); + return new Route.Collection(routes.toArray(new Route.Definition[routes.size()])); + } + + /** + * Append a new WebSocket handler under the given path. + * + *
+   *   ws("/ws", (socket) {@literal ->} {
+   *     // connected
+   *     socket.onMessage(message {@literal ->} {
+   *       System.out.println(message);
+   *     });
+   *     socket.send("Connected"):
+   *   });
+   * 
+ * + * @param path A path pattern. + * @param handler A connect callback. + * @return A new WebSocket definition. + */ + @Nonnull + default WebSocket.Definition ws(final String path, final WebSocket.OnOpen1 handler) { + return ws(path, (WebSocket.OnOpen) handler); + } + + /** + * Append a new WebSocket handler under the given path. + * + *
+   *   ws("/ws", (req, socket) {@literal ->} {
+   *     // connected
+   *     socket.onMessage(message {@literal ->} {
+   *       System.out.println(message);
+   *     });
+   *     socket.send("Connected"):
+   *   });
+   * 
+ * + * @param path A path pattern. + * @param handler A connect callback. + * @return A new WebSocket definition. + */ + @Nonnull + WebSocket.Definition ws(String path, WebSocket.OnOpen handler); + + /** + * Append a new WebSocket handler under the given path. + * + *
+   *   ws(MyHandler.class);
+   * 
+ * + * @param handler A message callback. + * @param Message type. + * @return A new WebSocket definition. + */ + @Nonnull + default WebSocket.Definition ws(final Class> handler) { + return ws("", handler); + } + + /** + * Append a new WebSocket handler under the given path. + * + *
+   *   ws("/ws", MyHandler.class);
+   * 
+ * + * @param path A path pattern. + * @param handler A message callback. + * @param Message type. + * @return A new WebSocket definition. + */ + @Nonnull + WebSocket.Definition ws(String path, Class> handler); + + /** + * Add a server-sent event handler. + * + *
{@code
+   * {
+   *   sse("/path",(req, sse) -> {
+   *     // 1. connected
+   *     sse.send("data"); // 2. send/push data
+   *   });
+   * }
+   * }
+ * + * @param path Event path. + * @param handler Callback. It might executed in a different thread (web server choice). + * @return A route definition. + */ + @Nonnull + Route.Definition sse(String path, Sse.Handler handler); + + /** + * Add a server-sent event handler. + * + *
{@code
+   * {
+   *   sse("/path", sse -> {
+   *     // 1. connected
+   *     sse.send("data"); // 2. send/push data
+   *   });
+   * }
+   * }
+ * + * @param path Event path. + * @param handler Callback. It might executed in a different thread (web server choice). + * @return A route definition. + */ + @Nonnull + Route.Definition sse(String path, Sse.Handler1 handler); + + /** + * Apply common configuration and attributes to a group of routes: + * + *
{@code
+   * {
+   *   with(() -> {
+   *     get("/foo", ...);
+   *
+   *     get("/bar", ...);
+   *
+   *     get("/etc", ...);
+   *
+   *     ...
+   *   }).attr("v1", "k1")
+   *     .excludes("/public/**");
+   * }
+   * }
+ * + * All the routes wrapped by with will have a v1 attribute and will + * excludes/ignores a /public request. + * + * @param callback Route callback. + * @return A route collection. + */ + @Nonnull + Route.Collection with(Runnable callback); + + /** + * Apply the mapper to all the functional routes. + * + *
{@code
+   * {
+   *   mapper((Integer v) -> v * 2);
+   *
+   *   mapper(v -> Integer.parseInt(v.toString()));
+   *
+   *   get("/four", () -> "2");
+   * }
+   * }
+ * + * A call to /four outputs 4. Mapper are applied in reverse order. + * + * @param mapper Route mapper to append. + * @return This instance. + */ + @Nonnull + Router map(final Mapper mapper); + + /** + * Setup a route error handler. Default error handler {@link Err.DefHandler} does content + * negotation and this method allow to override/complement default handler. + * + * This is a catch all error handler. + * + *

html

+ * + * If a request has an Accept: text/html header. Then, the default err handler will + * ask to a {@link View.Engine} to render the err view. + * + * The default model has these attributes: + *
+   * message: exception string
+   * stacktrace: exception stack-trace as an array of string
+   * status: status code, like 400
+   * reason: status code reason, like BAD REQUEST
+   * 
+ * + * Here is a simply public/err.html error page: + * + *
+   * <html>
+   * <body>
+   * {{ "{{status" }}}}:{{ "{{reason" }}}}
+   * </body>
+   * </html>
+   * 
+ * + * HTTP status code will be set too. + * + * @param err A route error handler. + * @return This router. + */ + @Nonnull + Router err(Err.Handler err); + + /** + * Setup a custom error handler.The error handler will be executed if the current exception is an + * instance of given type type. + * + * All headers are reset while generating the error response. + * + * @param type Exception type. The error handler will be executed if the current exception is an + * instance of this type. + * @param handler A route error handler. + * @return This router. + */ + @Nonnull + default Router err(final Class type, final Err.Handler handler) { + return err((req, rsp, x) -> { + if (type.isInstance(x) || type.isInstance(x.getCause())) { + handler.handle(req, rsp, x); + } + }); + } + + /** + * Setup a route error handler. The error handler will be executed if current status code matches + * the one provided. + * + * All headers are reset while generating the error response. + * + * @param statusCode The status code to match. + * @param handler A route error handler. + * @return This router. + */ + @Nonnull + default Router err(final int statusCode, final Err.Handler handler) { + return err((req, rsp, x) -> { + if (statusCode == x.statusCode()) { + handler.handle(req, rsp, x); + } + }); + } + + /** + * Setup a route error handler. The error handler will be executed if current status code matches + * the one provided. + * + * All headers are reset while generating the error response. + * + * @param code The status code to match. + * @param handler A route error handler. + * @return This router. + */ + @Nonnull + default Router err(final Status code, final Err.Handler handler) { + return err((req, rsp, x) -> { + if (code.value() == x.statusCode()) { + handler.handle(req, rsp, x); + } + }); + } + + /** + * Setup a route error handler. The error handler will be executed if current status code matches + * the one provided. + * + * All headers are reset while generating the error response. + * + * @param predicate Apply the error handler if the predicate evaluates to true. + * @param handler A route error handler. + * @return This router. + */ + @Nonnull + default Router err(final Predicate predicate, final Err.Handler handler) { + return err((req, rsp, err) -> { + if (predicate.test(Status.valueOf(err.statusCode()))) { + handler.handle(req, rsp, err); + } + }); + } + + /** + * Produces a deferred response, useful for async request processing. By default a + * {@link Deferred} results run in the current thread. + * + * This is intentional because application code (your code) always run in a worker thread. There + * is a thread pool of 100 worker threads, defined by the property: + * server.threads.Max. + * + * That's why a {@link Deferred} result runs in the current thread (no need to use a new thread), + * unless you want to apply a different thread model and or use a reactive/async library. + * + * As a final thought you might want to reduce the number of worker thread if you are going to a + * build a full reactive/async application. + * + *

usage

+ * + *
+   * {
+   *    get("/async", promise(deferred {@literal ->} {
+   *      try {
+   *        deferred.resolve(...); // success value
+   *      } catch (Exception ex) {
+   *        deferred.reject(ex); // error value
+   *      }
+   *    }));
+   *  }
+   * 
+ * + *

+ * This method is useful for integrating reactive/async libraries. Here is an example on how to + * use RxJava: + *

+ * + *
{@code
+   * {
+   *    get("/rx", promise(deferred -> {
+   *      Observable.create(s -> {
+   *      s.onNext(...);
+   *      s.onCompleted();
+   *    }).subscribeOn(Schedulers.computation())
+   *      .subscribe(deferred::resolve, deferred::reject);
+   *   }));
+   * }
+   * }
+ * + *

+ * This is just an example because there is a Rx module. + *

+ * + *

+ * Checkout the {@link #deferred(org.jooby.Route.ZeroArgHandler)} methods to see how to use a + * plain {@link Executor}. + *

+ * + * @param initializer Deferred initializer. + * @return A new deferred handler. + * @see Deferred + */ + @Nonnull + Route.OneArgHandler promise(Deferred.Initializer initializer); + + /** + * Produces a deferred response, useful for async request processing. Like + * {@link #promise(org.jooby.Deferred.Initializer)} but allow you to specify an {@link Executor} + * to use. See {@link Jooby#executor(Executor)} and {@link Jooby#executor(String, Executor)}. + * + *

usage

+ * + *
+   * {
+   *    executor("forkjoin", new ForkJoinPool());
+   *
+   *    get("/async", promise("forkjoin", deferred {@literal ->} {
+   *      try {
+   *        deferred.resolve(...); // success value
+   *      } catch (Exception ex) {
+   *        deferred.reject(ex); // error value
+   *      }
+   *    }));
+   *  }
+   * 
+ * + *

+ * Checkout the {@link #deferred(org.jooby.Route.ZeroArgHandler)} methods to see how to use a + * plain {@link Executor}. + *

+ * + * @param executor Executor to run the deferred. + * @param initializer Deferred initializer. + * @return A new deferred handler. + * @see Deferred + */ + @Nonnull + Route.OneArgHandler promise(String executor, Deferred.Initializer initializer); + + /** + * Produces a deferred response, useful for async request processing. Like + * {@link #promise(org.jooby.Deferred.Initializer)} but give you access to {@link Request}. + * + *

usage

+ * + *
+   * {
+   *    ExecutorService executor = ...;
+   *
+   *    get("/async", promise((req, deferred) {@literal ->} {
+   *      executor.execute(() {@literal ->} {
+   *        try {
+   *          deferred.resolve(req.param("param").value()); // success value
+   *        } catch (Exception ex) {
+   *          deferred.reject(ex); // error value
+   *        }
+   *      });
+   *    }));
+   *  }
+   * 
+ * + * @param initializer Deferred initializer. + * @return A new deferred handler. + * @see Deferred + */ + @Nonnull + Route.OneArgHandler promise(Deferred.Initializer0 initializer); + + /** + * Produces a deferred response, useful for async request processing. Like + * {@link #promise(String, org.jooby.Deferred.Initializer)} but give you access to + * {@link Request}. + * + *

usage

+ * + *
+   * {
+   *    get("/async", promise("myexec", (req, deferred) {@literal ->} {
+   *      // resolve a success value
+   *      deferred.resolve(req.param("param").value());
+   *    }));
+   *  }
+   * 
+ * + * @param executor Executor to run the deferred. + * @param initializer Deferred initializer. + * @return A new deferred handler. + * @see Deferred + */ + @Nonnull + Route.OneArgHandler promise(String executor, Deferred.Initializer0 initializer); + + /** + * Functional version of {@link #promise(org.jooby.Deferred.Initializer)}. To use ideally with one + * or more {@link Executor}: + * + *
{@code
+   * {
+   *   executor("cached", Executors.newCachedExecutor());
+   *
+   *   get("/fork", deferred("cached", req -> {
+   *     return req.param("value").value();
+   *   }));
+   * }
+   * }
+ * + * This handler automatically {@link Deferred#resolve(Object)} or + * {@link Deferred#reject(Throwable)} a route handler response. + * + * @param executor Executor to run the deferred. + * @param handler Application block. + * @return A new deferred handler. + */ + @Nonnull + default Route.ZeroArgHandler deferred(final String executor, final Route.OneArgHandler handler) { + return () -> Deferred.deferred(executor, handler); + } + + /** + * Functional version of {@link #promise(org.jooby.Deferred.Initializer)}. + * + * Using the default executor (current thread): + * + *
{@code
+   * {
+   *   get("/fork", deferred(req -> {
+   *     return req.param("value").value();
+   *   }));
+   * }
+   * }
+ * + * Using a custom executor: + * + *
{@code
+   * {
+   *   executor(new ForkJoinPool());
+   *
+   *   get("/fork", deferred(req -> {
+   *     return req.param("value").value();
+   *   }));
+   * }
+   * }
+ * + * This handler automatically {@link Deferred#resolve(Object)} or + * {@link Deferred#reject(Throwable)} a route handler response. + * + * @param handler Application block. + * @return A new deferred handler. + */ + @Nonnull + default Route.ZeroArgHandler deferred(final Route.OneArgHandler handler) { + return () -> Deferred.deferred(handler); + } + + /** + * Functional version of {@link #promise(org.jooby.Deferred.Initializer)}. To use ideally with one + * or more {@link Executor}: + * + *
{@code
+   * {
+   *   executor("cached", Executors.newCachedExecutor());
+   *
+   *   get("/fork", deferred("cached", () -> {
+   *     return "OK";
+   *   }));
+   * }
+   * }
+ * + * This handler automatically {@link Deferred#resolve(Object)} or + * {@link Deferred#reject(Throwable)} a route handler response. + * + * @param executor Executor to run the deferred. + * @param handler Application block. + * @return A new deferred handler. + */ + @Nonnull + default Route.ZeroArgHandler deferred(final String executor, final Route.ZeroArgHandler handler) { + return () -> Deferred.deferred(executor, handler); + } + + /** + * Functional version of {@link #promise(org.jooby.Deferred.Initializer)}. + * + * Using the default executor (current thread): + * + *
{@code
+   * {
+   *   get("/fork", deferred(() -> {
+   *     return req.param("value").value();
+   *   }));
+   * }
+   * }
+ * + * Using a custom executor: + * + *
{@code
+   * {
+   *   executor(new ForkJoinPool());
+   *
+   *   get("/fork", deferred(() -> {
+   *     return req.param("value").value();
+   *   }));
+   * }
+   * }
+ * + * This handler automatically {@link Deferred#resolve(Object)} or + * {@link Deferred#reject(Throwable)} a route handler response. + * + * @param handler Application block. + * @return A new deferred handler. + */ + @Nonnull + default Route.ZeroArgHandler deferred(final Route.ZeroArgHandler handler) { + return () -> Deferred.deferred(handler); + } +} diff --git a/jooby/src/main/java/org/jooby/Session.java b/jooby/src/main/java/org/jooby/Session.java new file mode 100644 index 00000000..41909a40 --- /dev/null +++ b/jooby/src/main/java/org/jooby/Session.java @@ -0,0 +1,581 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import com.google.common.io.BaseEncoding; +import static java.util.Objects.requireNonNull; + +import javax.annotation.Nonnull; +import java.security.SecureRandom; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + *

+ * Sessions are created on demand via: {@link Request#session()}. + *

+ * + *

+ * Sessions have a lot of uses cases but most commons are: auth, store information about current + * user, etc. + *

+ * + *

+ * A session attribute must be {@link String} or a primitive. Session doesn't allow to store + * arbitrary objects. It is a simple mechanism to store basic data. + *

+ * + *

Session configuration

+ * + *

No timeout

+ *

+ * There is no timeout for sessions from server perspective. By default, a session will expire when + * the user close the browser (a.k.a session cookie). + *

+ * + *

Session store

+ *

+ * A {@link Session.Store} is responsible for saving session data. Sessions are kept in memory, by + * default using the {@link Session.Mem} store, which is useful for development, but wont scale well + * on production environments. An redis, memcached, ehcache store will be a better option. + *

+ * + *

Store life-cycle

+ *

+ * Sessions are persisted every time a request exit, if they are dirty. A session get dirty if an + * attribute is added or removed from it. + *

+ *

+ * The session.saveInterval property indicates how frequently a session will be + * persisted (in millis). + *

+ *

+ * In short, a session is persisted when: 1) it is dirty; or 2) save interval has expired it. + *

+ * + *

Cookie configuration

+ *

+ * Next session describe the most important options: + *

+ * + *

max-age

+ *

+ * The session.cookie.maxAge sets the maximum age in seconds. A positive value + * indicates that the cookie will expire after that many seconds have passed. Note that the value is + * the maximum age when the cookie will expire, not the cookie's current age. + * + * A negative value means that the cookie is not stored persistently and will be deleted when the + * Web browser exits. + * + * Default maxAge is: -1. + * + *

+ * + *

signed cookie

+ *

+ * If the application.secret property has been set, then the session cookie will be + * signed it with it. + *

+ * + *

cookie's name

+ *

+ * The session.cookie.name indicates the name of the cookie that hold the session ID, + * by defaults: jooby.sid. Cookie's name can be explicitly set with + * {@link Cookie.Definition#name(String)} on {@link Session.Definition#cookie()}. + *

+ * + * @author edgar + * @since 0.1.0 + */ +public interface Session { + + /** + * Throw when session access is required but the session has been destroyed.\ + * + * See {@link Session#destroy()}. + */ + class Destroyed extends RuntimeException { + public Destroyed() { + super("Session has been destroyed."); + } + } + + /** Global/Shared id of cookie sessions. */ + String COOKIE_SESSION = "cookieSession"; + + /** + * Hold session related configuration parameters. + * + * @author edgar + * @since 0.1.0 + */ + class Definition { + + /** Session store. */ + private Object store; + + /** Session cookie. */ + private Cookie.Definition cookie; + + /** Save interval. */ + private Long saveInterval; + + /** + * Creates a new session definition. + * + * @param store A session store. + */ + public Definition(final Class store) { + this.store = requireNonNull(store, "A session store is required."); + cookie = new Cookie.Definition(); + } + + /** + * Creates a new session definition with a client store. + */ + Definition() { + cookie = new Cookie.Definition(); + } + + /** + * Creates a new session definition. + * + * @param store A session store. + */ + public Definition(final Store store) { + this.store = requireNonNull(store, "A session store is required."); + cookie = new Cookie.Definition(); + } + + /** + * Indicates how frequently a no-dirty session should be persisted (in millis). + * + * @return A save interval that indicates how frequently no dirty session should be persisted. + */ + public Optional saveInterval() { + return Optional.ofNullable(saveInterval); + } + + /** + * Set/override how frequently a no-dirty session should be persisted (in millis). + * + * @param saveInterval Save interval in millis or -1 for turning it off. + * @return This definition. + */ + public Definition saveInterval(final long saveInterval) { + this.saveInterval = saveInterval; + return this; + } + + /** + * @return A session store instance or class. + */ + public Object store() { + return store; + } + + /** + * @return Configure cookie session. + */ + public Cookie.Definition cookie() { + return cookie; + } + } + + /** + * Read, save and delete sessions from a persistent storage. + * + * @author edgar + * @since 0.1.0 + */ + interface Store { + + /** Single secure random instance. */ + SecureRandom rnd = new SecureRandom(); + + /** + * Get a session by ID (if any). + * + * @param builder A session builder. + * @return A session or null. + */ + Session get(Session.Builder builder); + + /** + * Save/persist a session. + * + * @param session A session to be persisted. + */ + void save(Session session); + + void create(final Session session); + + /** + * Delete a session by ID. + * + * @param id A session ID. + */ + void delete(String id); + + /** + * Generate a session ID. + * + * @return A unique session ID. + */ + default String generateID() { + byte[] bytes = new byte[30]; + rnd.nextBytes(bytes); + return BaseEncoding.base64Url().encode(bytes); + } + } + + /** + * A keep in memory session store. + * + * @author edgar + */ + class Mem implements Store { + + private ConcurrentMap sessions = new ConcurrentHashMap(); + + @Override + public void create(final Session session) { + sessions.putIfAbsent(session.id(), session); + } + + @Override + public void save(final Session session) { + sessions.put(session.id(), session); + } + + @Override + public Session get(final Session.Builder builder) { + return sessions.get(builder.sessionId()); + } + + @Override + public void delete(final String id) { + sessions.remove(id); + } + + } + + /** + * Build or restore a session from a persistent storage. + * + * @author edgar + */ + interface Builder { + + /** + * @return Session ID. + */ + String sessionId(); + + /** + * Set a session local attribute. + * + * @param name Attribute's name. + * @param value Attribute's value. + * @return This builder. + */ + Builder set(final String name, final String value); + + /** + * Set one ore more session local attributes. + * + * @param attributes Attributes to add. + * @return This builder. + */ + Builder set(final Map attributes); + + /** + * Set session created date. + * + * @param createdAt Session created date. + * @return This builder. + */ + Builder createdAt(long createdAt); + + /** + * Set session last accessed date. + * + * @param accessedAt Session last accessed date. + * @return This builder. + */ + Builder accessedAt(long accessedAt); + + /** + * Set session last saved it date. + * + * @param savedAt Session last saved it date. + * @return This builder. + */ + Builder savedAt(final long savedAt); + + /** + * Final step to build a new session. + * + * @return A session. + */ + Session build(); + + } + + /** + * A session ID for server side sessions. Otherwise {@link #COOKIE_SESSION} for client side sessions. + * + * Session ID on client sessions doesn't make sense because resolution of session is done via + * cookie name. + * + * Another reason of not saving the session ID inside the cookie, is the cookie size (up to 4kb). + * If the session ID is persisted then users lost space to save business data. + * + * @return Session ID. + */ + @Nonnull + String id(); + + /** + * The time when this session was created, measured in milliseconds since midnight January 1, 1970 + * GMT for server side sessions. Or -1 for client side sessions. + * + * @return The time when this session was created, measured in milliseconds since midnight January + * 1, 1970 GMT for server side sessions. Or -1 for client side sessions. + */ + long createdAt(); + + /** + * Last time the session was save it as epoch millis or -1 for client side sessions. + * + * @return Last time the session was save it as epoch millis or -1 for client side + * sessions. + */ + long savedAt(); + + /** + * The last time the client sent a request associated with this session, as the number of + * milliseconds since midnight January 1, 1970 GMT, and marked by the time the container + * received the request. Or -1 for client side sessions. + * + *

+ * Actions that your application takes, such as getting or setting a value associated with the + * session, do not affect the access time. + *

+ * + * @return Last time the client sent a request. Or -1 for client side sessions. + */ + long accessedAt(); + + /** + * The time when this session is going to expire, measured in milliseconds since midnight + * January 1, 1970 GMT. Or -1 for client side sessions. + * + * @return The time when this session is going to expire, measured in milliseconds since midnight + * January 1, 1970 GMT. Or -1 for client side sessions. + */ + long expiryAt(); + + /** + * Get a object from this session. If the object isn't found this method returns an empty + * optional. + * + * @param name Attribute's name. + * @return Value as mutant. + */ + @Nonnull + Mutant get(final String name); + + /** + * @return An immutable copy of local attributes. + */ + @Nonnull + Map attributes(); + + /** + * Test if the var name exists inside the session local attributes. + * + * @param name A local var's name. + * @return True, for existing locals. + */ + boolean isSet(final String name); + + /** + * Set a session local using a the given name. If a local already exists, it will be replaced + * with the new value. Keep in mind that null values are NOT allowed. + * + * @param name Attribute's name. + * @param value Attribute's value. + * @return This session. + */ + @Nonnull + default Session set(final String name, final byte value) { + return set(name, Byte.toString(value)); + } + + /** + * Set a session local using a the given name. If a local already exists, it will be replaced + * with the new value. Keep in mind that null values are NOT allowed. + * + * @param name Attribute's name. + * @param value Attribute's value. + * @return This session. + */ + @Nonnull + default Session set(final String name, final char value) { + return set(name, Character.toString(value)); + } + + /** + * Set a session local using a the given name. If a local already exists, it will be replaced + * with the new value. Keep in mind that null values are NOT allowed. + * + * @param name Attribute's name. + * @param value Attribute's value. + * @return This session. + */ + @Nonnull + default Session set(final String name, final boolean value) { + return set(name, Boolean.toString(value)); + } + + /** + * Set a session local using a the given name. If a local already exists, it will be replaced + * with the new value. Keep in mind that null values are NOT allowed. + * + * @param name Attribute's name. + * @param value Attribute's value. + * @return This session. + */ + @Nonnull + default Session set(final String name, final short value) { + return set(name, Short.toString(value)); + } + + /** + * Set a session local using a the given name. If a local already exists, it will be replaced + * with the new value. Keep in mind that null values are NOT allowed. + * + * @param name Attribute's name. + * @param value Attribute's value. + * @return This session. + */ + @Nonnull + default Session set(final String name, final int value) { + return set(name, Integer.toString(value)); + } + + /** + * Set a session local using a the given name. If a local already exists, it will be replaced + * with the new value. Keep in mind that null values are NOT allowed. + * + * @param name Attribute's name. + * @param value Attribute's value. + * @return This session. + */ + @Nonnull + default Session set(final String name, final long value) { + return set(name, Long.toString(value)); + } + + /** + * Set a session local using a the given name. If a local already exists, it will be replaced + * with the new value. Keep in mind that null values are NOT allowed. + * + * @param name Attribute's name. + * @param value Attribute's value. + * @return This session. + */ + @Nonnull + default Session set(final String name, final float value) { + return set(name, Float.toString(value)); + } + + /** + * Set a session local using a the given name. If a local already exists, it will be replaced + * with the new value. Keep in mind that null values are NOT allowed. + * + * @param name Attribute's name. + * @param value Attribute's value. + * @return This session. + */ + @Nonnull + default Session set(final String name, final double value) { + return set(name, Double.toString(value)); + } + + /** + * Set a session local using a the given name. If a local already exists, it will be replaced + * with the new value. Keep in mind that null values are NOT allowed. + * + * @param name Attribute's name. + * @param value Attribute's value. + * @return This session. + */ + @Nonnull + default Session set(final String name, final CharSequence value) { + return set(name, value.toString()); + } + + /** + * Set a session local using a the given name. If a local already exists, it will be replaced + * with the new value. Keep in mind that null values are NOT allowed. + * + * @param name Attribute's name. + * @param value Attribute's value. + * @return This session. + */ + @Nonnull + Session set(final String name, final String value); + + /** + * Remove a local value (if any) from session locals. + * + * @param name Attribute's name. + * @return Existing value or empty optional. + */ + @Nonnull + Mutant unset(final String name); + + /** + * Unset/remove all the session data. + * + * @return This session. + */ + @Nonnull + Session unset(); + + /** + * Invalidates this session then unset any objects bound to it. This is a noop if the session has + * been destroyed. + */ + void destroy(); + + /** + * True if the session was {@link #destroy()}. + * + * @return True if the session was {@link #destroy()}. + */ + boolean isDestroyed(); + + /** + * Assign a new ID to the existing session. + * @return This session. + */ + Session renewId(); +} diff --git a/jooby/src/main/java/org/jooby/Sse.java b/jooby/src/main/java/org/jooby/Sse.java new file mode 100644 index 00000000..113b4b21 --- /dev/null +++ b/jooby/src/main/java/org/jooby/Sse.java @@ -0,0 +1,886 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import com.google.common.collect.ImmutableList; +import com.google.inject.Injector; +import com.google.inject.Key; +import com.google.inject.TypeLiteral; +import com.google.inject.name.Names; +import static java.util.Objects.requireNonNull; +import org.jooby.Route.Chain; +import org.jooby.internal.SseRenderer; +import org.jooby.funzy.Throwing; +import org.jooby.funzy.Try; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nonnull; +import java.io.IOException; +import java.nio.channels.ClosedChannelException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** + *

Server Sent Events

+ *

+ * Server-Sent Events (SSE) is a mechanism that allows server to push the data from the server to + * the client once the client-server connection is established by the client. Once the connection is + * established by the client, it is the server who provides the data and decides to send it to the + * client whenever new chunk of data is available. + *

+ * + *

usage

+ * + *
{@code
+ * {
+ *   sse("/path", sse -> {
+ *     // 1. connected
+ *     sse.send("data"); // 2. send/push data
+ *   });
+ * }
+ * }
+ * + *

+ * Simple, effective and easy to use. The callback will be executed once when a new client is + * connected. Inside the callback we can send data, listen for connection close events, etc. + *

+ * + *

+ * There is a factory method {@link #event(Object)} that let you set event attributes: + *

+ * + *
{@code
+ * {
+ *   sse("/path", sse -> {
+ *     sse.event("data")
+ *         .id("id")
+ *         .name("myevent")
+ *         .retry(5000L)
+ *         .send();
+ *   });
+ * }
+ * }
+ * + *

structured data

+ *

+ * Beside raw/string data you can also send structured data, like json, + * xml, etc.. + *

+ * + *

+ * The next example will send two message one in json format and one in + * text/plain format: + *

+ * : + * + *
{@code
+ * {
+ *   use(new MyJsonRenderer());
+ *
+ *   sse("/path", sse -> {
+ *     MyObject object = ...
+ *     sse.send(object, "json");
+ *     sse.send(object, "plain");
+ *   });
+ * }
+ * }
+ * + *

+ * Or if your need only one format, just: + *

+ * + *
{@code
+ * {
+ *   use(new MyJsonRenderer());
+ *
+ *   sse("/path", sse -> {
+ *     MyObject object = ...
+ *     sse.send(object);
+ *   }).produces("json"); // by default always send json
+ * }
+ * }
+ * + *

request params

+ *

+ * We provide request access via two arguments callback: + *

+ * + *
{@code
+ * {
+ *   sse("/events/:id", (req, sse) -> {
+ *     String id = req.param("id").value();
+ *     MyObject object = findObject(id);
+ *     sse.send(object);
+ *   });
+ * }
+ * }
+ * + *

connection lost

+ *

+ * The {@link #onClose(Throwing.Runnable)} callback allow you to clean and release resources on + * connection close. A connection is closed by calling {@link #close()} or when the client/browser + * close the connection. + *

+ * + *
{@code
+ * {
+ *   sse("/events/:id", sse -> {
+ *     sse.onClose(() -> {
+ *       // clean up resources
+ *     });
+ *   });
+ * }
+ * }
+ * + *

+ * The close event will be generated if you try to send an event on a closed connection. + *

+ * + *

keep alive time

+ *

+ * The keep alive time feature can be used to prevent connections from timing out: + *

+ * + *
{@code
+ * {
+ *   sse("/events/:id", sse -> {
+ *     sse.keepAlive(15, TimeUnit.SECONDS);
+ *   });
+ * }
+ * }
+ * + *

+ * The previous example will sent a ':' message (empty comment) every 15 seconds to + * keep the connection alive. If the client drop the connection, then the + * {@link #onClose(Throwing.Runnable)} event will be fired it. + *

+ * + *

+ * This feature is useful when you want to detect {@link #onClose(Throwing.Runnable)} events without + * waiting for the next time you send a new event. But for example, if your application already + * generate events every 15s, then the use of keep alive is useless and you can avoid it. + *

+ * + *

require

+ *

+ * The {@link #require(Class)} methods let you access to application services: + *

+ * + *
{@code
+ * {
+ *   sse("/events/:id", sse -> {
+ *     MyService service = sse.require(MyService.class);
+ *   });
+ * }
+ * }
+ * + *

example

+ *

+ * The next example will generate a new event every 60s. It recovers from a server shutdown by using + * the {@link #lastEventId()} and clean resources on connection close. + *

+ *
{@code
+ * {
+ *   // creates an executor service
+ *   ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
+ *
+ *   sse("/events", sse -> {
+ *     // if we go down, recover from last event ID we sent. Otherwise, start from zero.
+ *     int lastId = sse.lastEventId(Integer.class).orElse(0);
+ *
+ *     AtomicInteger next = new AtomicInteger(lastId);
+ *
+ *     // send events every 60s
+ *     ScheduledFuture future = executor.scheduleAtFixedRate(() -> {
+ *        Integer id = next.incrementAndGet();
+ *        Object data = findDataById(id);
+ *
+ *        // send data and id
+ *        sse.event(data).id(id).send();
+ *      }, 0, 60, TimeUnit.SECONDS);
+ *
+ *      // on connection lost, cancel 60s task
+ *      sse.onClose(() -> {
+ *       future.cancel(true);
+ *      });
+ *   });
+ * }
+ *
+ * }
+ * + * @author edgar + * @since 1.0.0.CR + */ +public abstract class Sse implements AutoCloseable { + + /** + * Event representation of Server sent event. + * + * @author edgar + * @since 1.0.0.CR + */ + public static class Event { + private Object id; + + private String name; + + private Object data; + + private Long retry; + + private MediaType type; + + private String comment; + + private Sse sse; + + private Event(final Sse sse, final Object data) { + this.sse = sse; + this.data = data; + } + + /** + * @return Event data (if any). + */ + public Optional data() { + return Optional.ofNullable(data); + } + + /** + * Event media type helps to render or format event data. + * + * @return Event media type (if any). + */ + public Optional type() { + return Optional.ofNullable(type); + } + + /** + * Set event media type. Useful for sengin json, xml, etc.. + * + * @param type Media Type. + * @return This event. + */ + public Event type(final MediaType type) { + this.type = requireNonNull(type, "Type is required."); + return this; + } + + /** + * Set event media type. Useful for sengin json, xml, etc.. + * + * @param type Media Type. + * @return This event. + */ + public Event type(final String type) { + return type(MediaType.valueOf(type)); + } + + /** + * @return Event id (if any). + */ + public Optional id() { + return Optional.ofNullable(id); + } + + /** + * Set event id. + * + * @param id An event id. + * @return This event. + */ + public Event id(final Object id) { + this.id = requireNonNull(id, "Id is required."); + return this; + } + + /** + * @return Event name (a.k.a type). + */ + public Optional name() { + return Optional.ofNullable(name); + } + + /** + * Set event name (a.k.a type). + * + * @param name Event's name. + * @return This event. + */ + public Event name(final String name) { + this.name = requireNonNull(name, "Name is required."); + return this; + } + + /** + * Clients (browsers) will attempt to reconnect every 3 seconds. The retry option allow you to + * specify the number of millis a browser should wait before try to reconnect. + * + * @param retry Retry value. + * @param unit Time unit. + * @return This event. + */ + public Event retry(final int retry, final TimeUnit unit) { + this.retry = unit.toMillis(retry); + return this; + } + + /** + * Clients (browsers) will attempt to reconnect every 3 seconds. The retry option allow you to + * specify the number of millis a browser should wait before try to reconnect. + * + * @param retry Retry value in millis. + * @return This event. + */ + public Event retry(final long retry) { + this.retry = retry; + return this; + } + + /** + * @return Event comment (if any). + */ + public Optional comment() { + return Optional.ofNullable(comment); + } + + /** + * Set event comment. + * + * @param comment An event comment. + * @return This event. + */ + public Event comment(final String comment) { + this.comment = requireNonNull(comment, "Comment is required."); + return this; + } + + /** + * @return Retry event line (if any). + */ + public Optional retry() { + return Optional.ofNullable(retry); + } + + /** + * Send an event and optionally listen for success confirmation or error: + * + *
{@code
+     * sse.event(data).send().onSuccess(id -> {
+     *   // success
+     * }).onFailure(cause -> {
+     *   // handle error
+     * });
+     * }
+ * + * @return A future callback. + */ + public CompletableFuture> send() { + CompletableFuture> future = sse.send(this); + this.id = null; + this.name = null; + this.data = null; + this.type = null; + this.sse = null; + return future; + } + + } + + /** + * Server-sent event handler. + * + * @author edgar + * @since 1.0.0.CR + */ + public interface Handler extends Route.Filter { + + @Override + default void handle(final Request req, final Response rsp, final Chain chain) throws Throwable { + Sse sse = req.require(Sse.class); + String path = req.path(); + rsp.send(new Deferred(deferred -> { + try { + sse.handshake(req, () -> { + Try.run(() -> handle(req, sse)) + .onSuccess(() -> deferred.resolve(null)) + .onFailure(ex -> { + deferred.reject(ex); + Logger log = LoggerFactory.getLogger(Sse.class); + log.error("execution of {} resulted in error", path, ex); + }); + }); + } catch (Exception ex) { + deferred.reject(ex); + } + })); + } + + /** + * Event handler. + * + * @param req Current request. + * @param sse Sse object. + * @throws Exception If something goes wrong. + */ + void handle(Request req, Sse sse) throws Exception; + } + + /** + * Single argument event handler. + * + * @author edgar + * @since 1.0.0.CR + */ + public interface Handler1 extends Handler { + @Override + default void handle(final Request req, final Sse sse) throws Exception { + handle(sse); + } + + void handle(Sse sse) throws Exception; + } + + /* package */static class KeepAlive implements Runnable { + + /** The logging system. */ + private final Logger log = LoggerFactory.getLogger(Sse.class); + + private Sse sse; + + private long retry; + + public KeepAlive(final Sse sse, final long retry) { + this.sse = sse; + this.retry = retry; + } + + @Override + public void run() { + String sseId = sse.id(); + log.debug("running heart beat for {}", sseId); + Try.run(() -> sse.send(Optional.of(sseId), HEART_BEAT).whenComplete((id, x) -> { + if (x != null) { + log.debug("connection lost for {}", sseId, x); + sse.fireCloseEvent(); + Try.run(sse::close); + } else { + log.debug("reschedule heart beat for {}", id); + // reschedule + sse.keepAlive(retry); + } + })); + } + + } + + /** Keep alive scheduler. */ + private static final ScheduledExecutorService scheduler = Executors + .newSingleThreadScheduledExecutor(r -> { + Thread thread = new Thread(r, "sse-heartbeat"); + thread.setDaemon(true); + return thread; + }); + + /** Empty comment. */ + static final byte[] HEART_BEAT = ":\n".getBytes(StandardCharsets.UTF_8); + + /** The logging system. */ + protected final Logger log = LoggerFactory.getLogger(Sse.class); + + private Injector injector; + + private List renderers; + + private final String id; + + private List produces; + + private Map locals; + + private AtomicReference onclose = new AtomicReference<>(null); + + private Mutant lastEventId; + + private boolean closed; + + private Locale locale; + + public Sse() { + id = UUID.randomUUID().toString(); + } + + protected void handshake(final Request req, final Runnable handler) throws Exception { + this.injector = req.require(Injector.class); + this.renderers = ImmutableList.copyOf(injector.getInstance(Renderer.KEY)); + this.produces = req.route().produces(); + this.locals = req.attributes(); + this.lastEventId = req.header("Last-Event-ID"); + this.locale = req.locale(); + handshake(handler); + } + + protected abstract void handshake(Runnable handler) throws Exception; + + /** + * A unique ID (like a session ID). + * + * @return Sse unique ID (like a session ID). + */ + @Nonnull + public String id() { + return id; + } + + /** + * Server sent event will send a Last-Event-ID header if the server goes down. + * + * @return Last event id. + */ + @Nonnull + public Optional lastEventId() { + return lastEventId(String.class); + } + + /** + * Server sent event will send a Last-Event-ID header if the server goes down. + * + * @param type Last event id type. + * @param Event id type. + * @return Last event id. + */ + @Nonnull + public Optional lastEventId(final Class type) { + return lastEventId.toOptional(type); + } + + /** + * Listen for connection close (usually client drop the connection). This method is useful for + * resources cleanup. + * + * @param task Task to run. + * @return This instance. + */ + @Nonnull + public Sse onClose(final Throwing.Runnable task) { + onclose.set(task); + return this; + } + + /** + * Send an event and set media type. + * + *
{@code
+   *   sse.send(new MyObject(), "json");
+   * }
+ * + *
{@code
+   *   sse.send(new MyObject(), "json").whenComplete((id, x) -> {
+   *     if (x == null) {
+   *       handleSuccess();
+   *     } else {
+   *       handleError(x);
+   *     }
+   *   });
+   * }
+ * + * The id of the success callback correspond to the {@link Event#id()}. + * + * @param data Event data. + * @param type Media type, like: json, xml. + * @return A future. The success callback contains the {@link Event#id()}. + */ + @Nonnull + public CompletableFuture> send(final Object data, final String type) { + return send(data, MediaType.valueOf(type)); + } + + /** + * Send an event and set media type. + * + *
{@code
+   *   sse.send(new MyObject(), "json");
+   * }
+ * + *
{@code
+   *   sse.send(new MyObject(), "json").whenComplete((id, x) -> {
+   *     if (x == null) {
+   *       handleSuccess();
+   *     } else {
+   *       handleError(x);
+   *     }
+   *   });
+   * }
+ * + * The id of the success callback correspond to the {@link Event#id()}. + * + * @param data Event data. + * @param type Media type, like: json, xml. + * @return A future. The success callback contains the {@link Event#id()}. + */ + @Nonnull + public CompletableFuture> send(final Object data, final MediaType type) { + return event(data).type(type).send(); + } + + /** + * Send an event. + * + *
{@code
+   *   sse.send(new MyObject());
+   * }
+ * + *
{@code
+   *   sse.send(new MyObject(), "json").whenComplete((id, x) -> {
+   *     if (x == null) {
+   *       handleSuccess();
+   *     } else {
+   *       handleError(x);
+   *     }
+   *   });
+   * }
+ * + * The id of the success callback correspond to the {@link Event#id()}. + * + * @param data Event data. + * @return A future. The success callback contains the {@link Event#id()}. + */ + @Nonnull + public CompletableFuture> send(final Object data) { + return event(data).send(); + } + + /** + * Factory method for creating {@link Event} instances. + * + * Please note event won't be sent unless you call {@link Event#send()}: + * + *
{@code
+   *   sse.event(new MyObject()).send();
+   * }
+ * + * The factory allow you to set event attributes: + * + *
{@code
+   *   // send data
+   *   MyObject data = ...;
+   *   sse.event(data).send();
+   *
+   *   // send data with event name
+   *   sse.event(data).name("myevent").send();
+   *
+   *   // send data with event name and id
+   *   sse.event(data).name("myevent").id(id).send();
+   *
+   *   // send data with event name, id and retry interval
+   *   sse.event(data).name("myevent").id(id).retry(1500).send();
+   * }
+ * + * @param data Event data. + * @return A new event. + */ + @Nonnull + public Event event(final Object data) { + return new Event(this, data); + } + + /** + * Ask Guice for the given type. + * + * @param type A service type. + * @param Service type. + * @return A ready to use object. + */ + @Nonnull + public T require(final Class type) { + return require(Key.get(type)); + } + + /** + * Ask Guice for the given type. + * + * @param name A service name. + * @param type A service type. + * @param Service type. + * @return A ready to use object. + */ + @Nonnull + public T require(final String name, final Class type) { + return require(Key.get(type, Names.named(name))); + } + + /** + * Ask Guice for the given type. + * + * @param type A service type. + * @param Service type. + * @return A ready to use object. + */ + @Nonnull + public T require(final TypeLiteral type) { + return require(Key.get(type)); + } + + /** + * Ask Guice for the given type. + * + * @param key A service key. + * @param Service type. + * @return A ready to use object. + */ + @Nonnull + public T require(final Key key) { + return injector.getInstance(key); + } + + /** + * The keep alive time can be used to prevent connections from timing out: + * + *
{@code
+   * {
+   *   sse("/events/:id", sse -> {
+   *     sse.keepAlive(15, TimeUnit.SECONDS);
+   *   });
+   * }
+   * }
+ * + *

+ * The previous example will sent a ':' message (empty comment) every 15 seconds to + * keep the connection alive. If the client drop the connection, then the + * {@link #onClose(Throwing.Runnable)} event will be fired it. + *

+ * + *

+ * This feature is useful when you want to detect {@link #onClose(Throwing.Runnable)} events without + * waiting until you send a new event. But for example, if your application already generate + * events + * every 15s, then the use of keep alive is useless and you should avoid it. + *

+ * + * @param time Keep alive time. + * @param unit Time unit. + * @return This instance. + */ + @Nonnull + public Sse keepAlive(final int time, final TimeUnit unit) { + return keepAlive(unit.toMillis(time)); + } + + /** + * The keep alive time can be used to prevent connections from timing out: + * + *
{@code
+   * {
+   *   sse("/events/:id", sse -> {
+   *     sse.keepAlive(15, TimeUnit.SECONDS);
+   *   });
+   * }
+   * }
+ * + *

+ * The previous example will sent a ':' message (empty comment) every 15 seconds to + * keep the connection alive. If the client drop the connection, then the + * {@link #onClose(Throwing.Runnable)} event will be fired it. + *

+ * + *

+ * This feature is useful when you want to detect {@link #onClose(Throwing.Runnable)} events without + * waiting until you send a new event. But for example, if your application already generate + * events + * every 15s, then the use of keep alive is useless and you should avoid it. + *

+ * + * @param millis Keep alive time in millis. + * @return This instance. + */ + @Nonnull + public Sse keepAlive(final long millis) { + scheduler.schedule(new KeepAlive(this, millis), millis, TimeUnit.MILLISECONDS); + return this; + } + + /** + * Close the connection and fire an {@link #onClose(Throwing.Runnable)} event. + */ + @Override + public final void close() throws Exception { + closeAll(); + } + + private void closeAll() { + synchronized (this) { + if (!closed) { + closed = true; + fireCloseEvent(); + closeInternal(); + } + } + } + + protected abstract void closeInternal(); + + protected abstract CompletableFuture> send(Optional id, byte[] data); + + protected void ifClose(final Throwable cause) { + if (shouldClose(cause)) { + closeAll(); + } + } + + protected void fireCloseEvent() { + Throwing.Runnable task = onclose.getAndSet(null); + if (task != null) { + Try.run(task).onFailure(ex -> log.error("close callback resulted in error", ex)); + } + } + + protected boolean shouldClose(final Throwable ex) { + if (ex instanceof IOException) { + // is there a better way? + boolean brokenPipe = Optional.ofNullable(ex.getMessage()) + .map(m -> m.toLowerCase().contains("broken pipe")) + .orElse(false); + return brokenPipe || ex instanceof ClosedChannelException; + } + return false; + } + + private CompletableFuture> send(final Event event) { + List produces = event.type().>map(ImmutableList::of) + .orElse(this.produces); + SseRenderer ctx = new SseRenderer(renderers, produces, StandardCharsets.UTF_8, locale, locals); + return Try.apply(() -> { + byte[] bytes = ctx.format(event); + return send(event.id(), bytes); + }).recover(x -> { + CompletableFuture> future = new CompletableFuture<>(); + future.completeExceptionally(x); + return future; + }) + .get(); + } + +} diff --git a/jooby/src/main/java/org/jooby/Status.java b/jooby/src/main/java/org/jooby/Status.java new file mode 100644 index 00000000..9943f079 --- /dev/null +++ b/jooby/src/main/java/org/jooby/Status.java @@ -0,0 +1,467 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import java.util.HashMap; +import java.util.Map; + +/** + * HTTP status codes. + * + *

+ * This code has been kindly borrowed from Spring. + *

+ * + * @author Arjen Poutsma + * @see HTTP Status Code Registry + * @see List of HTTP status codes - + * Wikipedia + */ +public class Status { + + private static final Map statusMap = new HashMap<>(); + + // 1xx Informational + + /** + * {@code 100 Continue}. + * + * @see HTTP/1.1 + */ + public static final Status CONTINUE = new Status(100, "Continue"); + /** + * {@code 101 Switching Protocols}. + * + * @see HTTP/1.1 + */ + public static final Status SWITCHING_PROTOCOLS = new Status(101, "Switching Protocols"); + /** + * {@code 102 Processing}. + * + * @see WebDAV + */ + public static final Status PROCESSING = new Status(102, "Processing"); + /** + * {@code 103 Checkpoint}. + * + * @see A proposal for + * supporting resumable POST/PUT HTTP requests in HTTP/1.0 + */ + public static final Status CHECKPOINT = new Status(103, "Checkpoint"); + + // 2xx Success + + /** + * {@code 200 OK}. + * + * @see HTTP/1.1 + */ + public static final Status OK = new Status(200, "Success"); + + /** + * {@code 201 Created}. + * + * @see HTTP/1.1 + */ + public static final Status CREATED = new Status(201, "Created"); + /** + * {@code 202 Accepted}. + * + * @see HTTP/1.1 + */ + public static final Status ACCEPTED = new Status(202, "Accepted"); + /** + * {@code 203 Non-Authoritative Information}. + * + * @see HTTP/1.1 + */ + public static final Status NON_AUTHORITATIVE_INFORMATION = new Status(203, "Non-Authoritative Information"); + /** + * {@code 204 No Content}. + * + * @see HTTP/1.1 + */ + public static final Status NO_CONTENT = new Status(204, "No Content"); + /** + * {@code 205 Reset Content}. + * + * @see HTTP/1.1 + */ + public static final Status RESET_CONTENT = new Status(205, "Reset Content"); + /** + * {@code 206 Partial Content}. + * + * @see HTTP/1.1 + */ + public static final Status PARTIAL_CONTENT = new Status(206, "Partial Content"); + /** + * {@code 207 Multi-Status}. + * + * @see WebDAV + */ + public static final Status MULTI_STATUS = new Status(207, "Multi-Status"); + /** + * {@code 208 Already Reported}. + * + * @see WebDAV Binding Extensions + */ + public static final Status ALREADY_REPORTED = new Status(208, "Already Reported"); + /** + * {@code 226 IM Used}. + * + * @see Delta encoding in HTTP + */ + public static final Status IM_USED = new Status(226, "IM Used"); + + // 3xx Redirection + + /** + * {@code 300 Multiple Choices}. + * + * @see HTTP/1.1 + */ + public static final Status MULTIPLE_CHOICES = new Status(300, "Multiple Choices"); + /** + * {@code 301 Moved Permanently}. + * + * @see HTTP/1.1 + */ + public static final Status MOVED_PERMANENTLY = new Status(301, "Moved Permanently"); + /** + * {@code 302 Found}. + * + * @see HTTP/1.1 + */ + public static final Status FOUND = new Status(302, "Found"); + /** + * {@code 303 See Other}. + * + * @see HTTP/1.1 + */ + public static final Status SEE_OTHER = new Status(303, "See Other"); + /** + * {@code 304 Not Modified}. + * + * @see HTTP/1.1 + */ + public static final Status NOT_MODIFIED = new Status(304, "Not Modified"); + /** + * {@code 305 Use Proxy}. + * + * @see HTTP/1.1 + */ + public static final Status USE_PROXY = new Status(305, "Use Proxy"); + /** + * {@code 307 Temporary Redirect}. + * + * @see HTTP/1.1 + */ + public static final Status TEMPORARY_REDIRECT = new Status(307, "Temporary Redirect"); + /** + * {@code 308 Resume Incomplete}. + * + * @see A proposal for + * supporting resumable POST/PUT HTTP requests in HTTP/1.0 + */ + public static final Status RESUME_INCOMPLETE = new Status(308, "Resume Incomplete"); + + // --- 4xx Client Error --- + + /** + * {@code 400 Bad Request}. + * + * @see HTTP/1.1 + */ + public static final Status BAD_REQUEST = new Status(400, "Bad Request"); + + /** + * {@code 401 Unauthorized}. + * + * @see HTTP/1.1 + */ + public static final Status UNAUTHORIZED = new Status(401, "Unauthorized"); + /** + * {@code 402 Payment Required}. + * + * @see HTTP/1.1 + */ + public static final Status PAYMENT_REQUIRED = new Status(402, "Payment Required"); + /** + * {@code 403 Forbidden}. + * + * @see HTTP/1.1 + */ + public static final Status FORBIDDEN = new Status(403, "Forbidden"); + /** + * {@code 404 Not Found}. + * + * @see HTTP/1.1 + */ + public static final Status NOT_FOUND = new Status(404, "Not Found"); + /** + * {@code 405 Method Not Allowed}. + * + * @see HTTP/1.1 + */ + public static final Status METHOD_NOT_ALLOWED = new Status(405, "Method Not Allowed"); + /** + * {@code 406 Not Acceptable}. + * + * @see HTTP/1.1 + */ + public static final Status NOT_ACCEPTABLE = new Status(406, "Not Acceptable"); + /** + * {@code 407 Proxy Authentication Required}. + * + * @see HTTP/1.1 + */ + public static final Status PROXY_AUTHENTICATION_REQUIRED = new Status(407, "Proxy Authentication Required"); + /** + * {@code 408 Request Timeout}. + * + * @see HTTP/1.1 + */ + public static final Status REQUEST_TIMEOUT = new Status(408, "Request Timeout"); + /** + * {@code 409 Conflict}. + * + * @see HTTP/1.1 + */ + public static final Status CONFLICT = new Status(409, "Conflict"); + /** + * {@code 410 Gone}. + * + * @see HTTP/1.1 + */ + public static final Status GONE = new Status(410, "Gone"); + /** + * {@code 411 Length Required}. + * + * @see HTTP/1.1 + */ + public static final Status LENGTH_REQUIRED = new Status(411, "Length Required"); + /** + * {@code 412 Precondition failed}. + * + * @see HTTP/1.1 + */ + public static final Status PRECONDITION_FAILED = new Status(412, "Precondition Failed"); + /** + * {@code 413 Request Entity Too Large}. + * + * @see HTTP/1.1 + */ + public static final Status REQUEST_ENTITY_TOO_LARGE = new Status(413, "Request Entity Too Large"); + /** + * {@code 414 Request-URI Too Long}. + * + * @see HTTP/1.1 + */ + public static final Status REQUEST_URI_TOO_LONG = new Status(414, "Request-URI Too Long"); + /** + * {@code 415 Unsupported Media Type}. + * + * @see HTTP/1.1 + */ + public static final Status UNSUPPORTED_MEDIA_TYPE = new Status(415, "Unsupported Media Type"); + /** + * {@code 416 Requested Range Not Satisfiable}. + * + * @see HTTP/1.1 + */ + public static final Status REQUESTED_RANGE_NOT_SATISFIABLE = new Status(416, "Requested range not satisfiable"); + /** + * {@code 417 Expectation Failed}. + * + * @see HTTP/1.1 + */ + public static final Status EXPECTATION_FAILED = new Status(417, "Expectation Failed"); + /** + * {@code 418 I'm a teapot}. + * + * @see HTCPCP/1.0 + */ + public static final Status I_AM_A_TEAPOT = new Status(418, "I'm a teapot"); + /** + * {@code 422 Unprocessable Entity}. + * + * @see WebDAV + */ + public static final Status UNPROCESSABLE_ENTITY = new Status(422, "Unprocessable Entity"); + /** + * {@code 423 Locked}. + * + * @see WebDAV + */ + public static final Status LOCKED = new Status(423, "Locked"); + /** + * {@code 424 Failed Dependency}. + * + * @see WebDAV + */ + public static final Status FAILED_DEPENDENCY = new Status(424, "Failed Dependency"); + /** + * {@code 426 Upgrade Required}. + * + * @see Upgrading to TLS Within + * HTTP/1.1 + */ + public static final Status UPGRADE_REQUIRED = new Status(426, "Upgrade Required"); + /** + * {@code 428 Precondition Required}. + * + * @see Additional HTTP Status Codes + */ + public static final Status PRECONDITION_REQUIRED = new Status(428, "Precondition Required"); + /** + * {@code 429 Too Many Requests}. + * + * @see Additional HTTP Status Codes + */ + public static final Status TOO_MANY_REQUESTS = new Status(429, "Too Many Requests"); + /** + * {@code 431 Request Header Fields Too Large}. + * + * @see Additional HTTP Status Codes + */ + public static final Status REQUEST_HEADER_FIELDS_TOO_LARGE = new Status(431, "Request Header Fields Too Large"); + + // --- 5xx Server Error --- + + /** + * {@code 500 Server Error}. + * + * @see HTTP/1.1 + */ + public static final Status SERVER_ERROR = new Status(500, "Server Error"); + /** + * {@code 501 Not Implemented}. + * + * @see HTTP/1.1 + */ + public static final Status NOT_IMPLEMENTED = new Status(501, "Not Implemented"); + /** + * {@code 502 Bad Gateway}. + * + * @see HTTP/1.1 + */ + public static final Status BAD_GATEWAY = new Status(502, "Bad Gateway"); + /** + * {@code 503 Service Unavailable}. + * + * @see HTTP/1.1 + */ + public static final Status SERVICE_UNAVAILABLE = new Status(503, "Service Unavailable"); + /** + * {@code 504 Gateway Timeout}. + * + * @see HTTP/1.1 + */ + public static final Status GATEWAY_TIMEOUT = new Status(504, "Gateway Timeout"); + /** + * {@code 505 HTTP Version Not Supported}. + * + * @see HTTP/1.1 + */ + public static final Status HTTP_VERSION_NOT_SUPPORTED = new Status(505, "HTTP Version not supported"); + /** + * {@code 506 Variant Also Negotiates} + * + * @see Transparent Content + * Negotiation + */ + public static final Status VARIANT_ALSO_NEGOTIATES = new Status(506, "Variant Also Negotiates"); + /** + * {@code 507 Insufficient Storage} + * + * @see WebDAV + */ + public static final Status INSUFFICIENT_STORAGE = new Status(507, "Insufficient Storage"); + /** + * {@code 508 Loop Detected} + * + * @see WebDAV Binding Extensions + */ + public static final Status LOOP_DETECTED = new Status(508, "Loop Detected"); + /** + * {@code 509 Bandwidth Limit Exceeded} + */ + public static final Status BANDWIDTH_LIMIT_EXCEEDED = new Status(509, "Bandwidth Limit Exceeded"); + /** + * {@code 510 Not Extended} + * + * @see HTTP Extension Framework + */ + public static final Status NOT_EXTENDED = new Status(510, "Not Extended"); + /** + * {@code 511 Network Authentication Required}. + * + * @see Additional HTTP Status Codes + */ + public static final Status NETWORK_AUTHENTICATION_REQUIRED = new Status(511, "Network Authentication Required"); + + private final int value; + + private final String reason; + + private Status(final int value, final String reason) { + statusMap.put(Integer.valueOf(value), this); + this.value = value; + this.reason = reason; + } + + /** + * @return Return the integer value of this status code. + */ + public int value() { + return this.value; + } + + /** + * @return True, for status code >= 400. + */ + public boolean isError() { + return this.value >= 400; + } + + /** + * @return the reason phrase of this status code. + */ + public String reason() { + return reason; + } + + /** + * Return a string representation of this status code. + */ + @Override + public String toString() { + return reason() + " (" + value + ")"; + } + + /** + * Return the enum constant of this type with the specified numeric value. + * + * @param statusCode the numeric value of the enum to be returned + * @return the enum constant with the specified numeric value + * @throws IllegalArgumentException if this enum has no constant for the specified numeric value + */ + public static Status valueOf(final int statusCode) { + Integer key = Integer.valueOf(statusCode); + Status status = statusMap.get(key); + return status == null? new Status(key, key.toString()) : status; + } +} diff --git a/jooby/src/main/java/org/jooby/Upload.java b/jooby/src/main/java/org/jooby/Upload.java new file mode 100644 index 00000000..4a6afb9a --- /dev/null +++ b/jooby/src/main/java/org/jooby/Upload.java @@ -0,0 +1,61 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import javax.annotation.Nonnull; +import java.io.Closeable; +import java.io.File; +import java.io.IOException; + +/** + * File upload from a browser on {@link MediaType#multipart} request. + * + * @author edgar + * @since 0.1.0 + */ +public interface Upload extends Closeable { + + /** + * @return File's name. + */ + @Nonnull + String name(); + + /** + * @return File media type. + */ + @Nonnull + MediaType type(); + + /** + * Upload header, like content-type, charset, etc... + * + * @param name Header's name. + * @return A header value. + */ + @Nonnull + Mutant header(String name); + + /** + * Get this upload as temporary file. + * + * @return A temp file. + * @throws IOException If file doesn't exist. + */ + @Nonnull + File file() throws IOException; + +} diff --git a/jooby/src/main/java/org/jooby/View.java b/jooby/src/main/java/org/jooby/View.java new file mode 100644 index 00000000..83b53539 --- /dev/null +++ b/jooby/src/main/java/org/jooby/View.java @@ -0,0 +1,140 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static java.util.Objects.requireNonNull; + +import javax.annotation.Nonnull; +import java.io.FileNotFoundException; +import java.util.HashMap; +import java.util.Map; + +/** + * Special result that hold view name and model. It will be processed by a {@link View.Engine}. + * + * @author edgar + * @since 0.1.0 + */ +public class View extends Result { + + /** + * Special body serializer for dealing with {@link View}. + * + * Multiples view engine are supported too. + * + * In order to support multiples view engine, a view engine is allowed to throw a + * {@link FileNotFoundException} when a template can't be resolved it. + * This gives the chance to the next view resolver to load the template. + * + * @author edgar + * @since 0.1.0 + */ + public interface Engine extends Renderer { + + @Override + default void render(final Object value, final Renderer.Context ctx) throws Exception { + if (value instanceof View) { + View view = (View) value; + ctx.type(MediaType.html); + render(view, ctx); + } + } + + /** + * Render a view or throw a {@link FileNotFoundException} when template can't be resolved it.. + * + * @param viewable View to render. + * @param ctx A rendering context. + * @throws FileNotFoundException If template can't be resolved. + * @throws Exception If view rendering fails. + */ + void render(final View viewable, final Renderer.Context ctx) throws FileNotFoundException, + Exception; + + } + + /** View's name. */ + private final String name; + + /** View's model. */ + private final Map model = new HashMap<>(); + + /** + * Creates a new {@link View}. + * + * @param name View's name. + */ + protected View(final String name) { + this.name = requireNonNull(name, "View name is required."); + type(MediaType.html); + super.set(this); + } + + /** + * @return View's name. + */ + @Nonnull + public String name() { + return name; + } + + /** + * Set a model attribute and override existing attribute. + * + * @param name Attribute's name. + * @param value Attribute's value. + * @return This view. + */ + @Nonnull + public View put(final String name, final Object value) { + requireNonNull(name, "Model name is required."); + model.put(name, value); + return this; + } + + /** + * Set model attributes and override existing values. + * + * @param values Attribute's value. + * @return This view. + */ + @Nonnull + public View put(final Map values) { + values.forEach((k, v) -> model.put(k, v)); + return this; + } + + /** + * @return View's model. + */ + + @Nonnull + public Map model() { + return model; + } + + @Override + @Nonnull + public Result set(final Object content) { + throw new UnsupportedOperationException("Not allowed in views, use one of the put methods."); + } + + @Override + public String toString() { + return name + ": " + model; + } + +} diff --git a/jooby/src/main/java/org/jooby/WebSocket.java b/jooby/src/main/java/org/jooby/WebSocket.java new file mode 100644 index 00000000..c795e225 --- /dev/null +++ b/jooby/src/main/java/org/jooby/WebSocket.java @@ -0,0 +1,838 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import com.google.common.base.Preconditions; +import com.google.inject.Key; +import com.google.inject.TypeLiteral; +import static java.util.Objects.requireNonNull; +import org.jooby.internal.RouteMatcher; +import org.jooby.internal.RoutePattern; +import org.jooby.internal.WebSocketImpl; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.io.Closeable; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + *

WebSockets

+ *

+ * Creating web sockets is pretty straightforward: + *

+ * + *
+ *  {
+ *    ws("/", (ws) {@literal ->} {
+ *      // connected
+ *      ws.onMessage(message {@literal ->} {
+ *        System.out.println(message.value());
+ *        ws.send("Message Received");
+ *      });
+ *      ws.send("Connected");
+ *    });
+ *  }
+ * 
+ * + * First thing you need to do is to register a new web socket in your App using the + * {@link Jooby#ws(String, WebSocket.OnOpen1)} method. + * You can specify a path to listen for web socket connection. The path can be a static path or + * a path pattern (like routes). + * + * On new connections the {@link WebSocket.OnOpen1#onOpen(WebSocket)} will be executed from there + * you can listen using the {@link #onMessage(OnMessage)}, {@link #onClose(OnClose)} or + * {@link #onError(OnError)} events. + * + * Inside a handler you can send text or binary message. + * + *

mvc

+ *

+ * Starting from 1.1.0 is it possible to use a class as web socket listener (in + * addition to the script web sockets supported). Your class must implement + * {@link WebSocket#onMessage(OnMessage)}, like: + *

+ * + *
{@code
+ * @Path("/ws")
+ * class MyHandler implements WebSocket.OnMessage {
+ *
+ *   private WebSocket ws;
+ *
+ *   @Inject
+ *   public MyHandler(WebSocket ws) {
+ *     this.ws = ws;
+ *   }
+ *
+ *   @Override
+ *   public void onMessage(String message) {
+ *    ws.send("Got it!");
+ *   }
+ * }
+ *
+ * {
+ *   ws(MyHandler.class);
+ * }
+ *
+ * }
+ * + *

+ * Optionally, your listener might implements the + * {@link WebSocket.OnClose}, + * {@link WebSocket.OnError} or {@link WebSocket.OnOpen} callbacks. Or if you want to + * implement all them, then just {@link WebSocket.Handler} + *

+ * + *

data types

+ *

+ * If your web socket is suppose to send/received a specific data type, like: + * json it is nice to define a consume and produce types: + *

+ * + *
+ *   ws("/", (ws) {@literal ->} {
+ *     ws.onMessage(message {@literal ->} {
+ *       // read as json
+ *       MyObject object = message.to(MyObject.class);
+ *     });
+ *
+ *     MyObject object = new MyObject();
+ *     ws.send(object); // convert to text message using a json converter
+ *   })
+ *   .consumes(MediaType.json)
+ *   .produces(MediaType.json);
+ * 
+ * + *

+ * Or via annotations for mvc listeners: + *

+ * + *
{@code
+ *
+ * @Consumes("json")
+ * @Produces("json")
+ * @Path("/ws")
+ * class MyHandler implements WebSocket.OnMessage {
+ *
+ *   public void onMessage(MyObject message) {
+ *     // ...
+ *     ws.send(new ResponseObject());
+ *   }
+ *
+ * }
+ * }
+ * + *

+ * The message MyObject will be processed by a json parser and the + * response object will be renderered as json too. + *

+ * + * @author edgar + * @since 0.1.0 + */ +public interface WebSocket extends Closeable, Registry { + + /** Websocket key. */ + Key> KEY = Key.get(new TypeLiteral>() { + }); + + /** + * A web socket connect handler. Executed every time a new client connect to the socket. + * + * @author edgar + * @since 0.1.0 + */ + interface OnOpen { + /** + * Inside a connect event, you can listen for {@link WebSocket#onMessage(OnMessage)}, + * {@link WebSocket#onClose(OnClose)} or {@link WebSocket#onError(OnError)} events. + * + * Also, you can send text and binary message. + * + * @param req Current request. + * @param ws A web socket. + * @throws Exception If something goes wrong while connecting. + */ + void onOpen(Request req, WebSocket ws) throws Exception; + } + + /** + * A web socket connect handler. Executed every time a new client connect to the socket. + * + * @author edgar + * @since 0.1.0 + */ + interface OnOpen1 extends OnOpen { + + @Override + default void onOpen(final Request req, final WebSocket ws) throws Exception { + onOpen(ws); + } + + /** + * Inside a connect event, you can listen for {@link WebSocket#onMessage(OnMessage)}, + * {@link WebSocket#onClose(OnClose)} or {@link WebSocket#onError(OnError)} events. + * + * Also, you can send text and binary message. + * + * @param ws A web socket. + * @throws Exception If something goes wrong while connecting. + */ + void onOpen(WebSocket ws) throws Exception; + } + + /** + * Hold a status code and optionally a reason message for {@link WebSocket#close()} operations. + * + * @author edgar + * @since 0.1.0 + */ + class CloseStatus { + /** A status code. */ + private final int code; + + /** A close reason. */ + private final String reason; + + /** + * Create a new {@link CloseStatus} instance. + * + * @param code the status code + */ + private CloseStatus(final int code) { + this(code, null); + } + + /** + * Create a new {@link CloseStatus} instance. + * + * @param code the status code + * @param reason the reason + */ + private CloseStatus(final int code, final String reason) { + Preconditions.checkArgument((code >= 1000 && code < 5000), "Invalid code: %s", code); + this.code = code; + this.reason = reason == null || reason.isEmpty() ? null : reason; + } + + /** + * Creates a new {@link CloseStatus}. + * + * @param code A status code. + * @return A new close status. + */ + public static CloseStatus of(final int code) { + return new CloseStatus(code); + } + + /** + * Creates a new {@link CloseStatus}. + * + * @param code A status code. + * @param reason A close reason. + * @return A new close status. + */ + public static CloseStatus of(final int code, final String reason) { + requireNonNull(reason, "A reason is required."); + return new CloseStatus(code, reason); + } + + /** + * @return the status code. + */ + public int code() { + return this.code; + } + + /** + * @return the reason or {@code null}. + */ + public String reason() { + return this.reason; + } + + @Override + public String toString() { + if (reason == null) { + return code + ""; + } + return code + " (" + reason + ")"; + } + } + + /** + * Web socket message callback. + * + * @author edgar + * @since 0.1.0 + * @param Param type. + */ + interface OnMessage { + + /** + * Invoked from a web socket. + * + * @param message Client message. + * @throws Exception If something goes wrong. + */ + void onMessage(T message) throws Exception; + } + + interface OnClose { + void onClose(CloseStatus status) throws Exception; + } + + /** + * Web socket success callback. + * + * @author edgar + * @since 0.1.0 + */ + interface SuccessCallback { + + /** + * Invoked from a web socket. + * + * @throws Exception If something goes wrong. + */ + void invoke() throws Exception; + } + + /** + * Web socket err callback. + * + * @author edgar + * @since 0.1.0 + */ + interface OnError { + + /** + * Invoked if something goes wrong. + * + * @param err Err cause. + */ + void onError(Throwable err); + } + + /** + * Configure a web socket. + * + * @author edgar + * @since 0.1.0 + */ + class Definition { + /** + * A route compiled pattern. + */ + private RoutePattern routePattern; + + /** + * Defines the media types that the methods of a resource class or can consumes. Default is: + * {@literal *}/{@literal *}. + */ + private MediaType consumes = MediaType.plain; + + /** + * Defines the media types that the methods of a resource class or can produces. Default is: + * {@literal *}/{@literal *}. + */ + private MediaType produces = MediaType.plain; + + /** A path pattern. */ + private String pattern; + + /** A ws handler. */ + private OnOpen handler; + + /** + * Creates a new {@link Definition}. + * + * @param pattern A path pattern. + * @param handler A ws handler. + */ + public Definition(final String pattern, final OnOpen handler) { + requireNonNull(pattern, "A route path is required."); + requireNonNull(handler, "A handler is required."); + + this.routePattern = new RoutePattern("WS", pattern); + // normalized pattern + this.pattern = routePattern.pattern(); + this.handler = handler; + } + + /** + * @return A route pattern. + */ + public String pattern() { + return pattern; + } + + /** + * Test if the given path matches this web socket. + * + * @param path A path pattern. + * @return A web socket or empty optional. + */ + public Optional matches(final String path) { + RouteMatcher matcher = routePattern.matcher("WS" + path); + if (matcher.matches()) { + return Optional.of(asWebSocket(matcher)); + } + return Optional.empty(); + } + + /** + * Set the media types the route can consume. + * + * @param type The media types to test for. + * @return This route definition. + */ + public Definition consumes(final String type) { + return consumes(MediaType.valueOf(type)); + } + + /** + * Set the media types the route can consume. + * + * @param type The media types to test for. + * @return This route definition. + */ + public Definition consumes(final MediaType type) { + this.consumes = requireNonNull(type, "A type is required."); + return this; + } + + /** + * Set the media types the route can produces. + * + * @param type The media types to test for. + * @return This route definition. + */ + public Definition produces(final String type) { + return produces(MediaType.valueOf(type)); + } + + /** + * Set the media types the route can produces. + * + * @param type The media types to test for. + * @return This route definition. + */ + public Definition produces(final MediaType type) { + this.produces = requireNonNull(type, "A type is required."); + return this; + } + + /** + * @return All the types this route can consumes. + */ + public MediaType consumes() { + return this.consumes; + } + + /** + * @return All the types this route can produces. + */ + public MediaType produces() { + return this.produces; + } + + @Override + public boolean equals(final Object obj) { + if (obj instanceof Definition) { + Definition def = (Definition) obj; + return this.pattern.equals(def.pattern); + } + return false; + } + + @Override + public int hashCode() { + return pattern.hashCode(); + } + + @Override + public String toString() { + StringBuilder buffer = new StringBuilder(); + buffer.append("WS ").append(pattern()).append("\n"); + buffer.append(" consume: ").append(consumes()).append("\n"); + buffer.append(" produces: ").append(produces()).append("\n"); + return buffer.toString(); + } + + /** + * Creates a new web socket. + * + * @param matcher A route matcher. + * @return A new web socket. + */ + private WebSocket asWebSocket(final RouteMatcher matcher) { + return new WebSocketImpl(handler, matcher.path(), pattern, matcher.vars(), + consumes, produces); + } + } + + interface Handler extends OnClose, OnMessage, OnError, OnOpen { + + } + + /** Default success callback. */ + SuccessCallback SUCCESS = () -> { + }; + + /** Default err callback. */ + OnError ERR = (ex) -> { + LoggerFactory.getLogger(WebSocket.class).error("error while sending data", ex); + }; + + /** + * "1000 indicates a normal closure, meaning that the purpose for which the connection + * was established has been fulfilled." + */ + CloseStatus NORMAL = new CloseStatus(1000, "Normal"); + + /** + * "1001 indicates that an endpoint is "going away", such as a server going down or a + * browser having navigated away from a page." + */ + CloseStatus GOING_AWAY = new CloseStatus(1001, "Going away"); + + /** + * "1002 indicates that an endpoint is terminating the connection due to a protocol + * error." + */ + CloseStatus PROTOCOL_ERROR = new CloseStatus(1002, "Protocol error"); + + /** + * "1003 indicates that an endpoint is terminating the connection because it has + * received a type of data it cannot accept (e.g., an endpoint that understands only + * text data MAY send this if it receives a binary message)." + */ + CloseStatus NOT_ACCEPTABLE = new CloseStatus(1003, "Not acceptable"); + + /** + * "1007 indicates that an endpoint is terminating the connection because it has + * received data within a message that was not consistent with the type of the message + * (e.g., non-UTF-8 [RFC3629] data within a text message)." + */ + CloseStatus BAD_DATA = new CloseStatus(1007, "Bad data"); + + /** + * "1008 indicates that an endpoint is terminating the connection because it has + * received a message that violates its policy. This is a generic status code that can + * be returned when there is no other more suitable status code (e.g., 1003 or 1009) + * or if there is a need to hide specific details about the policy." + */ + CloseStatus POLICY_VIOLATION = new CloseStatus(1008, "Policy violation"); + + /** + * "1009 indicates that an endpoint is terminating the connection because it has + * received a message that is too big for it to process." + */ + CloseStatus TOO_BIG_TO_PROCESS = new CloseStatus(1009, "Too big to process"); + + /** + * "1010 indicates that an endpoint (client) is terminating the connection because it + * has expected the server to negotiate one or more extension, but the server didn't + * return them in the response message of the WebSocket handshake. The list of + * extensions that are needed SHOULD appear in the /reason/ part of the Close frame. + * Note that this status code is not used by the server, because it can fail the + * WebSocket handshake instead." + */ + CloseStatus REQUIRED_EXTENSION = new CloseStatus(1010, "Required extension"); + + /** + * "1011 indicates that a server is terminating the connection because it encountered + * an unexpected condition that prevented it from fulfilling the request." + */ + CloseStatus SERVER_ERROR = new CloseStatus(1011, "Server error"); + + /** + * "1012 indicates that the service is restarted. A client may reconnect, and if it + * chooses to do, should reconnect using a randomized delay of 5 - 30s." + */ + CloseStatus SERVICE_RESTARTED = new CloseStatus(1012, "Service restarted"); + + /** + * "1013 indicates that the service is experiencing overload. A client should only + * connect to a different IP (when there are multiple for the target) or reconnect to + * the same IP upon user action." + */ + CloseStatus SERVICE_OVERLOAD = new CloseStatus(1013, "Service overload"); + + /** + * @return Current request path. + */ + @Nonnull + String path(); + + /** + * @return The currently matched pattern. + */ + @Nonnull + String pattern(); + + /** + * @return The currently matched path variables (if any). + */ + @Nonnull + Map vars(); + + /** + * @return The type this route can consumes, defaults is: {@code * / *}. + */ + @Nonnull + MediaType consumes(); + + /** + * @return The type this route can produces, defaults is: {@code * / *}. + */ + @Nonnull + MediaType produces(); + + /** + * Register a callback to execute when a new message arrive. + * + * @param callback A callback + * @throws Exception If something goes wrong. + */ + void onMessage(OnMessage callback) throws Exception; + + /** + * Register an error callback to execute when an error is found. + * + * @param callback A callback + */ + void onError(OnError callback); + + /** + * Register an close callback to execute when client close the web socket. + * + * @param callback A callback + * @throws Exception If something goes wrong. + */ + void onClose(OnClose callback) throws Exception; + + /** + * Gracefully closes the connection, after sending a description message + * + * @param code Close status code. + * @param reason Close reason. + */ + default void close(final int code, final String reason) { + close(CloseStatus.of(code, reason)); + } + + /** + * Gracefully closes the connection, after sending a description message + * + * @param code Close status code. + */ + default void close(final int code) { + close(CloseStatus.of(code)); + } + + /** + * Gracefully closes the connection, after sending a description message + */ + @Override + default void close() { + close(NORMAL); + } + + /** + * True if the websocket is still open. + * + * @return True if the websocket is still open. + */ + boolean isOpen(); + + /** + * Gracefully closes the connection, after sending a description message + * + * @param status Close status code. + */ + void close(CloseStatus status); + + /** + * Resume the client stream. + */ + void resume(); + + /** + * Pause the client stream. + */ + void pause(); + + /** + * Immediately shuts down the connection. + * + * @throws Exception If something goes wrong. + */ + void terminate() throws Exception; + + /** + * Send data through the connection. + * + * If the web socket is closed this method throw an {@link Err} with {@link #NORMAL} close status. + * + * @param data Data to send. + * @throws Exception If something goes wrong. + */ + default void send(final Object data) throws Exception { + send(data, SUCCESS, ERR); + } + + /** + * Send data through the connection. + * + * If the web socket is closed this method throw an {@link Err} with {@link #NORMAL} close status. + * + * @param data Data to send. + * @param success A success callback. + * @throws Exception If something goes wrong. + */ + default void send(final Object data, final SuccessCallback success) throws Exception { + send(data, success, ERR); + } + + /** + * Send data through the connection. + * + * If the web socket is closed this method throw an {@link Err} with {@link #NORMAL} close status. + * + * @param data Data to send. + * @param err An err callback. + * @throws Exception If something goes wrong. + */ + default void send(final Object data, final OnError err) throws Exception { + send(data, SUCCESS, err); + } + + /** + * Send data through the connection. + * + * If the web socket is closed this method throw an {@link Err} with {@link #NORMAL} close status. + * + * @param data Data to send. + * @param success A success callback. + * @param err An err callback. + * @throws Exception If something goes wrong. + */ + void send(Object data, SuccessCallback success, OnError err) throws Exception; + + /** + * Send data to all connected sessions. + * + * If the web socket is closed this method throw an {@link Err} with {@link #NORMAL} close status. + * + * @param data Data to send. + * @throws Exception If something goes wrong. + */ + default void broadcast(final Object data) throws Exception { + broadcast(data, SUCCESS, ERR); + } + + /** + * Send data to all connected sessions. + * + * If the web socket is closed this method throw an {@link Err} with {@link #NORMAL} close status. + * + * @param data Data to send. + * @param success A success callback. + * @throws Exception If something goes wrong. + */ + default void broadcast(final Object data, final SuccessCallback success) throws Exception { + broadcast(data, success, ERR); + } + + /** + * Send data to all connected sessions. + * + * If the web socket is closed this method throw an {@link Err} with {@link #NORMAL} close status. + * + * @param data Data to send. + * @param err An err callback. + * @throws Exception If something goes wrong. + */ + default void broadcast(final Object data, final OnError err) throws Exception { + broadcast(data, SUCCESS, err); + } + + /** + * Send data to all connected sessions. + * + * If the web socket is closed this method throw an {@link Err} with {@link #NORMAL} close status. + * + * @param data Data to send. + * @param success A success callback. + * @param err An err callback. + * @throws Exception If something goes wrong. + */ + void broadcast(Object data, SuccessCallback success, OnError err) throws Exception; + + /** + * Set a web socket attribute. + * + * @param name Attribute name. + * @param value Attribute value. + * @return This socket. + */ + @Nullable + WebSocket set(String name, Object value); + + /** + * Get a web socket attribute. + * + * @param name Attribute name. + * @return Attribute value. + */ + T get(String name); + + /** + * Get a web socket attribute or empty value. + * + * @param name Attribute name. + * @param Attribute type. + * @return Attribute value or empty value. + */ + Optional ifGet(String name); + + /** + * Clear/remove a web socket attribute. + * + * @param name Attribute name. + * @param Attribute type. + * @return Attribute value (if any). + */ + Optional unset(String name); + + /** + * Clear/reset all the web socket attributes. + * + * @return This socket. + */ + WebSocket unset(); + + /** + * Web socket attributes. + * + * @return Web socket attributes. + */ + Map attributes(); +} diff --git a/jooby/src/main/java/org/jooby/funzy/Throwing.java b/jooby/src/main/java/org/jooby/funzy/Throwing.java new file mode 100644 index 00000000..7c0b5468 --- /dev/null +++ b/jooby/src/main/java/org/jooby/funzy/Throwing.java @@ -0,0 +1,2558 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.funzy; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Collection of throwable interfaces to simplify exception handling, specially on lambdas. + * + * We do provide throwable and 100% compatible implementation of {@link java.util.function.Function}, + * {@link java.util.function.Consumer}, {@link java.lang.Runnable}, + * {@link java.util.function.Supplier}, {@link java.util.function.Predicate} and + * {@link java.util.function.BiPredicate}. + * + * Examples: + * + *
{@code
+ *
+ *  interface Query {
+ *    Item findById(String id) throws IOException;
+ *  }
+ *
+ *  Query query = ...
+ *
+ *  List items = Arrays.asList("1", "2", "3")
+ *    .stream()
+ *    .map(throwingFunction(query::findById))
+ *    .collect(Collectors.toList());
+ *
+ * }
+ * + * + * @author edgar + * @since 0.1.0 + */ +public class Throwing { + private interface Memoized { + } + + /** + * Throwable version of {@link Predicate}. + * + * @param Input type. + */ + public interface Predicate extends java.util.function.Predicate { + boolean tryTest(V v) throws Throwable; + + @Override default boolean test(V v) { + try { + return tryTest(v); + } catch (Throwable x) { + throw sneakyThrow(x); + } + } + } + + /** + * Throwable version of {@link Predicate}. + * + * @param Input type. + * @param Input type. + */ + public interface Predicate2 extends java.util.function.BiPredicate { + boolean tryTest(V1 v1, V2 v2) throws Throwable; + + @Override default boolean test(V1 v1, V2 v2) { + try { + return tryTest(v1, v2); + } catch (Throwable x) { + throw sneakyThrow(x); + } + } + } + + /** + * Throwable version of {@link java.lang.Runnable}. + */ + @FunctionalInterface + public interface Runnable extends java.lang.Runnable { + void tryRun() throws Throwable; + + @Override default void run() { + runAction(this); + } + + /** + * Execute the given action before throwing the exception. + * + * @param action Action to execute. + * @return A new consumer with a listener action. + */ + default Runnable onFailure(java.util.function.Consumer action) { + return onFailure(Throwable.class, action); + } + + /** + * Execute the given action before throwing the exception. + * + * @param type Exception type filter. + * @param action Action to execute. + * @param Exception type. + * @return A new consumer with a listener action. + */ + default Runnable onFailure(Class type, + java.util.function.Consumer action) { + return () -> runOnFailure(() -> tryRun(), type, action); + } + + /** + * Wrap an exception as new exception provided by the given wrap function. + * + * @param wrapper Wrap function. + * @return A new consumer. + */ + default Runnable wrap(java.util.function.Function wrapper) { + return () -> runWrap(() -> tryRun(), wrapper); + } + + /** + * Unwrap an exception and rethrow. Useful to produce clean/shorter stacktraces. + * + * @param type Type to unwrap. + * @param Exception type. + * @return A new consumer. + */ + default Runnable unwrap(Class type) { + return () -> runUnwrap(() -> tryRun(), type); + } + } + + /** + * Throwable version of {@link java.util.function.Supplier}. + * + * @param Result type. + */ + @FunctionalInterface + public interface Supplier extends java.util.function.Supplier { + + V tryGet() throws Throwable; + + @Override default V get() { + return fn(this); + } + + /** + * Apply this function and run the given action in case of exception. + * + * @param action Action to run when exception occurs. + * @return A new function. + */ + default Supplier onFailure(java.util.function.Consumer action) { + return onFailure(Throwable.class, action); + } + + /** + * + * Apply this function and run the given action in case of exception. + * + * @param type Exception filter. + * @param action Action to run when exception occurs. + * @param Exception type. + * @return A new function. + */ + default Supplier onFailure(Class type, + java.util.function.Consumer action) { + return () -> fnOnFailure(() -> tryGet(), type, action); + } + + /** + * Apply this function and wrap any resulting exception. + * + * @param wrapper Exception wrapper. + * @return A new function. + */ + default Supplier wrap(java.util.function.Function wrapper) { + return () -> fnWrap(() -> tryGet(), wrapper); + } + + /** + * Apply this function and unwrap any resulting exception. Useful to get clean/shorter stacktrace. + * + * @param type Exception to unwrap. + * @param Exception type. + * @return A new function. + */ + default Supplier unwrap(Class type) { + return () -> fnUnwrap(() -> tryGet(), type); + } + + /** + * Apply this function and returns the given default value in case of exception. + * + * @param defaultValue Exceptional default value. + * @return A new function. + */ + default Supplier orElse(V defaultValue) { + return orElse(() -> defaultValue); + } + + /** + * Apply this function and returns the given default value in case of exception. + * + * @param defaultValue Exceptional default value. + * @return A new function. + */ + default Supplier orElse(Supplier defaultValue) { + return () -> fn(() -> tryGet(), defaultValue); + } + + /** + * Apply this function or recover from it in case of exception. + * + * @param fn Exception recover. + * @return A new function. + */ + default Supplier recover(java.util.function.Function fn) { + return recover(Throwable.class, fn); + } + + /** + * Apply this function or recover from a specific exception in case of exception. + * + * @param type Exception filter. + * @param fn Exception recover. + * @param Exception type. + * @return A new function. + */ + default Supplier recover(Class type, + java.util.function.Function fn) { + return () -> fnRecover(() -> tryGet(), type, fn); + } + + /** + * Singleton version of this supplier. + * + * @return A memo function. + */ + default Supplier singleton() { + if (this instanceof Memoized) { + return this; + } + AtomicReference ref = new AtomicReference<>(); + return (Supplier & Memoized) () -> { + if (ref.get() == null) { + ref.set(tryGet()); + } + return ref.get(); + }; + } + } + + /** + * Throwable version of {@link java.util.function.Consumer}. + * + * This class rethrow any exception using the {@link #sneakyThrow(Throwable)} technique. + * + * @param Input type. + */ + @FunctionalInterface + public interface Consumer extends java.util.function.Consumer { + /** + * Performs this operation on the given argument. + * + * @param value Argument. + * @throws Throwable If something goes wrong. + */ + void tryAccept(V value) throws Throwable; + + @Override default void accept(V v) { + runAction(() -> tryAccept(v)); + } + + /** + * Execute the given action before throwing the exception. + * + * @param action Action to execute. + * @param Input type. + * @return A new consumer with a listener action. + */ + default Consumer onFailure(java.util.function.Consumer action) { + return onFailure(Throwable.class, action); + } + + /** + * Execute the given action before throwing the exception. + * + * @param type Exception type filter. + * @param action Action to execute. + * @param Exception type. + * @param Input type. + * @return A new consumer with a listener action. + */ + default Consumer onFailure(Class type, + java.util.function.Consumer action) { + return value -> runOnFailure(() -> tryAccept(value), type, action); + } + + /** + * Wrap an exception as new exception provided by the given wrap function. + * + * @param wrapper Wrap function. + * @param Input type + * @return A new consumer. + */ + default Consumer wrap( + java.util.function.Function wrapper) { + return value -> runWrap(() -> tryAccept(value), wrapper); + } + + /** + * Unwrap an exception and rethrow. Useful to produce clean/shorter stacktraces. + * + * @param type Type to unwrap. + * @param Input type + * @param Exception type. + * @return A new consumer. + */ + default Consumer unwrap(Class type) { + return value -> runUnwrap(() -> tryAccept(value), type); + } + } + + /** + * Two argument version of {@link Consumer}. + * + * This class rethrow any exception using the {@link #sneakyThrow(Throwable)} technique. + * + * @param Input type. + * @param Input type. + */ + @FunctionalInterface + public interface Consumer2 { + /** + * Performs this operation on the given argument. + * + * @param v1 Argument. + * @param v2 Argument. + * @throws Throwable If something goes wrong. + */ + void tryAccept(V1 v1, V2 v2) throws Throwable; + + default void accept(V1 v1, V2 v2) { + runAction(() -> tryAccept(v1, v2)); + } + + /** + * Execute the given action before throwing the exception. + * + * @param action Action to execute. + * @param Input type. + * @param Input type. + * @return A new consumer with a listener action. + */ + default Consumer2 onFailure( + java.util.function.Consumer action) { + return onFailure(Throwable.class, action); + } + + /** + * Execute the given action before throwing the exception. + * + * @param type Exception type filter. + * @param action Action to execute. + * @param Exception type. + * @param Input type. + * @param Input type. + * @return A new consumer with a listener action. + */ + default Consumer2 onFailure( + Class type, java.util.function.Consumer action) { + return (v1, v2) -> runOnFailure(() -> tryAccept(v1, v2), type, action); + } + + /** + * Wrap an exception as new exception provided by the given wrap function. + * + * @param wrapper Wrap function. + * @param Input type. + * @param Input type. + * @return A new consumer. + */ + default Consumer2 wrap( + java.util.function.Function wrapper) { + return (v1, v2) -> runWrap(() -> tryAccept(v1, v2), wrapper); + } + + /** + * Unwrap an exception and rethrow. Useful to produce clean/shorter stacktraces. + * + * @param type Type to unwrap. + * @param Input type. + * @param Input type. + * @param Exception type. + * @return A new consumer. + */ + default Consumer2 unwrap( + Class type) { + return (v1, v2) -> runUnwrap(() -> tryAccept(v1, v2), type); + } + } + + /** + * Three argument version of {@link Consumer}. + * + * This class rethrow any exception using the {@link #sneakyThrow(Throwable)} technique. + * + * @param Input type. + * @param Input type. + * @param Input type. + */ + @FunctionalInterface + public interface Consumer3 { + /** + * Performs this operation on the given argument. + * + * @param v1 Argument. + * @param v2 Argument. + * @param v3 Argument. + * @throws Throwable If something goes wrong. + */ + void tryAccept(V1 v1, V2 v2, V3 v3) throws Throwable; + + default void accept(V1 v1, V2 v2, V3 v3) { + runAction(() -> tryAccept(v1, v2, v3)); + } + + /** + * Execute the given action before throwing the exception. + * + * @param action Action to execute. + * @param Input type. + * @param Input type. + * @param Input type. + * @return A new consumer with a listener action. + */ + default Consumer3 onFailure( + java.util.function.Consumer action) { + return onFailure(Throwable.class, action); + } + + /** + * Execute the given action before throwing the exception. + * + * @param type Exception type filter. + * @param action Action to execute. + * @param Exception type. + * @param Input type. + * @param Input type. + * @param Input type. + * @return A new consumer with a listener action. + */ + default Consumer3 onFailure( + Class type, java.util.function.Consumer action) { + return (v1, v2, v3) -> runOnFailure(() -> tryAccept(v1, v2, v3), type, action); + } + + /** + * Wrap an exception as new exception provided by the given wrap function. + * + * @param wrapper Wrap function. + * @param Input type. + * @param Input type. + * @param Input type. + * @return A new consumer. + */ + default Consumer3 wrap( + java.util.function.Function wrapper) { + return (v1, v2, v3) -> runWrap(() -> tryAccept(v1, v2, v3), wrapper); + } + + /** + * Unwrap an exception and rethrow. Useful to produce clean/shorter stacktraces. + * + * @param type Type to unwrap. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Exception type. + * @return A new consumer. + */ + default Consumer3 unwrap( + Class type) { + return (v1, v2, v3) -> runUnwrap(() -> tryAccept(v1, v2, v3), type); + } + } + + /** + * Four argument version of {@link Consumer}. + * + * This class rethrow any exception using the {@link #sneakyThrow(Throwable)} technique. + * + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + */ + @FunctionalInterface + public interface Consumer4 { + /** + * Performs this operation on the given arguments. + * + * @param v1 Argument. + * @param v2 Argument. + * @param v3 Argument. + * @param v4 Argument. + * @throws Throwable If something goes wrong. + */ + void tryAccept(V1 v1, V2 v2, V3 v3, V4 v4) throws Throwable; + + default void accept(V1 v1, V2 v2, V3 v3, V4 v4) { + runAction(() -> tryAccept(v1, v2, v3, v4)); + } + + /** + * Execute the given action before throwing the exception. + * + * @param action Action to execute. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @return A new consumer with a listener action. + */ + default Consumer4 onFailure( + java.util.function.Consumer action) { + return onFailure(Throwable.class, action); + } + + /** + * Execute the given action before throwing the exception. + * + * @param type Exception type filter. + * @param action Action to execute. + * @param Exception type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @return A new consumer with a listener action. + */ + default Consumer4 onFailure( + Class type, java.util.function.Consumer action) { + return (v1, v2, v3, v4) -> runOnFailure(() -> tryAccept(v1, v2, v3, v4), type, action); + } + + /** + * Wrap an exception as new exception provided by the given wrap function. + * + * @param wrapper Wrap function. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @return A new consumer. + */ + default Consumer4 wrap( + java.util.function.Function wrapper) { + return (v1, v2, v3, v4) -> runWrap(() -> tryAccept(v1, v2, v3, v4), wrapper); + } + + /** + * Unwrap an exception and rethrow. Useful to produce clean/shorter stacktraces. + * + * @param type Type to unwrap. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Exception type. + * @return A new consumer. + */ + default Consumer4 unwrap( + Class type) { + return (v1, v2, v3, v4) -> runUnwrap(() -> tryAccept(v1, v2, v3, v4), type); + } + } + + /** + * Five argument version of {@link Consumer}. + * + * This class rethrow any exception using the {@link #sneakyThrow(Throwable)} technique. + * + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + */ + @FunctionalInterface + public interface Consumer5 { + /** + * Performs this operation on the given arguments. + * + * @param v1 Argument. + * @param v2 Argument. + * @param v3 Argument. + * @param v4 Argument. + * @param v5 Argument. + * @throws Throwable If something goes wrong. + */ + void tryAccept(V1 v1, V2 v2, V3 v3, V4 v4, V5 v5) throws Throwable; + + default void accept(V1 v1, V2 v2, V3 v3, V4 v4, V5 v5) { + runAction(() -> tryAccept(v1, v2, v3, v4, v5)); + } + + /** + * Execute the given action before throwing the exception. + * + * @param action Action to execute. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @return A new consumer with a listener action. + */ + default Consumer5 onFailure( + java.util.function.Consumer action) { + return onFailure(Throwable.class, action); + } + + /** + * Execute the given action before throwing the exception. + * + * @param type Exception type filter. + * @param action Action to execute. + * @param Exception type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @return A new consumer with a listener action. + */ + default Consumer5 onFailure( + Class type, java.util.function.Consumer action) { + return (v1, v2, v3, v4, v5) -> runOnFailure(() -> tryAccept(v1, v2, v3, v4, v5), type, + action); + } + + /** + * Wrap an exception as new exception provided by the given wrap function. + * + * @param wrapper Wrap function. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @return A new consumer. + */ + default Consumer5 wrap( + java.util.function.Function wrapper) { + return (v1, v2, v3, v4, v5) -> runWrap(() -> tryAccept(v1, v2, v3, v4, v5), wrapper); + } + + /** + * Unwrap an exception and rethrow. Useful to produce clean/shorter stacktraces. + * + * @param type Type to unwrap. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Exception type. + * @return A new consumer. + */ + default Consumer5 unwrap( + Class type) { + return (v1, v2, v3, v4, v5) -> runUnwrap(() -> tryAccept(v1, v2, v3, v4, v5), type); + } + } + + /** + * Six argument version of {@link Consumer}. + * + * This class rethrow any exception using the {@link #sneakyThrow(Throwable)} technique. + * + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + */ + @FunctionalInterface + public interface Consumer6 { + /** + * Performs this operation on the given arguments. + * + * @param v1 Argument. + * @param v2 Argument. + * @param v3 Argument. + * @param v4 Argument. + * @param v5 Argument. + * @param v6 Argument. + * @throws Throwable If something goes wrong. + */ + void tryAccept(V1 v1, V2 v2, V3 v3, V4 v4, V5 v5, V6 v6) throws Throwable; + + /** + * Performs this operation on the given arguments and throw any exception using {@link #sneakyThrow(Throwable)} method. + * + * @param v1 Argument. + * @param v2 Argument. + * @param v3 Argument. + * @param v4 Argument. + * @param v5 Argument. + * @param v6 Argument. + */ + default void accept(V1 v1, V2 v2, V3 v3, V4 v4, V5 v5, V6 v6) { + runAction(() -> tryAccept(v1, v2, v3, v4, v5, v6)); + } + + /** + * Execute the given action before throwing the exception. + * + * @param action Action to execute. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @return A new consumer with a listener action. + */ + default Consumer6 onFailure( + java.util.function.Consumer action) { + return onFailure(Throwable.class, action); + } + + /** + * Execute the given action before throwing the exception. + * + * @param type Exception type filter. + * @param action Action to execute. + * @param Exception type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @return A new consumer with a listener action. + */ + default Consumer6 onFailure( + Class type, java.util.function.Consumer action) { + return (v1, v2, v3, v4, v5, v6) -> runOnFailure(() -> tryAccept(v1, v2, v3, v4, v5, v6), type, + action); + } + + /** + * Wrap an exception as new exception provided by the given wrap function. + * + * @param wrapper Wrap function. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @return A new consumer. + */ + default Consumer6 wrap( + java.util.function.Function wrapper) { + return (v1, v2, v3, v4, v5, v6) -> runWrap(() -> tryAccept(v1, v2, v3, v4, v5, v6), wrapper); + } + + /** + * Unwrap an exception and rethrow. Useful to produce clean/shorter stacktraces. + * + * @param type Type to unwrap. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Exception type. + * @return A new consumer. + */ + default Consumer6 unwrap( + Class type) { + return (v1, v2, v3, v4, v5, v6) -> runUnwrap(() -> tryAccept(v1, v2, v3, v4, v5, v6), type); + } + } + + /** + * Seven argument version of {@link Consumer}. + * + * This class rethrow any exception using the {@link #sneakyThrow(Throwable)} technique. + * + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + */ + @FunctionalInterface + public interface Consumer7 { + /** + * Performs this operation on the given arguments. + * + * @param v1 Argument. + * @param v2 Argument. + * @param v3 Argument. + * @param v4 Argument. + * @param v5 Argument. + * @param v6 Argument. + * @param v7 Argument. + * @throws Throwable If something goes wrong. + */ + void tryAccept(V1 v1, V2 v2, V3 v3, V4 v4, V5 v5, V6 v6, V7 v7) throws Throwable; + + /** + * Performs this operation on the given arguments and throw any exception using {@link #sneakyThrow(Throwable)} method. + * + * @param v1 Argument. + * @param v2 Argument. + * @param v3 Argument. + * @param v4 Argument. + * @param v5 Argument. + * @param v6 Argument. + * @param v7 Argument. + */ + default void accept(V1 v1, V2 v2, V3 v3, V4 v4, V5 v5, V6 v6, V7 v7) { + runAction(() -> tryAccept(v1, v2, v3, v4, v5, v6, v7)); + } + + /** + * Execute the given action before throwing the exception. + * + * @param action Action to execute. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @return A new consumer with a listener action. + */ + default Consumer7 onFailure( + java.util.function.Consumer action) { + return onFailure(Throwable.class, action); + } + + /** + * Execute the given action before throwing the exception. + * + * @param type Exception type filter. + * @param action Action to execute. + * @param Exception type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @return A new consumer with a listener action. + */ + default Consumer7 onFailure( + Class type, java.util.function.Consumer action) { + return (v1, v2, v3, v4, v5, v6, v7) -> runOnFailure( + () -> tryAccept(v1, v2, v3, v4, v5, v6, v7), type, action); + } + + /** + * Wrap an exception as new exception provided by the given wrap function. + * + * @param wrapper Wrap function. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @return A new consumer. + */ + default Consumer7 wrap( + java.util.function.Function wrapper) { + return (v1, v2, v3, v4, v5, v6, v7) -> runWrap(() -> tryAccept(v1, v2, v3, v4, v5, v6, v7), + wrapper); + } + + /** + * Unwrap an exception and rethrow. Useful to produce clean/shorter stacktraces. + * + * @param type Type to unwrap. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Exception type. + * @return A new consumer. + */ + default Consumer7 unwrap( + Class type) { + return (v1, v2, v3, v4, v5, v6, v7) -> runUnwrap(() -> tryAccept(v1, v2, v3, v4, v5, v6, v7), + type); + } + } + + /** + * Seven argument version of {@link Consumer}. + * + * This class rethrow any exception using the {@link #sneakyThrow(Throwable)} technique. + * + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + */ + @FunctionalInterface + public interface Consumer8 { + /** + * Performs this operation on the given arguments. + * + * @param v1 Argument. + * @param v2 Argument. + * @param v3 Argument. + * @param v4 Argument. + * @param v5 Argument. + * @param v6 Argument. + * @param v7 Argument. + * @param v8 Argument. + * @throws Throwable If something goes wrong. + */ + void tryAccept(V1 v1, V2 v2, V3 v3, V4 v4, V5 v5, V6 v6, V7 v7, V8 v8) throws Throwable; + + /** + * Performs this operation on the given arguments and throw any exception using {@link #sneakyThrow(Throwable)} method. + * + * @param v1 Argument. + * @param v2 Argument. + * @param v3 Argument. + * @param v4 Argument. + * @param v5 Argument. + * @param v6 Argument. + * @param v7 Argument. + * @param v8 Argument. + */ + default void accept(V1 v1, V2 v2, V3 v3, V4 v4, V5 v5, V6 v6, V7 v7, V8 v8) { + runAction(() -> tryAccept(v1, v2, v3, v4, v5, v6, v7, v8)); + } + + /** + * Execute the given action before throwing the exception. + * + * @param action Action to execute. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @return A new consumer with a listener action. + */ + default Consumer8 onFailure( + java.util.function.Consumer action) { + return onFailure(Throwable.class, action); + } + + /** + * Execute the given action before throwing the exception. + * + * @param type Exception type filter. + * @param action Action to execute. + * @param Exception type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @return A new consumer with a listener action. + */ + default Consumer8 onFailure( + Class type, java.util.function.Consumer action) { + return (v1, v2, v3, v4, v5, v6, v7, v8) -> runOnFailure( + () -> tryAccept(v1, v2, v3, v4, v5, v6, v7, v8), type, action); + } + + /** + * Wrap an exception as new exception provided by the given wrap function. + * + * @param wrapper Wrap function. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @return A new consumer. + */ + default Consumer8 wrap( + java.util.function.Function wrapper) { + return (v1, v2, v3, v4, v5, v6, v7, v8) -> runWrap( + () -> tryAccept(v1, v2, v3, v4, v5, v6, v7, v8), wrapper); + } + + /** + * Unwrap an exception and rethrow. Useful to produce clean/shorter stacktraces. + * + * @param type Type to unwrap. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Exception type. + * @return A new consumer. + */ + default Consumer8 unwrap( + Class type) { + return (v1, v2, v3, v4, v5, v6, v7, v8) -> runUnwrap( + () -> tryAccept(v1, v2, v3, v4, v5, v6, v7, v8), type); + } + } + + /** + * Throwable version of {@link java.util.function.Function}. + * + * The {@link #apply(Object)} method throws checked exceptions using {@link #sneakyThrow(Throwable)} method. + * + * @param Input type. + * @param Output type. + */ + @FunctionalInterface + public interface Function extends java.util.function.Function { + /** + * Apply this function to the given argument and produces a result. + * + * @param value Input argument. + * @return Result. + * @throws Throwable If something goes wrong. + */ + R tryApply(V value) throws Throwable; + + /** + * Apply this function to the given argument and produces a result. + * + * @param v Input argument. + * @return Result. + */ + @Override default R apply(V v) { + return fn(() -> tryApply(v)); + } + + /** + * Apply this function and run the given action in case of exception. + * + * @param action Action to run when exception occurs. + * @return A new function. + */ + default Function onFailure(java.util.function.Consumer action) { + return onFailure(Throwable.class, action); + } + + /** + * + * Apply this function and run the given action in case of exception. + * + * @param type Exception filter. + * @param action Action to run when exception occurs. + * @param Exception type. + * @return A new function. + */ + default Function onFailure(Class type, + java.util.function.Consumer action) { + return value -> fnOnFailure(() -> tryApply(value), type, action); + } + + /** + * Apply this function and wrap any resulting exception. + * + * @param wrapper Exception wrapper. + * @return A new function. + */ + default Function wrap(java.util.function.Function wrapper) { + return value -> fnWrap(() -> tryApply(value), wrapper); + } + + /** + * Apply this function and unwrap any resulting exception. Useful to get clean/shorter stacktrace. + * + * @param type Exception to unwrap. + * @param Exception type. + * @return A new function. + */ + default Function unwrap(Class type) { + return value -> fnUnwrap(() -> tryApply(value), type); + } + + /** + * Apply this function and returns the given default value in case of exception. + * + * @param defaultValue Exceptional default value. + * @return A new function. + */ + default Function orElse(R defaultValue) { + return orElse(() -> defaultValue); + } + + /** + * Apply this function and returns the given default value in case of exception. + * + * @param defaultValue Exceptional default value. + * @return A new function. + */ + default Function orElse(Supplier defaultValue) { + return value -> fn(() -> tryApply(value), defaultValue); + } + + /** + * Apply this function or recover from it in case of exception. + * + * @param fn Exception recover. + * @return A new function. + */ + default Function recover(java.util.function.Function fn) { + return recover(Throwable.class, fn); + } + + /** + * Apply this function or recover from a specific exception in case of exception. + * + * @param type Exception filter. + * @param fn Exception recover. + * @param Exception type. + * @return A new function. + */ + default Function recover(Class type, + java.util.function.Function fn) { + return value -> fnRecover(() -> tryApply(value), type, fn); + } + + /** + * A function that remember/cache previous executions. + * + * @return A memo function. + */ + default Function memoized() { + if (this instanceof Memoized) { + return this; + } + Map cache = new HashMap<>(); + return (Function & Memoized) value -> memo(cache, Arrays.asList(value), + () -> tryApply(value)); + } + } + + /** + * Throwable version of {@link java.util.function.BiFunction}. + * + * The {@link #apply(Object, Object)} method throws checked exceptions using {@link #sneakyThrow(Throwable)} method. + * + * @param Input type. + * @param Input type. + * @param Output type. + */ + @FunctionalInterface + public interface Function2 extends java.util.function.BiFunction { + /** + * Apply this function to the given argument and produces a result. + * + * @param v1 Input argument. + * @param v2 Input argument. + * @return Result. + * @throws Throwable If something goes wrong. + */ + R tryApply(V1 v1, V2 v2) throws Throwable; + + /** + * Apply this function to the given argument and produces a result. + * + * @param v1 Input argument. + * @param v2 Input argument. + * @return Result. + */ + @Override default R apply(V1 v1, V2 v2) { + return fn(() -> tryApply(v1, v2)); + } + + /** + * Apply this function and run the given action in case of exception. + * + * @param action Action to run when exception occurs. + * @return A new function. + */ + default Function2 onFailure(java.util.function.Consumer action) { + return onFailure(Throwable.class, action); + } + + /** + * + * Apply this function and run the given action in case of exception. + * + * @param type Exception filter. + * @param action Action to run when exception occurs. + * @param Exception type. + * @return A new function. + */ + default Function2 onFailure(Class type, + java.util.function.Consumer action) { + return (v1, v2) -> fnOnFailure(() -> tryApply(v1, v2), type, action); + } + + /** + * Apply this function and wrap any resulting exception. + * + * @param wrapper Exception wrapper. + * @return A new function. + */ + default Function2 wrap(java.util.function.Function wrapper) { + return (v1, v2) -> fnWrap(() -> tryApply(v1, v2), wrapper); + } + + /** + * Apply this function and unwrap any resulting exception. Useful to get clean/shorter stacktrace. + * + * @param type Exception to unwrap. + * @param Exception type. + * @return A new function. + */ + default Function2 unwrap(Class type) { + return (v1, v2) -> fnUnwrap(() -> tryApply(v1, v2), type); + } + + /** + * Apply this function and returns the given default value in case of exception. + * + * @param defaultValue Exceptional default value. + * @return A new function. + */ + default Function2 orElse(R defaultValue) { + return orElse(() -> defaultValue); + } + + /** + * Apply this function and returns the given default value in case of exception. + * + * @param defaultValue Exceptional default value. + * @return A new function. + */ + default Function2 orElse(Supplier defaultValue) { + return (v1, v2) -> fn(() -> tryApply(v1, v2), defaultValue); + } + + /** + * Apply this function or recover from it in case of exception. + * + * @param fn Exception recover. + * @return A new function. + */ + default Function2 recover(java.util.function.Function fn) { + return recover(Throwable.class, fn); + } + + /** + * Apply this function or recover from a specific exception in case of exception. + * + * @param type Exception filter. + * @param fn Exception recover. + * @param Exception type. + * @return A new function. + */ + default Function2 recover(Class type, + java.util.function.Function fn) { + return (v1, v2) -> fnRecover(() -> tryApply(v1, v2), type, fn); + } + + /** + * A function that remember/cache previous executions. + * + * @return A memo function. + */ + default Function2 memoized() { + if (this instanceof Memoized) { + return this; + } + Map cache = new HashMap<>(); + return (Function2 & Memoized) (v1, v2) -> memo(cache, Arrays.asList(v1, v2), + () -> tryApply(v1, v2)); + } + } + + /** + * Function with three arguments. + * + * The {@link #apply(Object, Object, Object)} method throws checked exceptions using {@link #sneakyThrow(Throwable)} method. + * + * @param Input type. + * @param Input type. + * @param Input type. + * @param Output type. + */ + @FunctionalInterface + public interface Function3 { + /** + * Apply this function to the given argument and produces a result. + * + * @param v1 Input argument. + * @param v2 Input argument. + * @param v3 Input argument. + * @return Result. + * @throws Throwable If something goes wrong. + */ + R tryApply(V1 v1, V2 v2, V3 v3) throws Throwable; + + /** + * Apply this function to the given argument and produces a result. + * + * @param v1 Input argument. + * @param v2 Input argument. + * @param v3 Input argument. + * @return Result. + */ + default R apply(V1 v1, V2 v2, V3 v3) { + return fn(() -> tryApply(v1, v2, v3)); + } + + /** + * Apply this function and run the given action in case of exception. + * + * @param action Action to run when exception occurs. + * @return A new function. + */ + default Function3 onFailure(java.util.function.Consumer action) { + return onFailure(Throwable.class, action); + } + + /** + * + * Apply this function and run the given action in case of exception. + * + * @param type Exception filter. + * @param action Action to run when exception occurs. + * @param Exception type. + * @return A new function. + */ + default Function3 onFailure(Class type, + java.util.function.Consumer action) { + return (v1, v2, v3) -> fnOnFailure(() -> tryApply(v1, v2, v3), type, action); + } + + /** + * Apply this function and wrap any resulting exception. + * + * @param wrapper Exception wrapper. + * @return A new function. + */ + default Function3 wrap( + java.util.function.Function wrapper) { + return (v1, v2, v3) -> fnWrap(() -> tryApply(v1, v2, v3), wrapper); + } + + /** + * Apply this function and unwrap any resulting exception. Useful to get clean/shorter stacktrace. + * + * @param type Exception to unwrap. + * @param Exception type. + * @return A new function. + */ + default Function3 unwrap(Class type) { + return (v1, v2, v3) -> fnUnwrap(() -> tryApply(v1, v2, v3), type); + } + + /** + * Apply this function and returns the given default value in case of exception. + * + * @param defaultValue Exceptional default value. + * @return A new function. + */ + default Function3 orElse(R defaultValue) { + return orElse(() -> defaultValue); + } + + /** + * Apply this function and returns the given default value in case of exception. + * + * @param defaultValue Exceptional default value. + * @return A new function. + */ + default Function3 orElse(Supplier defaultValue) { + return (v1, v2, v3) -> fn(() -> tryApply(v1, v2, v3), defaultValue); + } + + /** + * Apply this function or recover from it in case of exception. + * + * @param fn Exception recover. + * @return A new function. + */ + default Function3 recover(java.util.function.Function fn) { + return recover(Throwable.class, fn); + } + + /** + * Apply this function or recover from a specific exception in case of exception. + * + * @param type Exception filter. + * @param fn Exception recover. + * @param Exception type. + * @return A new function. + */ + default Function3 recover(Class type, + java.util.function.Function fn) { + return (v1, v2, v3) -> fnRecover(() -> tryApply(v1, v2, v3), type, fn); + } + + /** + * A function that remember/cache previous executions. + * + * @return A memo function. + */ + default Function3 memoized() { + if (this instanceof Memoized) { + return this; + } + Map cache = new HashMap<>(); + return (Function3 & Memoized) (v1, v2, v3) -> memo(cache, + Arrays.asList(v1, v2, v3), + () -> tryApply(v1, v2, v3)); + } + } + + /** + * Function with four arguments. + * + * The {@link #apply(Object, Object, Object, Object)} method throws checked exceptions using {@link #sneakyThrow(Throwable)} method. + * + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Output type. + */ + @FunctionalInterface + public interface Function4 { + /** + * Apply this function to the given argument and produces a result. + * + * @param v1 Input argument. + * @param v2 Input argument. + * @param v3 Input argument. + * @param v4 Input argument. + * @return Result. + * @throws Throwable If something goes wrong. + */ + R tryApply(V1 v1, V2 v2, V3 v3, V4 v4) throws Throwable; + + /** + * Apply this function to the given argument and produces a result. + * + * @param v1 Input argument. + * @param v2 Input argument. + * @param v3 Input argument. + * @param v4 Input argument. + * @return Result. + */ + default R apply(V1 v1, V2 v2, V3 v3, V4 v4) { + return fn(() -> tryApply(v1, v2, v3, v4)); + } + + /** + * Apply this function and run the given action in case of exception. + * + * @param action Action to run when exception occurs. + * @return A new function. + */ + default Function4 onFailure(java.util.function.Consumer action) { + return onFailure(Throwable.class, action); + } + + /** + * + * Apply this function and run the given action in case of exception. + * + * @param type Exception filter. + * @param action Action to run when exception occurs. + * @param Exception type. + * @return A new function. + */ + default Function4 onFailure(Class type, + java.util.function.Consumer action) { + return (v1, v2, v3, v4) -> fnOnFailure(() -> tryApply(v1, v2, v3, v4), type, action); + } + + /** + * Apply this function and wrap any resulting exception. + * + * @param wrapper Exception wrapper. + * @return A new function. + */ + default Function4 wrap( + java.util.function.Function wrapper) { + return (v1, v2, v3, v4) -> fnWrap(() -> tryApply(v1, v2, v3, v4), wrapper); + } + + /** + * Apply this function and unwrap any resulting exception. Useful to get clean/shorter stacktrace. + * + * @param type Exception to unwrap. + * @param Exception type. + * @return A new function. + */ + default Function4 unwrap(Class type) { + return (v1, v2, v3, v4) -> fnUnwrap(() -> tryApply(v1, v2, v3, v4), type); + } + + /** + * Apply this function and returns the given default value in case of exception. + * + * @param defaultValue Exceptional default value. + * @return A new function. + */ + default Function4 orElse(R defaultValue) { + return orElse(() -> defaultValue); + } + + /** + * Apply this function and returns the given default value in case of exception. + * + * @param defaultValue Exceptional default value. + * @return A new function. + */ + default Function4 orElse(Supplier defaultValue) { + return (v1, v2, v3, v4) -> fn(() -> tryApply(v1, v2, v3, v4), defaultValue); + } + + /** + * Apply this function or recover from it in case of exception. + * + * @param fn Exception recover. + * @return A new function. + */ + default Function4 recover(java.util.function.Function fn) { + return recover(Throwable.class, fn); + } + + /** + * Apply this function or recover from a specific exception in case of exception. + * + * @param type Exception filter. + * @param fn Exception recover. + * @param Exception type. + * @return A new function. + */ + default Function4 recover(Class type, + java.util.function.Function fn) { + return (v1, v2, v3, v4) -> fnRecover(() -> tryApply(v1, v2, v3, v4), type, fn); + } + + /** + * A function that remember/cache previous executions. + * + * @return A memo function. + */ + default Function4 memoized() { + if (this instanceof Memoized) { + return this; + } + Map cache = new HashMap<>(); + return (Function4 & Memoized) (v1, v2, v3, v4) -> memo(cache, + Arrays.asList(v1, v2, v3, v4), + () -> tryApply(v1, v2, v3, v4)); + } + } + + /** + * Function with five arguments. + * + * The {@link #apply(Object, Object, Object, Object, Object)} method throws checked exceptions using {@link #sneakyThrow(Throwable)} method. + * + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Output type. + */ + @FunctionalInterface + public interface Function5 { + /** + * Apply this function to the given argument and produces a result. + * + * @param v1 Input argument. + * @param v2 Input argument. + * @param v3 Input argument. + * @param v4 Input argument. + * @param v5 Input argument. + * @return Result. + * @throws Throwable If something goes wrong. + */ + R tryApply(V1 v1, V2 v2, V3 v3, V4 v4, V5 v5) throws Throwable; + + /** + * Apply this function to the given argument and produces a result. + * + * @param v1 Input argument. + * @param v2 Input argument. + * @param v3 Input argument. + * @param v4 Input argument. + * @param v5 Input argument. + * @return Result. + */ + default R apply(V1 v1, V2 v2, V3 v3, V4 v4, V5 v5) { + return fn(() -> tryApply(v1, v2, v3, v4, v5)); + } + + /** + * Apply this function and run the given action in case of exception. + * + * @param action Action to run when exception occurs. + * @return A new function. + */ + default Function5 onFailure( + java.util.function.Consumer action) { + return onFailure(Throwable.class, action); + } + + /** + * + * Apply this function and run the given action in case of exception. + * + * @param type Exception filter. + * @param action Action to run when exception occurs. + * @param Exception type. + * @return A new function. + */ + default Function5 onFailure(Class type, + java.util.function.Consumer action) { + return (v1, v2, v3, v4, v5) -> fnOnFailure(() -> tryApply(v1, v2, v3, v4, v5), type, action); + } + + /** + * Apply this function and wrap any resulting exception. + * + * @param wrapper Exception wrapper. + * @return A new function. + */ + default Function5 wrap( + java.util.function.Function wrapper) { + return (v1, v2, v3, v4, v5) -> fnWrap(() -> tryApply(v1, v2, v3, v4, v5), wrapper); + } + + /** + * Apply this function and unwrap any resulting exception. Useful to get clean/shorter stacktrace. + * + * @param type Exception to unwrap. + * @param Exception type. + * @return A new function. + */ + default Function5 unwrap(Class type) { + return (v1, v2, v3, v4, v5) -> fnUnwrap(() -> tryApply(v1, v2, v3, v4, v5), type); + } + + /** + * Apply this function and returns the given default value in case of exception. + * + * @param defaultValue Exceptional default value. + * @return A new function. + */ + default Function5 orElse(R defaultValue) { + return orElse(() -> defaultValue); + } + + /** + * Apply this function and returns the given default value in case of exception. + * + * @param defaultValue Exceptional default value. + * @return A new function. + */ + default Function5 orElse(Supplier defaultValue) { + return (v1, v2, v3, v4, v5) -> fn(() -> tryApply(v1, v2, v3, v4, v5), defaultValue); + } + + /** + * Apply this function or recover from it in case of exception. + * + * @param fn Exception recover. + * @return A new function. + */ + default Function5 recover(java.util.function.Function fn) { + return recover(Throwable.class, fn); + } + + /** + * Apply this function or recover from a specific exception in case of exception. + * + * @param type Exception filter. + * @param fn Exception recover. + * @param Exception type. + * @return A new function. + */ + default Function5 recover(Class type, + java.util.function.Function fn) { + return (v1, v2, v3, v4, v5) -> fnRecover(() -> tryApply(v1, v2, v3, v4, v5), type, fn); + } + + /** + * A function that remember/cache previous executions. + * + * @return A memo function. + */ + default Function5 memoized() { + if (this instanceof Memoized) { + return this; + } + Map cache = new HashMap<>(); + return (Function5 & Memoized) (v1, v2, v3, v4, v5) -> memo(cache, + Arrays.asList(v1, v2, v3, v4, v5), + () -> tryApply(v1, v2, v3, v4, v5)); + } + } + + /** + * Function with six arguments. + * + * The {@link #apply(Object, Object, Object, Object, Object, Object)} method throws checked exceptions using {@link #sneakyThrow(Throwable)} method. + * + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Output type. + */ + @FunctionalInterface + public interface Function6 { + /** + * Apply this function to the given argument and produces a result. + * + * @param v1 Input argument. + * @param v2 Input argument. + * @param v3 Input argument. + * @param v4 Input argument. + * @param v5 Input argument. + * @param v6 Input argument. + * @return Result. + * @throws Throwable If something goes wrong. + */ + R tryApply(V1 v1, V2 v2, V3 v3, V4 v4, V5 v5, V6 v6) throws Throwable; + + /** + * Apply this function to the given argument and produces a result. + * + * @param v1 Input argument. + * @param v2 Input argument. + * @param v3 Input argument. + * @param v4 Input argument. + * @param v5 Input argument. + * @param v6 Input argument. + * @return Result. + */ + default R apply(V1 v1, V2 v2, V3 v3, V4 v4, V5 v5, V6 v6) { + return fn(() -> tryApply(v1, v2, v3, v4, v5, v6)); + } + + /** + * Apply this function and run the given action in case of exception. + * + * @param action Action to run when exception occurs. + * @return A new function. + */ + default Function6 onFailure( + java.util.function.Consumer action) { + return onFailure(Throwable.class, action); + } + + /** + * + * Apply this function and run the given action in case of exception. + * + * @param type Exception filter. + * @param action Action to run when exception occurs. + * @param Exception type. + * @return A new function. + */ + default Function6 onFailure(Class type, + java.util.function.Consumer action) { + return (v1, v2, v3, v4, v5, v6) -> fnOnFailure(() -> tryApply(v1, v2, v3, v4, v5, v6), type, + action); + } + + /** + * Apply this function and wrap any resulting exception. + * + * @param wrapper Exception wrapper. + * @return A new function. + */ + default Function6 wrap( + java.util.function.Function wrapper) { + return (v1, v2, v3, v4, v5, v6) -> fnWrap(() -> tryApply(v1, v2, v3, v4, v5, v6), wrapper); + } + + /** + * Apply this function and unwrap any resulting exception. Useful to get clean/shorter stacktrace. + * + * @param type Exception to unwrap. + * @param Exception type. + * @return A new function. + */ + default Function6 unwrap( + Class type) { + return (v1, v2, v3, v4, v5, v6) -> fnUnwrap(() -> tryApply(v1, v2, v3, v4, v5, v6), type); + } + + /** + * Apply this function and returns the given default value in case of exception. + * + * @param defaultValue Exceptional default value. + * @return A new function. + */ + default Function6 orElse(R defaultValue) { + return orElse(() -> defaultValue); + } + + /** + * Apply this function and returns the given default value in case of exception. + * + * @param defaultValue Exceptional default value. + * @return A new function. + */ + default Function6 orElse(Supplier defaultValue) { + return (v1, v2, v3, v4, v5, v6) -> fn(() -> tryApply(v1, v2, v3, v4, v5, v6), defaultValue); + } + + /** + * Apply this function or recover from it in case of exception. + * + * @param fn Exception recover. + * @return A new function. + */ + default Function6 recover( + java.util.function.Function fn) { + return recover(Throwable.class, fn); + } + + /** + * Apply this function or recover from a specific exception in case of exception. + * + * @param type Exception filter. + * @param fn Exception recover. + * @param Exception type. + * @return A new function. + */ + default Function6 recover( + Class type, + java.util.function.Function fn) { + return (v1, v2, v3, v4, v5, v6) -> fnRecover(() -> tryApply(v1, v2, v3, v4, v5, v6), type, + fn); + } + + /** + * A function that remember/cache previous executions. + * + * @return A memo function. + */ + default Function6 memoized() { + if (this instanceof Memoized) { + return this; + } + Map cache = new HashMap<>(); + return (Function6 & Memoized) (v1, v2, v3, v4, v5, v6) -> memo( + cache, + Arrays.asList(v1, v2, v3, v4, v5, v6), + () -> tryApply(v1, v2, v3, v4, v5, v6)); + } + } + + /** + * Function with seven arguments. + * + * The {@link #apply(Object, Object, Object, Object, Object, Object, Object)} method throws checked exceptions using {@link #sneakyThrow(Throwable)} method. + * + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Output type. + */ + @FunctionalInterface + public interface Function7 { + /** + * Apply this function to the given argument and produces a result. + * + * @param v1 Input argument. + * @param v2 Input argument. + * @param v3 Input argument. + * @param v4 Input argument. + * @param v5 Input argument. + * @param v6 Input argument. + * @param v7 Input argument. + * @return Result. + * @throws Throwable If something goes wrong. + */ + R tryApply(V1 v1, V2 v2, V3 v3, V4 v4, V5 v5, V6 v6, V7 v7) throws Throwable; + + /** + * Apply this function to the given argument and produces a result. + * + * @param v1 Input argument. + * @param v2 Input argument. + * @param v3 Input argument. + * @param v4 Input argument. + * @param v5 Input argument. + * @param v6 Input argument. + * @param v7 Input argument. + * @return Result. + */ + default R apply(V1 v1, V2 v2, V3 v3, V4 v4, V5 v5, V6 v6, V7 v7) { + return fn(() -> tryApply(v1, v2, v3, v4, v5, v6, v7)); + } + + /** + * Apply this function and run the given action in case of exception. + * + * @param action Action to run when exception occurs. + * @return A new function. + */ + default Function7 onFailure( + java.util.function.Consumer action) { + return onFailure(Throwable.class, action); + } + + /** + * + * Apply this function and run the given action in case of exception. + * + * @param type Exception filter. + * @param action Action to run when exception occurs. + * @param Exception type. + * @return A new function. + */ + default Function7 onFailure(Class type, + java.util.function.Consumer action) { + return (v1, v2, v3, v4, v5, v6, v7) -> fnOnFailure(() -> tryApply(v1, v2, v3, v4, v5, v6, v7), + type, action); + } + + /** + * Apply this function and wrap any resulting exception. + * + * @param wrapper Exception wrapper. + * @return A new function. + */ + default Function7 wrap( + java.util.function.Function wrapper) { + return (v1, v2, v3, v4, v5, v6, v7) -> fnWrap(() -> tryApply(v1, v2, v3, v4, v5, v6, v7), + wrapper); + } + + /** + * Apply this function and unwrap any resulting exception. Useful to get clean/shorter stacktrace. + * + * @param type Exception to unwrap. + * @param Exception type. + * @return A new function. + */ + default Function7 unwrap( + Class type) { + return (v1, v2, v3, v4, v5, v6, v7) -> fnUnwrap(() -> tryApply(v1, v2, v3, v4, v5, v6, v7), + type); + } + + /** + * Apply this function and returns the given default value in case of exception. + * + * @param defaultValue Exceptional default value. + * @return A new function. + */ + default Function7 orElse(R defaultValue) { + return orElse(() -> defaultValue); + } + + /** + * Apply this function and returns the given default value in case of exception. + * + * @param defaultValue Exceptional default value. + * @return A new function. + */ + default Function7 orElse(Supplier defaultValue) { + return (v1, v2, v3, v4, v5, v6, v7) -> fn(() -> tryApply(v1, v2, v3, v4, v5, v6, v7), + defaultValue); + } + + /** + * Apply this function or recover from it in case of exception. + * + * @param fn Exception recover. + * @return A new function. + */ + default Function7 recover( + java.util.function.Function fn) { + return recover(Throwable.class, fn); + } + + /** + * Apply this function or recover from a specific exception in case of exception. + * + * @param type Exception filter. + * @param fn Exception recover. + * @param Exception type. + * @return A new function. + */ + default Function7 recover( + Class type, + java.util.function.Function fn) { + return (v1, v2, v3, v4, v5, v6, v7) -> fnRecover(() -> tryApply(v1, v2, v3, v4, v5, v6, v7), + type, fn); + } + + /** + * A function that remember/cache previous executions. + * + * @return A memo function. + */ + default Function7 memoized() { + if (this instanceof Memoized) { + return this; + } + Map cache = new HashMap<>(); + return (Function7 & Memoized) (v1, v2, v3, v4, v5, v6, v7) -> memo( + cache, + Arrays.asList(v1, v2, v3, v4, v5, v6, v7), + () -> tryApply(v1, v2, v3, v4, v5, v6, v7)); + } + } + + /** + * Function with seven arguments. + * + * The {@link #apply(Object, Object, Object, Object, Object, Object, Object, Object)} method throws checked exceptions using {@link #sneakyThrow(Throwable)} method. + * + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Input type. + * @param Output type. + */ + @FunctionalInterface + public interface Function8 { + /** + * Apply this function to the given argument and produces a result. + * + * @param v1 Input argument. + * @param v2 Input argument. + * @param v3 Input argument. + * @param v4 Input argument. + * @param v5 Input argument. + * @param v6 Input argument. + * @param v7 Input argument. + * @param v8 Input argument. + * @return Result. + * @throws Throwable If something goes wrong. + */ + R tryApply(V1 v1, V2 v2, V3 v3, V4 v4, V5 v5, V6 v6, V7 v7, V8 v8) throws Throwable; + + /** + * Apply this function to the given argument and produces a result. + * + * @param v1 Input argument. + * @param v2 Input argument. + * @param v3 Input argument. + * @param v4 Input argument. + * @param v5 Input argument. + * @param v6 Input argument. + * @param v7 Input argument. + * @param v8 Input argument. + * @return Result. + */ + default R apply(V1 v1, V2 v2, V3 v3, V4 v4, V5 v5, V6 v6, V7 v7, V8 v8) { + return fn(() -> tryApply(v1, v2, v3, v4, v5, v6, v7, v8)); + } + + /** + * Apply this function and run the given action in case of exception. + * + * @param action Action to run when exception occurs. + * @return A new function. + */ + default Function8 onFailure( + java.util.function.Consumer action) { + return onFailure(Throwable.class, action); + } + + /** + * + * Apply this function and run the given action in case of exception. + * + * @param type Exception filter. + * @param action Action to run when exception occurs. + * @param Exception type. + * @return A new function. + */ + default Function8 onFailure( + Class type, + java.util.function.Consumer action) { + return (v1, v2, v3, v4, v5, v6, v7, v8) -> fnOnFailure( + () -> tryApply(v1, v2, v3, v4, v5, v6, v7, v8), + type, action); + } + + /** + * Apply this function and wrap any resulting exception. + * + * @param wrapper Exception wrapper. + * @return A new function. + */ + default Function8 wrap( + java.util.function.Function wrapper) { + return (v1, v2, v3, v4, v5, v6, v7, v8) -> fnWrap( + () -> tryApply(v1, v2, v3, v4, v5, v6, v7, v8), + wrapper); + } + + /** + * Apply this function and unwrap any resulting exception. Useful to get clean/shorter stacktrace. + * + * @param type Exception to unwrap. + * @param Exception type. + * @return A new function. + */ + default Function8 unwrap( + Class type) { + return (v1, v2, v3, v4, v5, v6, v7, v8) -> fnUnwrap( + () -> tryApply(v1, v2, v3, v4, v5, v6, v7, v8), + type); + } + + /** + * Apply this function and returns the given default value in case of exception. + * + * @param defaultValue Exceptional default value. + * @return A new function. + */ + default Function8 orElse(R defaultValue) { + return orElse(() -> defaultValue); + } + + /** + * Apply this function and returns the given default value in case of exception. + * + * @param defaultValue Exceptional default value. + * @return A new function. + */ + default Function8 orElse(Supplier defaultValue) { + return (v1, v2, v3, v4, v5, v6, v7, v8) -> fn(() -> tryApply(v1, v2, v3, v4, v5, v6, v7, v8), + defaultValue); + } + + /** + * Apply this function or recover from it in case of exception. + * + * @param fn Exception recover. + * @return A new function. + */ + default Function8 recover( + java.util.function.Function fn) { + return recover(Throwable.class, fn); + } + + /** + * Apply this function or recover from a specific exception in case of exception. + * + * @param type Exception filter. + * @param fn Exception recover. + * @param Exception type. + * @return A new function. + */ + default Function8 recover( + Class type, java.util.function.Function fn) { + return (v1, v2, v3, v4, v5, v6, v7, v8) -> fnRecover( + () -> tryApply(v1, v2, v3, v4, v5, v6, v7, v8), type, fn); + } + + /** + * A function that remember/cache previous executions. + * + * @return A memo function. + */ + default Function8 memoized() { + if (this instanceof Memoized) { + return this; + } + Map cache = new HashMap<>(); + return (Function8 & Memoized) (v1, v2, v3, v4, v5, v6, v7, v8) -> memo( + cache, + Arrays.asList(v1, v2, v3, v4, v5, v6, v7, v8), + () -> tryApply(v1, v2, v3, v4, v5, v6, v7, v8)); + } + } + + public final static Predicate throwingPredicate(Predicate predicate) { + return predicate; + } + + public final static Predicate2 throwingPredicate(Predicate2 predicate) { + return predicate; + } + + /** + * Factory method for {@link Runnable}. + * + * @param action Runnable. + * @return Same runnable. + */ + public final static Runnable throwingRunnable(Runnable action) { + return action; + } + + /** + * Factory method for {@link Supplier}. + * + * @param fn Supplier. + * @param Resulting value. + * @return Same supplier. + */ + public final static Supplier throwingSupplier(Supplier fn) { + return fn; + } + + /** + * Factory method for {@link Function} and {@link java.util.function.Function}. + * + * @param fn Function. + * @param Input value. + * @param Result value. + * @return Same supplier. + */ + public final static Function throwingFunction(Function fn) { + return fn; + } + + /** + * Factory method for {@link Function2} and {@link java.util.function.BiFunction}. + * + * @param fn Function. + * @param Input value. + * @param Input value. + * @param Result value. + * @return Same supplier. + */ + public final static Function2 throwingFunction(Function2 fn) { + return fn; + } + + public final static Function3 throwingFunction( + Function3 fn) { + return fn; + } + + public final static Function4 throwingFunction( + Function4 fn) { + return fn; + } + + public final static Function5 throwingFunction( + Function5 fn) { + return fn; + } + + public final static Function6 throwingFunction( + Function6 fn) { + return fn; + } + + public final static Function7 throwingFunction( + Function7 fn) { + return fn; + } + + public final static Function8 throwingFunction( + Function8 fn) { + return fn; + } + + public final static Consumer throwingConsumer(Consumer action) { + return action; + } + + public final static Consumer2 throwingConsumer(Consumer2 action) { + return action; + } + + public final static Consumer3 throwingConsumer( + Consumer3 action) { + return action; + } + + public final static Consumer4 throwingConsumer( + Consumer4 action) { + return action; + } + + public final static Consumer5 throwingConsumer( + Consumer5 action) { + return action; + } + + public final static Consumer6 throwingConsumer( + Consumer6 action) { + return action; + } + + public final static Consumer7 throwingConsumer( + Consumer7 action) { + return action; + } + + public final static Consumer8 throwingConsumer( + Consumer8 action) { + return action; + } + + /** + * Throws any throwable 'sneakily' - you don't need to catch it, nor declare that you throw it + * onwards. + * The exception is still thrown - javac will just stop whining about it. + *

+ * Example usage: + *

public void run() {
+   *     throw sneakyThrow(new IOException("You don't need to catch me!"));
+   * }
+ *

+ * NB: The exception is not wrapped, ignored, swallowed, or redefined. The JVM actually does not + * know or care + * about the concept of a 'checked exception'. All this method does is hide the act of throwing a + * checked exception from the java compiler. + *

+ * Note that this method has a return type of {@code RuntimeException}; it is advised you always + * call this + * method as argument to the {@code throw} statement to avoid compiler errors regarding no return + * statement and similar problems. This method won't of course return an actual + * {@code RuntimeException} - + * it never returns, it always throws the provided exception. + * + * @param x The throwable to throw without requiring you to catch its type. + * @return A dummy RuntimeException; this method never returns normally, it always throws + * an exception! + */ + public static RuntimeException sneakyThrow(final Throwable x) { + if (x == null) { + throw new NullPointerException("x"); + } + + sneakyThrow0(x); + return null; + } + + /** + * True if the given exception is one of {@link InterruptedException}, {@link LinkageError}, + * {@link ThreadDeath}, {@link VirtualMachineError}. + * + * @param x Exception to test. + * @return True if the given exception is one of {@link InterruptedException}, {@link LinkageError}, + * {@link ThreadDeath}, {@link VirtualMachineError}. + */ + public static boolean isFatal(Throwable x) { + return x instanceof InterruptedException || + x instanceof LinkageError || + x instanceof ThreadDeath || + x instanceof VirtualMachineError; + } + + /** + * Make a checked exception un-checked and rethrow it. + * + * @param x Exception to throw. + * @param Exception type. + * @throws E Exception to throw. + */ + @SuppressWarnings("unchecked") + private static void sneakyThrow0(final Throwable x) throws E { + throw (E) x; + } + + private static void runAction(Runnable action) { + try { + action.tryRun(); + } catch (Throwable x) { + throw sneakyThrow(x); + } + } + + private static V fn(Supplier fn) { + try { + return fn.tryGet(); + } catch (Throwable x) { + throw sneakyThrow(x); + } + } + + private static R fn(Supplier fn, Supplier orElse) { + try { + return fn.tryGet(); + } catch (Throwable x) { + if (isFatal(x)) { + throw sneakyThrow(x); + } + return orElse.get(); + } + } + + private static R fnRecover(Supplier fn, Class type, + java.util.function.Function recover) { + try { + return fn.tryGet(); + } catch (Throwable x) { + if (isFatal(x)) { + throw sneakyThrow(x); + } + if (type.isInstance(x)) { + return recover.apply(type.cast(x)); + } + throw sneakyThrow(x); + } + } + + private static V fnOnFailure(Supplier fn, Class type, + java.util.function.Consumer consumer) { + try { + return fn.tryGet(); + } catch (Throwable x) { + if (type.isInstance(x)) { + consumer.accept(type.cast(x)); + } + throw sneakyThrow(x); + } + } + + private static V fnWrap(Supplier fn, + java.util.function.Function wrapper) { + try { + return fn.tryGet(); + } catch (Throwable x) { + if (isFatal(x)) { + throw sneakyThrow(x); + } + throw sneakyThrow(wrapper.apply(x)); + } + } + + private static V fnUnwrap(Supplier fn, Class type) { + try { + return fn.tryGet(); + } catch (Throwable x) { + if (isFatal(x)) { + throw sneakyThrow(x); + } + Throwable t = x; + if (type.isInstance(x)) { + t = Optional.ofNullable(x.getCause()).orElse(x); + } + throw sneakyThrow(t); + } + } + + private static void runOnFailure(Runnable action, Class type, + java.util.function.Consumer consumer) { + try { + action.tryRun(); + } catch (Throwable x) { + if (type.isInstance(x)) { + consumer.accept(type.cast(x)); + } + throw sneakyThrow(x); + } + } + + private static void runWrap(Runnable action, + java.util.function.Function wrapper) { + try { + action.tryRun(); + } catch (Throwable x) { + if (isFatal(x)) { + throw sneakyThrow(x); + } + throw sneakyThrow(wrapper.apply(x)); + } + } + + private static void runUnwrap(Runnable action, Class type) { + try { + action.tryRun(); + } catch (Throwable x) { + if (isFatal(x)) { + throw sneakyThrow(x); + } + Throwable t = x; + if (type.isInstance(x)) { + t = Optional.ofNullable(x.getCause()).orElse(x); + } + throw sneakyThrow(t); + } + } + + private final static R memo(Map cache, List key, Supplier fn) { + synchronized (cache) { + R value = cache.get(key); + if (value == null) { + value = fn.get(); + cache.put(key, value); + } + return value; + } + } +} diff --git a/jooby/src/main/java/org/jooby/funzy/Try.java b/jooby/src/main/java/org/jooby/funzy/Try.java new file mode 100644 index 00000000..29a30899 --- /dev/null +++ b/jooby/src/main/java/org/jooby/funzy/Try.java @@ -0,0 +1,932 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.funzy; + +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * Functional try and try-with-resources implementation. + */ +public abstract class Try { + + /** Try with a value. */ + public static abstract class Value extends Try { + + /** + * Gets the success result or {@link Throwing#sneakyThrow(Throwable)} the exception. + * + * @return The success result or {@link Throwing#sneakyThrow(Throwable)} the exception. + */ + public abstract V get(); + + /** + * Get the success value or use the given function on failure. + * + * @param value Default value provider. + * @return Success or default value. + */ + public V orElseGet(Supplier value) { + return isSuccess() ? get() : value.get(); + } + + /** + * Get the success value or use the given default value on failure. + * + * @param value Default value. + * @return Success or default value. + */ + public V orElse(V value) { + return isSuccess() ? get() : value; + } + + /** + * Get the success value or throw an exception created by the exception provider. + * + * @param provider Exception provider. + * @return Success value. + */ + public V orElseThrow(Throwing.Function provider) { + if (isSuccess()) { + return get(); + } + throw Throwing.sneakyThrow(provider.apply(getCause().get())); + } + + /** + * Always run the given action, works like a finally clause. + * + * @param action Finally action. + * @return This try result. + */ + @Override public Value onComplete(Throwing.Runnable action) { + return (Value) super.onComplete(action); + } + + @Override public Value onComplete(final Throwing.Consumer action) { + return (Value) super.onComplete(action); + } + + /** + * Always run the given action, works like a finally clause. Exception and value might be null. + * Exception will be null in case of success. + * + * @param action Finally action. + * @return This try result. + */ + public Value onComplete(final Throwing.Consumer2 action) { + try { + V value = isSuccess() ? get() : null; + action.accept(value, getCause().orElse(null)); + return this; + } catch (Throwable x) { + return (Value) failure(x); + } + } + + /** + * Run the given action if and only if this is a failure. + * + * @param action Failure action/listener. + * @return This try. + */ + @Override public Value onFailure(final Consumer action) { + super.onFailure(action); + return this; + } + + /** + * Run the given action if and only if this is a success. + * + * @param action Success listener. + * @return This try. + */ + @Override public Value onSuccess(final Runnable action) { + super.onSuccess(action); + return this; + } + + /** + * Run the given action if and only if this is a success. + * + * @param action Success listener. + * @return This try. + */ + public Value onSuccess(final Consumer action) { + if (isSuccess()) { + action.accept(get()); + } + return this; + } + + /** + * Recover from failure. The recover function will be executed in case of failure. + * + * @param fn Recover function. + * @return This try on success, a new success try from recover or a failure try in case of exception. + */ + public Value recoverWith(Throwing.Function> fn) { + return recoverWith(Throwable.class, fn); + } + + /** + * Recover from failure if and only if the exception is a subclass of the given exception filter. + * The recover function will be executed in case of failure. + * + * @param exception Exception filter. + * @param fn Recover function. + * @param Exception type. + * @return This try on success, a new success try from recover or a failure try in case of exception. + */ + public Value recoverWith(Class exception, + Throwing.Function> fn) { + return (Value) getCause() + .filter(exception::isInstance) + .map(x -> { + try { + return fn.apply((X) x); + } catch (Throwable ex) { + return failure(ex); + } + }) + .orElse(this); + } + + /** + * Recover from failure. The recover function will be executed in case of failure. + * + * @param fn Recover function. + * @return This try on success, a new success try from recover or a failure try in case of exception. + */ + public Value recover(Throwing.Function fn) { + return recover(Throwable.class, fn); + } + + /** + * Recover from failure if and only if the exception is a subclass of the given exception filter. + * The recover function will be executed in case of failure. + * + * @param exception Exception filter. + * @param value Recover value. + * @param Exception type. + * @return This try on success, a new success try from recover or a failure try in case of exception. + */ + public Value recover(Class exception, V value) { + return recoverWith(exception, x -> Try.success(value)); + } + + /** + * Recover from failure if and only if the exception is a subclass of the given exception filter. + * The recover function will be executed in case of failure. + * + * @param exception Exception filter. + * @param fn Recover function. + * @param Exception type. + * @return This try on success, a new success try from recover or a failure try in case of exception. + */ + public Value recover(Class exception, Throwing.Function fn) { + return recoverWith(exception, x -> Try.apply(() -> fn.apply(x))); + } + + /** + * Flat map the success value. + * + * @param mapper Mapper. + * @param New type. + * @return A new try value for success or failure. + */ + public Value flatMap(Throwing.Function> mapper) { + if (isFailure()) { + return (Value) this; + } + try { + return mapper.apply(get()); + } catch (Throwable x) { + return new Failure<>(x); + } + } + + /** + * Map the success value. + * + * @param mapper Mapper. + * @param New type. + * @return A new try value for success or failure. + */ + public Value map(Throwing.Function mapper) { + return flatMap(v -> new Success<>(mapper.apply(v))); + } + + /** + * Get an empty optional in case of failure. + * + * @return An empty optional in case of failure. + */ + public Optional toOptional() { + return isFailure() ? Optional.empty() : Optional.ofNullable(get()); + } + + @Override public Value unwrap(Class type) { + return (Value) super.unwrap(type); + } + + @Override public Value unwrap(final Throwing.Predicate predicate) { + return (Value) super.unwrap(predicate); + } + + @Override public Value wrap(final Throwing.Function wrapper) { + return (Value) super.wrap(wrapper); + } + + @Override public Value wrap(final Class predicate, + final Throwing.Function wrapper) { + return (Value) super.wrap(predicate, wrapper); + } + + @Override public Value wrap(final Throwing.Predicate predicate, + final Throwing.Function wrapper) { + return (Value) super.wrap(predicate, wrapper); + } + } + + private static class Success extends Value { + private final V value; + + public Success(V value) { + this.value = value; + } + + @Override public V get() { + return value; + } + + @Override public Optional getCause() { + return Optional.empty(); + } + } + + private static class Failure extends Value { + private final Throwable x; + + public Failure(Throwable x) { + this.x = x; + } + + @Override public V get() { + throw Throwing.sneakyThrow(x); + } + + @Override public Optional getCause() { + return Optional.of(x); + } + } + + /** + * Try with resource implementation. + * + * @param Resource type. + */ + public static class ResourceHandler { + + private static class ProxyCloseable

+ implements AutoCloseable, Throwing.Supplier { + + private final Throwing.Supplier

parent; + private final Throwing.Function mapper; + private P parentResource; + private R resource; + + public ProxyCloseable(Throwing.Supplier

parent, Throwing.Function mapper) { + this.parent = parent; + this.mapper = mapper; + } + + @Override public void close() throws Exception { + try { + Optional.ofNullable(resource) + .ifPresent(Throwing.throwingConsumer(AutoCloseable::close)); + } finally { + if (parent instanceof ProxyCloseable) { + ((ProxyCloseable) parent).close(); + } else { + Optional.ofNullable(parentResource) + .ifPresent(Throwing.throwingConsumer(AutoCloseable::close)); + } + } + } + + @Override public R tryGet() throws Throwable { + if (parent instanceof ProxyCloseable) { + ProxyCloseable proxy = (ProxyCloseable) parent; + if (proxy.resource == null) { + proxy.get(); + } + this.parentResource = (P) proxy.resource; + } else { + this.parentResource = parent.get(); + } + this.resource = mapper.apply(parentResource); + return (R) this; + } + } + + private final Throwing.Supplier r; + + private ResourceHandler(Throwing.Supplier r1) { + this.r = r1; + } + + /** + * Map the resource to a new closeable resource. + * + * @param fn Mapper. + * @param New resource type. + * @return A new resource handler. + */ + public ResourceHandler map(Throwing.Function fn) { + return new ResourceHandler<>(new ProxyCloseable<>(this.r, fn)); + } + + /** + * Apply the resource and produces an output. + * + * @param fn Function to apply. + * @param Output type. + * @return A new try result. + */ + public Value apply(Throwing.Function fn) { + return Try.apply(() -> { + try (R r1 = this.r.get()) { + if (r1 instanceof ProxyCloseable) { + return fn.apply((R) ((ProxyCloseable) r1).resource); + } + return fn.apply(r1); + } + }); + } + + /** + * Run an operation over the resource. + * + * @param fn Function to apply. + * @return A new try result. + */ + public Try run(Throwing.Consumer fn) { + return Try.run(() -> { + try (R r1 = this.r.get()) { + fn.accept(r1); + } + }); + } + } + + /** + * Try with resource implementation. + * + * @param Resource type. + * @param Resource type. + */ + public static class ResourceHandler2 { + private final Throwing.Supplier r1; + private final Throwing.Supplier r2; + + private ResourceHandler2(Throwing.Supplier r1, Throwing.Supplier r2) { + this.r1 = r1; + this.r2 = r2; + } + + public Value apply(Throwing.Function2 fn) { + return Try.apply(() -> { + try (R1 r1 = this.r1.get(); R2 r2 = this.r2.get()) { + return fn.apply(r1, r2); + } + }); + } + + public Try run(Throwing.Consumer2 fn) { + return Try.run(() -> { + try (R1 r1 = this.r1.get(); R2 r2 = this.r2.get()) { + fn.accept(r1, r2); + } + }); + } + } + + public static class ResourceHandler3 { + private final Throwing.Supplier r1; + private final Throwing.Supplier r2; + private final Throwing.Supplier r3; + + private ResourceHandler3(Throwing.Supplier r1, Throwing.Supplier r2, + Throwing.Supplier r3) { + this.r1 = r1; + this.r2 = r2; + this.r3 = r3; + } + + public Value apply(Throwing.Function3 fn) { + return Try.apply(() -> { + try (R1 r1 = this.r1.get(); R2 r2 = this.r2.get(); R3 r3 = this.r3.get()) { + return fn.apply(r1, r2, r3); + } + }); + } + + public Try run(Throwing.Consumer3 fn) { + return Try.run(() -> { + try (R1 r1 = this.r1.get(); R2 r2 = this.r2.get(); R3 r3 = this.r3.get()) { + fn.accept(r1, r2, r3); + } + }); + } + } + + public static class ResourceHandler4 { + private final Throwing.Supplier r1; + private final Throwing.Supplier r2; + private final Throwing.Supplier r3; + private final Throwing.Supplier r4; + + private ResourceHandler4(Throwing.Supplier r1, Throwing.Supplier r2, + Throwing.Supplier r3, Throwing.Supplier r4) { + this.r1 = r1; + this.r2 = r2; + this.r3 = r3; + this.r4 = r4; + } + + public Value apply(Throwing.Function4 fn) { + return Try.apply(() -> { + try (R1 r1 = this.r1.get(); + R2 r2 = this.r2.get(); + R3 r3 = this.r3.get(); + R4 r4 = this.r4.get()) { + return fn.apply(r1, r2, r3, r4); + } + }); + } + + public Try run(Throwing.Consumer4 fn) { + return Try.run(() -> { + try (R1 r1 = this.r1.get(); + R2 r2 = this.r2.get(); + R3 r3 = this.r3.get(); + R4 r4 = this.r4.get()) { + fn.accept(r1, r2, r3, r4); + } + }); + } + } + + /** + * Functional try-with-resources: + * + *

{@code
+   *  InputStream in = ...;
+   *
+   *  byte[] content = Try.of(in)
+   *    .apply(in -> read(in))
+   *    .get();
+   *
+   * }
+ * + * Jdbc example: + * + *
{@code
+   *  Connection connection = ...;
+   *
+   *  Try.of(connection)
+   *     .map(c -> c.preparedStatement("..."))
+   *     .map(stt -> stt.executeQuery())
+   *     .apply(rs-> {
+   *       return res.getString("column");
+   *     })
+   *     .get();
+   *
+   * }
+ * + * @param r1 Input resource. + * @param Resource type. + * @return A resource handler. + */ + public final static ResourceHandler of(R r1) { + return with(() -> r1); + } + + /** + * Functional try-with-resources: + * + *
{@code
+   *  InputStream in = ...;
+   *  OutputStream out = ...;
+   *
+   *  Try.of(in, out)
+   *    .run((from, to) -> copy(from, to))
+   *    .onFailure(Throwable::printStacktrace);
+   *
+   * }
+ * + * @param r1 Input resource. + * @param r2 Input resource. + * @param Resource type. + * @param Resource type. + * @return A resource handler. + */ + public final static ResourceHandler2 of( + R1 r1, R2 r2) { + return with(() -> r1, () -> r2); + } + + /** + * Functional try-with-resources with 3 inputs. + * + * @param r1 Input resource. + * @param r2 Input resource. + * @param r3 Input resource. + * @param Resource type. + * @param Resource type. + * @param Resource type. + * @return A resource handler. + */ + public final static ResourceHandler3 of( + R1 r1, R2 r2, R3 r3) { + return with(() -> r1, () -> r2, () -> r3); + } + + /** + * Functional try-with-resources with 4 inputs. + * + * @param r1 Input resource. + * @param r2 Input resource. + * @param r3 Input resource. + * @param r4 Input resource. + * @param Resource type. + * @param Resource type. + * @param Resource type. + * @param Resource type. + * @return A resource handler. + */ + public final static ResourceHandler4 of( + R1 r1, R2 r2, R3 r3, R4 r4) { + return with(() -> r1, () -> r2, () -> r3, () -> r4); + } + + /** + * Functional try-with-resources: + * + *
{@code
+   *  byte[] content = Try.with(() -> newInputStream())
+   *    .apply(in -> read(in))
+   *    .get();
+   *
+   * }
+ * + * Jdbc example: + * + *
{@code
+   *  Try.with(() -> newConnection())
+   *     .map(c -> c.preparedStatement("..."))
+   *     .map(stt -> stt.executeQuery())
+   *     .apply(rs-> {
+   *       return res.getString("column");
+   *     })
+   *     .get();
+   *
+   * }
+ * + * @param r1 Input resource. + * @param Resource type. + * @return A resource handler. + */ + public final static ResourceHandler with(Throwing.Supplier r1) { + return new ResourceHandler<>(r1); + } + + /** + * Functional try-with-resources: + * + *
{@code
+   *  Try.with(() -> newIn(), () -> newOut())
+   *    .run((from, to) -> copy(from, to))
+   *    .onFailure(Throwable::printStacktrace);
+   * }
+ * + * @param r1 Input resource. + * @param r2 Input resource. + * @param Resource type. + * @param Resource type. + * @return A resource handler. + */ + public final static ResourceHandler2 with( + Throwing.Supplier r1, Throwing.Supplier r2) { + return new ResourceHandler2<>(r1, r2); + } + + /** + * Functional try-with-resources with 3 inputs. + * + * @param r1 Input resource. + * @param r2 Input resource. + * @param r3 Input resource. + * @param Resource type. + * @param Resource type. + * @param Resource type. + * @return A resource handler. + */ + public final static ResourceHandler3 with( + Throwing.Supplier r1, Throwing.Supplier r2, Throwing.Supplier r3) { + return new ResourceHandler3<>(r1, r2, r3); + } + + /** + * Functional try-with-resources with 4 inputs. + * + * @param r1 Input resource. + * @param r2 Input resource. + * @param r3 Input resource. + * @param r4 Input resource. + * @param Resource type. + * @param Resource type. + * @param Resource type. + * @param Resource type. + * @return A resource handler. + */ + public final static ResourceHandler4 with( + Throwing.Supplier r1, Throwing.Supplier r2, Throwing.Supplier r3, + Throwing.Supplier r4) { + return new ResourceHandler4<>(r1, r2, r3, r4); + } + + /** + * Get a new success value. + * + * @param value Value. + * @param Value type. + * @return A new success value. + */ + public final static Value success(V value) { + return new Success<>(value); + } + + /** + * Get a new failure value. + * + * @param x Exception. + * @return A new failure value. + */ + public final static Value failure(Throwable x) { + return new Failure<>(x); + } + + /** + * Creates a new try from given value provider. + * + * @param fn Value provider. + * @param Value type. + * @return A new success try or failure try in case of exception. + */ + public static Value apply(Throwing.Supplier fn) { + try { + return new Success<>(fn.get()); + } catch (Throwable x) { + return new Failure(x); + } + } + + /** + * Creates a new try from given callable. + * + * @param fn Callable. + * @param Value type. + * @return A new success try or failure try in case of exception. + */ + public static Value call(Callable fn) { + return apply(fn::call); + } + + /** + * Creates a side effect try from given runnable. Don't forget to either throw or log the exception + * in case of failure. Unless, of course you don't care about the exception. + * + * Log the exception: + *
{@code
+   *   Try.run(() -> ...)
+   *     .onFailure(x -> x.printStacktrace());
+   * }
+ * + * Throw the exception: + *
{@code
+   *   Try.run(() -> ...)
+   *     .throwException();
+   * }
+ * + * @param runnable Runnable. + * @return A void try. + */ + public static Try run(Throwing.Runnable runnable) { + try { + runnable.run(); + return new Success<>(null); + } catch (Throwable x) { + return new Failure(x); + } + } + + /** + * True in case of failure. + * + * @return True in case of failure. + */ + public boolean isFailure() { + return getCause().isPresent(); + } + + /** + * True in case of success. + * + * @return True in case of success. + */ + public boolean isSuccess() { + return !isFailure(); + } + + /** + * Run the given action if and only if this is a failure. + * + * @param action Failure listener. + * @return This try. + */ + public Try onFailure(Consumer action) { + getCause().ifPresent(action); + return this; + } + + /** + * Run the given action if and only if this is a success. + * + * @param action Success listener. + * @return This try. + */ + public Try onSuccess(Runnable action) { + if (isSuccess()) { + action.run(); + } + return this; + } + + /** + * In case of failure unwrap the exception provided by calling {@link Throwable#getCause()}. + * Useful for clean/shorter stackstrace. + * + * Example for {@link java.lang.reflect.InvocationTargetException}: + * + *
{@code
+   * Try.run(() -> {
+   *   Method m = ...;
+   *   m.invoke(...); //might throw InvocationTargetException
+   * }).unwrap(InvocationTargetException.class)
+   *   .throwException();
+   * }
+ * + * @param type Exception filter. + * @param Exception type. + * @return This try for success or a new failure with exception unwrap. + */ + public Try unwrap(Class type) { + return unwrap(type::isInstance); + } + + /** + * In case of failure unwrap the exception provided by calling {@link Throwable#getCause()}. + * Useful for clean/shorter stackstrace. + * + * Example for {@link java.lang.reflect.InvocationTargetException}: + * + *
{@code
+   * Try.run(() -> {
+   *   Method m = ...;
+   *   m.invoke(...); //might throw InvocationTargetException
+   * }).unwrap(InvocationTargetException.class::isInstance)
+   *   .throwException();
+   * }
+ * + * @param predicate Exception filter. + * @return This try for success or a new failure with exception unwrap. + */ + public Try unwrap(Throwing.Predicate predicate) { + try { + return getCause() + .filter(predicate) + .map(Throwable::getCause) + .filter(Objects::nonNull) + .map(x -> (Try) Try.failure(x)) + .orElse(this); + } catch (Throwable x) { + return failure(x); + } + } + + /** + * In case of failure wrap an exception matching the given predicate to something else. + * + * @param wrapper Exception mapper. + * @return This try for success or a new failure with exception wrapped. + */ + public Try wrap(Throwing.Function wrapper) { + return wrap(Throwable.class, wrapper); + } + + /** + * In case of failure wrap an exception matching the given predicate to something else. + * + * @param predicate Exception predicate. + * @param wrapper Exception mapper. + * @param Exception type. + * @return This try for success or a new failure with exception wrapped. + */ + public Try wrap(Class predicate, + Throwing.Function wrapper) { + return wrap(predicate::isInstance, wrapper); + } + + /** + * In case of failure wrap an exception matching the given predicate to something else. + * + * @param predicate Exception predicate. + * @param wrapper Exception mapper. + * @param Exception type. + * @return This try for success or a new failure with exception wrapped. + */ + public Try wrap(Throwing.Predicate predicate, + Throwing.Function wrapper) { + try { + return getCause() + .filter(x -> predicate.test((X) x)) + .map(x -> (Try) Try.failure(wrapper.apply((X) x))) + .orElse(this); + } catch (Throwable x) { + return failure(x); + } + } + + /** + * Always run the given action, works like a finally clause. + * + * @param action Finally action. + * @return This try result. + */ + public Try onComplete(Throwing.Runnable action) { + try { + action.run(); + return this; + } catch (Throwable x) { + return Try.failure(x); + } + } + + /** + * Always run the given action, works like a finally clause. Exception will be null in case of success. + * + * @param action Finally action. + * @return This try result. + */ + public Try onComplete(Throwing.Consumer action) { + try { + action.accept(getCause().orElse(null)); + return this; + } catch (Throwable x) { + return Try.failure(x); + } + } + + /** + * Propagate/throw the exception in case of failure. + */ + public void throwException() { + getCause().ifPresent(Throwing::sneakyThrow); + } + + /** + * Cause for failure or empty optional for success result. + * + * @return Cause for failure or empty optional for success result. + */ + public abstract Optional getCause(); + +} diff --git a/jooby/src/main/java/org/jooby/funzy/When.java b/jooby/src/main/java/org/jooby/funzy/When.java new file mode 100644 index 00000000..bd1fdd23 --- /dev/null +++ b/jooby/src/main/java/org/jooby/funzy/When.java @@ -0,0 +1,166 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.funzy; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.Optional; + +/** + * Functional idiom for switch/case statement. + * + * Basic example: + *
{code@
+ *   import static org.jooby.funzy.When.when;
+ *
+ *   Object value = ...;
+ *   String result = when(value)
+ *     .is(Number.class, "Got a number")
+ *     .is(String.class, "Got a string")
+ *     .orElse("Unknown");
+ *   System.out.println(result);
+ * }
+ * + * Automatic cast example: + * + *
{@code
+ *   import static org.jooby.funzy.When.when;
+ *
+ *   Object value = ...;
+ *   int result = when(value)
+ *     .is(Integer.class, i -> i * 2)
+ *     .orElse(-1);
+ *
+ *   System.out.println(result);
+ * }
+ * + * + * @param Input type. + */ +public class When { + + public static class Value { + private final V source; + private final Map predicates = new LinkedHashMap<>(); + + private Value(final V source) { + this.source = source; + } + + public Value is(V value, R result) { + return is(source -> Objects.equals(source, value), v -> result); + } + + public Value is(Class predicate, R result) { + return is(predicate::isInstance, v -> result); + } + + public Value is(V value, Throwing.Supplier result) { + return is(source -> Objects.equals(source, value), v -> result.get()); + } + + public Value is(Class predicate, Throwing.Function result) { + return is(predicate::isInstance, result); + } + + public Value is(Throwing.Predicate predicate, + Throwing.Supplier result) { + return is(predicate, v -> result.get()); + } + + public Value is(Throwing.Predicate predicate, + Throwing.Function result) { + predicates.put(predicate, result); + return this; + } + + public R get() { + return toOptional().orElseThrow(NoSuchElementException::new); + } + + public R orElse(R value) { + return toOptional().orElse(value); + } + + public R orElseGet(Throwing.Supplier value) { + return toOptional().orElseGet(value); + } + + public R orElseThrow(Throwing.Supplier exception) { + return toOptional().orElseThrow(() -> Throwing.sneakyThrow(exception.get())); + } + + public Optional toOptional() { + for (Map.Entry predicate : predicates.entrySet()) { + if (predicate.getKey().test(source)) { + return Optional.ofNullable( (R) predicate.getValue().apply(source)); + } + } + return Optional.empty(); + } + } + + private final V source; + + public When(final V source) { + this.source = source; + } + + public final static When when(V value) { + return new When<>(value); + } + + public Value is(V value, R result) { + Value when = new Value<>(source); + when.is(value, result); + return when; + } + + public Value is(Class predicate, R result) { + Value when = new Value<>(source); + when.is(predicate, result); + return when; + } + + public Value is(V value, Throwing.Supplier result) { + Value when = new Value<>(source); + when.is(value, result); + return when; + } + + public Value is(Class predicate, Throwing.Function result) { + Value when = new Value<>(source); + when.is(predicate, result); + return when; + } + + public Value is(Throwing.Predicate predicate, + Throwing.Supplier result) { + Value when = new Value<>(source); + when.is(predicate, result); + return when; + } + + public Value is(Throwing.Predicate predicate, + Throwing.Function result) { + Value when = new Value<>(source); + when.is(predicate, result); + return when; + } + +} diff --git a/jooby/src/main/java/org/jooby/handlers/AssetHandler.java b/jooby/src/main/java/org/jooby/handlers/AssetHandler.java new file mode 100644 index 00000000..d1d8d309 --- /dev/null +++ b/jooby/src/main/java/org/jooby/handlers/AssetHandler.java @@ -0,0 +1,486 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.handlers; + +import com.google.common.base.Strings; +import com.typesafe.config.ConfigFactory; +import com.typesafe.config.ConfigValueFactory; +import org.jooby.Asset; +import org.jooby.Err; +import org.jooby.Jooby; +import org.jooby.MediaType; +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Route; +import org.jooby.Status; +import org.jooby.funzy.Throwing; +import org.jooby.funzy.Try; +import org.jooby.internal.URLAsset; + +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.text.MessageFormat; +import java.time.Duration; +import java.util.Date; +import java.util.Map; + +import static java.util.Objects.requireNonNull; + +/** + * Serve static resources, via {@link Jooby#assets(String)} or variants. + * + *

e-tag support

+ *

+ * It generates ETag headers using {@link Asset#etag()}. It handles + * If-None-Match header automatically. + *

+ *

+ * ETag handling is enabled by default. If you want to disabled etag support + * {@link #etag(boolean)}. + *

+ * + *

modified since support

+ *

+ * It generates Last-Modified header using {@link Asset#lastModified()}. It handles + * If-Modified-Since header automatically. + *

+ * + *

CDN support

+ *

+ * Asset can be serve from a content delivery network (a.k.a cdn). All you have to do is to set the + * assets.cdn property. + *

+ * + *
+ * assets.cdn = "http://d7471vfo50fqt.cloudfront.net"
+ * 
+ * + *

+ * Resolved assets are redirected to the cdn. + *

+ * + * @author edgar + * @since 0.1.0 + */ +public class AssetHandler implements Route.Handler { + + private interface Loader { + URL getResource(String name); + } + + private static final Throwing.Function prefix = prefix().memoized(); + + private Throwing.Function2 fn; + + private Loader loader; + + private String cdn; + + private boolean etag = true; + + private long maxAge = -1; + + private boolean lastModified = true; + + private int statusCode = 404; + + private String location; + + private Path basedir; + + private ClassLoader classLoader; + + /** + *

+ * Creates a new {@link AssetHandler}. The handler accepts a location pattern, that serve for + * locating the static resource. + *

+ * + * Given assets("/assets/**", "/") with: + * + *
+   *   GET /assets/js/index.js it translates the path to: /assets/js/index.js
+   * 
+ * + * Given assets("/js/**", "/assets") with: + * + *
+   *   GET /js/index.js it translate the path to: /assets/js/index.js
+   * 
+ * + * Given assets("/webjars/**", "/META-INF/resources/webjars/{0}") with: + * + *
+   *   GET /webjars/jquery/2.1.3/jquery.js it translate the path to: /META-INF/resources/webjars/jquery/2.1.3/jquery.js
+   * 
+ * + * @param pattern Pattern to locate static resources. + * @param loader The one who load the static resources. + */ + public AssetHandler(final String pattern, final ClassLoader loader) { + this.location = Route.normalize(pattern); + this.basedir = Paths.get("public"); + this.classLoader = loader; + } + + /** + *

+ * Creates a new {@link AssetHandler}. The handler accepts a location pattern, that serve for + * locating the static resource. + *

+ * + * Given assets("/assets/**", "/") with: + * + *
+   *   GET /assets/js/index.js it translates the path to: /assets/js/index.js
+   * 
+ * + * Given assets("/js/**", "/assets") with: + * + *
+   *   GET /js/index.js it translate the path to: /assets/js/index.js
+   * 
+ * + * Given assets("/webjars/**", "/META-INF/resources/webjars/{0}") with: + * + *
+   *   GET /webjars/jquery/2.1.3/jquery.js it translate the path to: /META-INF/resources/webjars/jquery/2.1.3/jquery.js
+   * 
+ * + * @param basedir Base directory. + */ + public AssetHandler(final Path basedir) { + this.location = "/{0}"; + this.basedir = basedir; + this.classLoader = getClass().getClassLoader(); + } + + /** + *

+ * Creates a new {@link AssetHandler}. The location pattern can be one of. + *

+ * + * Given / like in assets("/assets/**", "/") with: + * + *
+   *   GET /assets/js/index.js it translates the path to: /assets/js/index.js
+   * 
+ * + * Given /assets like in assets("/js/**", "/assets") with: + * + *
+   *   GET /js/index.js it translate the path to: /assets/js/index.js
+   * 
+ * + * Given /META-INF/resources/webjars/{0} like in + * assets("/webjars/**", "/META-INF/resources/webjars/{0}") with: + * + *
+   *   GET /webjars/jquery/2.1.3/jquery.js it translate the path to: /META-INF/resources/webjars/jquery/2.1.3/jquery.js
+   * 
+ * + * @param pattern Pattern to locate static resources. + */ + public AssetHandler(final String pattern) { + this.location = Route.normalize(pattern); + this.basedir = Paths.get("public"); + this.classLoader = getClass().getClassLoader(); + } + + /** + * @param etag Turn on/off etag support. + * @return This handler. + */ + public AssetHandler etag(final boolean etag) { + this.etag = etag; + return this; + } + + /** + * @param enabled Turn on/off last modified support. + * @return This handler. + */ + public AssetHandler lastModified(final boolean enabled) { + this.lastModified = enabled; + return this; + } + + /** + * @param cdn If set, every resolved asset will be serve from it. + * @return This handler. + */ + public AssetHandler cdn(final String cdn) { + this.cdn = Strings.emptyToNull(cdn); + return this; + } + + /** + * @param maxAge Set the cache header max-age value. + * @return This handler. + */ + public AssetHandler maxAge(final Duration maxAge) { + return maxAge(maxAge.getSeconds()); + } + + /** + * @param maxAge Set the cache header max-age value in seconds. + * @return This handler. + */ + public AssetHandler maxAge(final long maxAge) { + this.maxAge = maxAge; + return this; + } + + /** + * Set the route definition and initialize the handler. + * + * @param route Route definition. + * @return This handler. + */ + public AssetHandler setRoute(final Route.AssetDefinition route) { + String prefix; + boolean rootLocation = location.equals("/") || location.equals("/{0}"); + if (rootLocation) { + String pattern = route.pattern(); + int i = pattern.indexOf("/*"); + if (i > 0) { + prefix = pattern.substring(0, i + 1); + } else { + prefix = pattern; + } + } else { + int i = location.indexOf("{"); + if (i > 0) { + prefix = location.substring(0, i); + } else { + /// TODO: review what we have here + prefix = location; + } + } + if (prefix.startsWith("/")) { + prefix = prefix.substring(1); + } + if (prefix.isEmpty() && rootLocation) { + throw new IllegalArgumentException( + "For security reasons root classpath access is not allowed. Map your static resources " + + "using a prefix like: assets(static/**); or use a location classpath prefix like: " + + "assets(/, /static/{0})"); + } + init(prefix, location, basedir, classLoader); + return this; + } + + /** + * Parse value as {@link Duration}. If the value is already a number then it uses as seconds. + * Otherwise, it parse expressions like: 8m, 1h, 365d, etc... + * + * @param maxAge Set the cache header max-age value in seconds. + * @return This handler. + */ + public AssetHandler maxAge(final String maxAge) { + Try.apply(() -> Long.parseLong(maxAge)) + .recover(x -> ConfigFactory.empty() + .withValue("v", ConfigValueFactory.fromAnyRef(maxAge)) + .getDuration("v") + .getSeconds()) + .onSuccess(this::maxAge); + return this; + } + + /** + * Indicates what to do when an asset is missing (not resolved). Default action is to resolve them + * as 404 (NOT FOUND) request. + * + * If you specify a status code <= 0, missing assets are ignored and the next handler on pipeline + * will be executed. + * + * @param statusCode HTTP code or 0. + * @return This handler. + */ + public AssetHandler onMissing(final int statusCode) { + this.statusCode = statusCode; + return this; + } + + @Override + public void handle(final Request req, final Response rsp) throws Throwable { + String path = req.path(); + URL resource = resolve(req, path); + + if (resource != null) { + String localpath = resource.getPath(); + int jarEntry = localpath.indexOf("!/"); + if (jarEntry > 0) { + localpath = localpath.substring(jarEntry + 2); + } + + URLAsset asset = new URLAsset(resource, path, + MediaType.byPath(localpath).orElse(MediaType.octetstream)); + + if (asset.exists()) { + // cdn? + if (cdn != null) { + String absUrl = cdn + path; + rsp.redirect(absUrl); + rsp.end(); + } else { + doHandle(req, rsp, asset); + } + } + } else if (statusCode > 0) { + throw new Err(statusCode); + } + } + + private void doHandle(final Request req, final Response rsp, final Asset asset) throws Throwable { + // handle etag + if (this.etag) { + String etag = asset.etag(); + boolean ifnm = req.header("If-None-Match").toOptional() + .map(etag::equals) + .orElse(false); + if (ifnm) { + rsp.header("ETag", etag).status(Status.NOT_MODIFIED).end(); + return; + } + + rsp.header("ETag", etag); + } + + // Handle if modified since + if (this.lastModified) { + long lastModified = asset.lastModified(); + if (lastModified > 0) { + boolean ifm = req.header("If-Modified-Since").toOptional(Long.class) + .map(ifModified -> lastModified / 1000 <= ifModified / 1000) + .orElse(false); + if (ifm) { + rsp.status(Status.NOT_MODIFIED).end(); + return; + } + rsp.header("Last-Modified", new Date(lastModified)); + } + } + + // cache max-age + if (maxAge > 0) { + rsp.header("Cache-Control", "max-age=" + maxAge); + } + + send(req, rsp, asset); + } + + /** + * Send an asset to the client. + * + * @param req Request. + * @param rsp Response. + * @param asset Resolve asset. + * @throws Exception If send fails. + */ + protected void send(final Request req, final Response rsp, final Asset asset) throws Throwable { + rsp.send(asset); + } + + private URL resolve(final Request req, final String path) throws Throwable { + String target = fn.apply(req, path); + return resolve(target); + } + + /** + * Resolve a path as a {@link URL}. + * + * @param path Path of resource to resolve. + * @return A URL or null for unresolved resource. + * @throws Exception If something goes wrong. + */ + protected URL resolve(final String path) throws Exception { + return loader.getResource(path); + } + + private void init(final String classPathPrefix, final String location, final Path basedir, + final ClassLoader loader) { + requireNonNull(loader, "Resource loader is required."); + this.fn = location.equals("/") + ? (req, p) -> prefix.apply(p) + : (req, p) -> MessageFormat.format(prefix.apply(location), vars(req)); + this.loader = loader(basedir, classpathLoader(classPathPrefix, classLoader)); + } + + private static Object[] vars(final Request req) { + Map vars = req.route().vars(); + return vars.values().toArray(new Object[vars.size()]); + } + + private static Loader loader(final Path basedir, Loader classpath) { + if (basedir != null && Files.exists(basedir)) { + return name -> { + Path path = basedir.resolve(name).normalize(); + if (Files.exists(path) && path.startsWith(basedir)) { + try { + return path.toUri().toURL(); + } catch (MalformedURLException x) { + // shh + } + } + return classpath.getResource(name); + }; + } + return classpath; + } + + private static Loader classpathLoader(String prefix, ClassLoader classloader) { + return name -> { + String safePath = safePath(name); + if (safePath.startsWith(prefix)) { + URL resource = classloader.getResource(safePath); + return resource; + } + return null; + }; + } + + private static String safePath(String name) { + if (name.indexOf("./") > 0) { + Path path = toPath(name.split("/")).normalize(); + return toStringPath(path); + } + return name; + } + + private static String toStringPath(Path path) { + StringBuilder buffer = new StringBuilder(); + for (Path segment : path) { + buffer.append("/").append(segment); + } + return buffer.substring(1); + } + + private static Path toPath(String[] segments) { + Path path = Paths.get(segments[0]); + for (int i = 1; i < segments.length; i++) { + path = path.resolve(segments[i]); + } + return path; + } + + private static Throwing.Function prefix() { + return p -> p.substring(1); + } +} diff --git a/jooby/src/main/java/org/jooby/handlers/Cors.java b/jooby/src/main/java/org/jooby/handlers/Cors.java new file mode 100644 index 00000000..c1fa7cfd --- /dev/null +++ b/jooby/src/main/java/org/jooby/handlers/Cors.java @@ -0,0 +1,412 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.handlers; + +import static java.util.Objects.requireNonNull; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import javax.inject.Inject; +import javax.inject.Named; + +import com.google.common.collect.ImmutableList; +import com.typesafe.config.Config; + +/** + *

Cross-origin resource sharing

+ *

+ * Cross-origin resource sharing (CORS) is a mechanism that allows restricted resources (e.g. fonts, + * JavaScript, etc.) on a web page to be requested from another domain outside the domain from which + * the resource originated. + *

+ * + *

+ * This class represent the available options for configure CORS in Jooby. + *

+ * + *

usage

+ * + *
+ * {
+ *   use("*", new CorsHandler(new Cors()));
+ * }
+ * 
+ * + *

+ * Previous example, adds a cors filter using the default cors options. + *

+ * + * @author edgar + * @since 0.8.0 + */ +public class Cors { + + private static class Matcher implements Predicate { + + private List values; + + private Predicate predicate; + + private boolean wild; + + public Matcher(final List values, final Predicate predicate) { + this.values = ImmutableList.copyOf(values); + this.predicate = predicate; + this.wild = values.contains("*"); + } + + @Override + public boolean test(final T value) { + return predicate.test(value); + } + + } + + private boolean enabled; + + private Matcher origin; + + private boolean credentials; + + private Matcher requestMehods; + + private Matcher> requestHeaders; + + private int maxAge; + + private List exposedHeaders; + + /** + * Creates {@link Cors} options from {@link Config}: + * + *
+   *  origin: "*"
+   *  credentials: true
+   *  allowedMethods: [GET, POST]
+   *  allowedHeaders: [X-Requested-With, Content-Type, Accept, Origin]
+   *  exposedHeaders: []
+   * 
+ * + * @param config Config to use. + */ + @Inject + public Cors(@Named("cors") final Config config) { + requireNonNull(config, "Config is required."); + this.enabled = config.hasPath("enabled") ? config.getBoolean("enabled") : true; + withOrigin(list(config.getAnyRef("origin"))); + this.credentials = config.getBoolean("credentials"); + withMethods(list(config.getAnyRef("allowedMethods"))); + withHeaders(list(config.getAnyRef("allowedHeaders"))); + withMaxAge((int) config.getDuration("maxAge", TimeUnit.SECONDS)); + withExposedHeaders(config.hasPath("exposedHeaders") + ? list(config.getAnyRef("exposedHeaders")) + : Collections.emptyList()); + } + + /** + * Creates default {@link Cors}. Default options are: + * + *
+   *  origin: "*"
+   *  credentials: true
+   *  allowedMethods: [GET, POST]
+   *  allowedHeaders: [X-Requested-With, Content-Type, Accept, Origin]
+   *  exposedHeaders: []
+   * 
+ */ + public Cors() { + this.enabled = true; + withOrigin("*"); + credentials = true; + withMethods("GET", "POST"); + withHeaders("X-Requested-With", "Content-Type", "Accept", "Origin"); + withMaxAge(1800); + withExposedHeaders(); + } + + /** + * Set {@link #credentials()} to false. + * + * @return This cors. + */ + public Cors withoutCreds() { + this.credentials = false; + return this; + } + + /** + * @return True, if cors is enabled. Controlled by: cors.enabled property. Default + * is: true. + */ + public boolean enabled() { + return enabled; + } + + /** + * Disabled cors (enabled = false). + * + * @return This cors. + */ + public Cors disabled() { + enabled = false; + return this; + } + + /** + * If true, set the Access-Control-Allow-Credentials header. Controlled by: + * cors.credentials property. Default is: true + * + * @return If the Access-Control-Allow-Credentials header must be set. + */ + public boolean credentials() { + return this.credentials; + } + + /** + * @return True if any origin is accepted. + */ + public boolean anyOrigin() { + return origin.wild; + } + + /** + * An origin must be a "*" (any origin), a domain name (like, http://foo.com) and/or a regex + * (like, http://*.domain.com). + * + * @return List of valid origins: Default is: * + */ + public List origin() { + return origin.values; + } + + /** + * Test if the given origin is allowed or not. + * + * @param origin The origin to test. + * @return True if the origin is allowed. + */ + public boolean allowOrigin(final String origin) { + return this.origin.test(origin); + } + + /** + * Set the allowed origins. An origin must be a "*" (any origin), a domain name (like, + * http://foo.com) and/or a regex (like, http://*.domain.com). + * + * @param origin One ore more origin. + * @return This cors. + */ + public Cors withOrigin(final String... origin) { + return withOrigin(Arrays.asList(origin)); + } + + /** + * Set the allowed origins. An origin must be a "*" (any origin), a domain name (like, + * http://foo.com) and/or a regex (like, http://*.domain.com). + * + * @param origin One ore more origin. + * @return This cors. + */ + public Cors withOrigin(final List origin) { + this.origin = firstMatch(requireNonNull(origin, "Origins are required.")); + return this; + } + + /** + * True if the method is allowed. + * + * @param method Method to test. + * @return True if the method is allowed. + */ + public boolean allowMethod(final String method) { + return this.requestMehods.test(method); + } + + /** + * @return List of allowed methods. + */ + public List allowedMethods() { + return requestMehods.values; + } + + /** + * Set one or more allowed methods. + * + * @param methods One or more method. + * @return This cors. + */ + public Cors withMethods(final String... methods) { + return withMethods(Arrays.asList(methods)); + } + + /** + * Set one or more allowed methods. + * + * @param methods One or more method. + * @return This cors. + */ + public Cors withMethods(final List methods) { + this.requestMehods = firstMatch(methods); + return this; + } + + /** + * @return True if any header is allowed: *. + */ + public boolean anyHeader() { + return requestHeaders.wild; + } + + /** + * @param header A header to test. + * @return True if a header is allowed. + */ + public boolean allowHeader(final String header) { + return allowHeaders(ImmutableList.of(header)); + } + + /** + * True if all the headers are allowed. + * + * @param headers Headers to test. + * @return True if all the headers are allowed. + */ + public boolean allowHeaders(final String... headers) { + return allowHeaders(Arrays.asList(headers)); + } + + /** + * True if all the headers are allowed. + * + * @param headers Headers to test. + * @return True if all the headers are allowed. + */ + public boolean allowHeaders(final List headers) { + return this.requestHeaders.test(headers); + } + + /** + * @return List of allowed headers. Default are: X-Requested-With, + * Content-Type, Accept and Origin. + */ + public List allowedHeaders() { + return requestHeaders.values; + } + + /** + * Set one or more allowed headers. Possible values are a header name or * if any + * header is allowed. + * + * @param headers Headers to set. + * @return This cors. + */ + public Cors withHeaders(final String... headers) { + return withHeaders(Arrays.asList(headers)); + } + + /** + * Set one or more allowed headers. Possible values are a header name or * if any + * header is allowed. + * + * @param headers Headers to set. + * @return This cors. + */ + public Cors withHeaders(final List headers) { + this.requestHeaders = allMatch(headers); + return this; + } + + /** + * @return List of exposed headers. + */ + public List exposedHeaders() { + return exposedHeaders; + } + + /** + * Set the list of exposed headers. + * + * @param exposedHeaders Headers to expose. + * @return This cors. + */ + public Cors withExposedHeaders(final String... exposedHeaders) { + return withExposedHeaders(Arrays.asList(exposedHeaders)); + } + + /** + * Set the list of exposed headers. + * + * @param exposedHeaders Headers to expose. + * @return This cors. + */ + public Cors withExposedHeaders(final List exposedHeaders) { + this.exposedHeaders = requireNonNull(exposedHeaders, "Exposed headers are required."); + return this; + } + + /** + * @return Preflight max age. How many seconds a client can cache a preflight request. + */ + public int maxAge() { + return maxAge; + } + + /** + * Set the preflight max age header. That's how many seconds a client can cache a preflight + * request. + * + * @param preflightMaxAge Number of seconds or -1 to turn this off. + * @return This cors. + */ + public Cors withMaxAge(final int preflightMaxAge) { + this.maxAge = preflightMaxAge; + return this; + } + + @SuppressWarnings({"unchecked", "rawtypes" }) + private List list(final Object value) { + return value instanceof List ? (List) value : ImmutableList.of(value.toString()); + } + + private static Matcher> allMatch(final List values) { + Predicate predicate = firstMatch(values); + Predicate> allmatch = it -> it.stream().allMatch(predicate); + return new Matcher>(values, allmatch); + } + + private static Matcher firstMatch(final List values) { + List patterns = values.stream() + .map(Cors::rewrite) + .collect(Collectors.toList()); + Predicate predicate = it -> patterns.stream() + .filter(pattern -> pattern.matcher(it).matches()) + .findFirst() + .isPresent(); + + return new Matcher(values, predicate); + } + + private static Pattern rewrite(final String origin) { + return Pattern.compile(origin.replace(".", "\\.").replace("*", ".*"), Pattern.CASE_INSENSITIVE); + } + +} diff --git a/jooby/src/main/java/org/jooby/handlers/CorsHandler.java b/jooby/src/main/java/org/jooby/handlers/CorsHandler.java new file mode 100644 index 00000000..d11cf840 --- /dev/null +++ b/jooby/src/main/java/org/jooby/handlers/CorsHandler.java @@ -0,0 +1,179 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.handlers; + +import static java.util.Objects.requireNonNull; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Route; +import org.jooby.Route.Chain; +import org.jooby.Status; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.base.Joiner; +import com.google.common.base.Splitter; + +/** + * Handle preflight and simple CORS requests. CORS options are set via: {@link Cors}. + * + * @author edgar + * @since 0.8.0 + * @see Cors + */ +public class CorsHandler implements Route.Filter { + + private static final String ORIGIN = "Origin"; + + private static final String ANY_ORIGIN = "*"; + + private static final String AC_REQUEST_METHOD = "Access-Control-Request-Method"; + + private static final String AC_REQUEST_HEADERS = "Access-Control-Request-Headers"; + + private static final String AC_MAX_AGE = "Access-Control-Max-Age"; + + private static final String AC_EXPOSE_HEADERS = "Access-Control-Expose-Headers"; + + private static final String AC_ALLOW_ORIGIN = "Access-Control-Allow-Origin"; + + private static final String AC_ALLOW_HEADERS = "Access-Control-Allow-Headers"; + + private static final String AC_ALLOW_CREDENTIALS = "Access-Control-Allow-Credentials"; + + private static final String AC_ALLOW_METHODS = "Access-Control-Allow-Methods"; + + /** The logging system. */ + private final Logger log = LoggerFactory.getLogger(Cors.class); + + private Optional cors = Optional.empty(); + + /** + * Creates a new {@link CorsHandler}. + * + * @param cors Cors options, or empty for using default options. + */ + public CorsHandler(final Cors cors) { + this.cors = Optional.of(requireNonNull(cors, "Cors is required.")); + } + + /** + * Creates a new {@link CorsHandler}. + */ + public CorsHandler() { + } + + @Override + public void handle(final Request req, final Response rsp, final Chain chain) throws Throwable { + Optional origin = req.header("Origin").toOptional(); + Cors cors = this.cors.orElseGet(() -> req.require(Cors.class)); + if (cors.enabled() && origin.isPresent()) { + cors(cors, req, rsp, origin.get()); + } + chain.next(req, rsp); + } + + private void cors(final Cors cors, final Request req, final Response rsp, + final String origin) throws Exception { + if (cors.allowOrigin(origin)) { + log.debug("allowed origin: {}", origin); + if (preflight(req)) { + log.debug("handling preflight for: {}", origin); + preflight(cors, req, rsp, origin); + } else { + log.debug("handling simple cors for: {}", origin); + if ("null".equals(origin)) { + rsp.header(AC_ALLOW_ORIGIN, ANY_ORIGIN); + } else { + rsp.header(AC_ALLOW_ORIGIN, origin); + if (!cors.anyOrigin()) { + rsp.header("Vary", ORIGIN); + } + if (cors.credentials()) { + rsp.header(AC_ALLOW_CREDENTIALS, true); + } + if (!cors.exposedHeaders().isEmpty()) { + rsp.header(AC_EXPOSE_HEADERS, join(cors.exposedHeaders())); + } + } + } + } + } + + private boolean preflight(final Request req) { + return req.method().equals("OPTIONS") && req.header(AC_REQUEST_METHOD).isSet(); + } + + private void preflight(final Cors cors, final Request req, final Response rsp, + final String origin) { + /** + * Allowed method + */ + boolean allowMethod = req.header(AC_REQUEST_METHOD).toOptional() + .map(cors::allowMethod) + .orElse(false); + if (!allowMethod) { + return; + } + + /** + * Allowed headers + */ + List headers = req.header(AC_REQUEST_HEADERS).toOptional().map(header -> + Splitter.on(',').trimResults().omitEmptyStrings().splitToList(header) + ).orElse(Collections.emptyList()); + if (!cors.allowHeaders(headers)) { + return; + } + + /** + * Allowed methods + */ + rsp.header(AC_ALLOW_METHODS, join(cors.allowedMethods())); + + List allowedHeaders = cors.anyHeader() ? headers : cors.allowedHeaders(); + rsp.header(AC_ALLOW_HEADERS, join(allowedHeaders)); + + /** + * Allow credentials + */ + if (cors.credentials()) { + rsp.header(AC_ALLOW_CREDENTIALS, true); + } + + if (cors.maxAge() > 0) { + rsp.header(AC_MAX_AGE, cors.maxAge()); + } + + rsp.header(AC_ALLOW_ORIGIN, origin); + + if (!cors.anyOrigin()) { + rsp.header("Vary", ORIGIN); + } + + rsp.status(Status.OK).end(); + } + + private String join(final List values) { + return Joiner.on(',').join(values); + } + +} diff --git a/jooby/src/main/java/org/jooby/handlers/CsrfHandler.java b/jooby/src/main/java/org/jooby/handlers/CsrfHandler.java new file mode 100644 index 00000000..3b8b23e7 --- /dev/null +++ b/jooby/src/main/java/org/jooby/handlers/CsrfHandler.java @@ -0,0 +1,162 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.handlers; + +import static java.util.Objects.requireNonNull; + +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.function.Predicate; + +import org.jooby.Err; +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Route; +import org.jooby.Session; +import org.jooby.Status; + +import com.google.common.collect.ImmutableSet; + +/** + *

Cross Site Request Forgery handler

+ * + *
+ * {
+ *   use("*", new CsrfHandler());
+ * }
+ * 
+ * + *

+ * This filter require a token on POST, PUT, PATCH and + * DELETE requests. A custom policy might be provided via: + * {@link #requireTokenOn(Predicate)}. + *

+ * + *

+ * Default token generator, use a {@link UUID#randomUUID()}. A custom token generator might be + * provided via: {@link #tokenGen(Function)}. + *

+ * + *

+ * Default token name is: csrf. If you want to use a different name, just pass the name + * to the {@link #CsrfHandler(String)} constructor. + *

+ * + *

Token verification

+ *

+ * The {@link CsrfHandler} handler will read an existing token from {@link Session} (or created a + * new one + * is necessary) and make available as a request local variable via: + * {@link Request#set(String, Object)}. + *

+ * + *

+ * If the incoming request require a token verification, it will extract the token from: + *

+ *
    + *
  1. HTTP header
  2. + *
  3. HTTP parameter
  4. + *
+ * + *

+ * If the extracted token doesn't match the existing token (from {@link Session}) a 403 + * will be thrown. + *

+ * + * @author edgar + * @since 0.8.1 + */ +public class CsrfHandler implements Route.Filter { + + private final Set REQUIRE_ON = ImmutableSet.of("POST", "PUT", "DELETE", "PATCH"); + + private String name; + + private Function generator; + + private Predicate requireToken; + + /** + * Creates a new {@link CsrfHandler} handler and use the given name to save the token in the + * {@link Session} and or extract the token from incoming requests. + * + * @param name Token's name. + */ + public CsrfHandler(final String name) { + this.name = requireNonNull(name, "Name is required."); + tokenGen(req -> UUID.randomUUID().toString()); + requireTokenOn(req -> REQUIRE_ON.contains(req.method())); + } + + /** + * Creates a new {@link CsrfHandler} and use csrf as token name. + */ + public CsrfHandler() { + this("csrf"); + } + + /** + * Set a custom token generator. Default generator use: {@link UUID#randomUUID()}. + * + * @param generator A custom token generator. + * @return This filter. + */ + public CsrfHandler tokenGen(final Function generator) { + this.generator = requireNonNull(generator, "Generator is required."); + return this; + } + + /** + * Decided whenever or not an incoming request require token verification. Default predicate + * requires verification on: POST, PUT, PATCH and + * DELETE requests. + * + * @param requireToken Predicate to use. + * @return This filter. + */ + public CsrfHandler requireTokenOn(final Predicate requireToken) { + this.requireToken = requireNonNull(requireToken, "RequireToken predicate is required."); + return this; + } + + @Override + public void handle(final Request req, final Response rsp, final Route.Chain chain) + throws Throwable { + + /** + * Get or generate a token + */ + Session session = req.session(); + String token = session.get(name).toOptional().orElseGet(() -> { + String newToken = generator.apply(req); + session.set(name, newToken); + return newToken; + }); + + req.set(name, token); + + if (requireToken.test(req)) { + String candidate = req.header(name).toOptional() + .orElseGet(() -> req.param(name).toOptional().orElse(null)); + if (!token.equals(candidate)) { + throw new Err(Status.FORBIDDEN, "Invalid Csrf token: " + candidate); + } + } + + chain.next(req, rsp); + } +} diff --git a/jooby/src/main/java/org/jooby/handlers/SSIHandler.java b/jooby/src/main/java/org/jooby/handlers/SSIHandler.java new file mode 100644 index 00000000..f4f6e60b --- /dev/null +++ b/jooby/src/main/java/org/jooby/handlers/SSIHandler.java @@ -0,0 +1,162 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.handlers; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.NoSuchElementException; + +import org.jooby.Asset; +import org.jooby.Env; +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Route; + +import com.google.common.io.CharStreams; + +/** + *

server side include

+ *

+ * Custom {@link AssetHandler} with server side include function. + *

+ * + *

usage

+ * + *
{@code
+ * {
+ *   get("/static/**", new SSIHandler());
+ * }
+ * }
+ * + *

+ * Request to /static/index.html: + *

+ * + *
+ * <html>
+ * <-- /static/chunk.html -->
+ * </html>
+ * 
+ * + *

+ * The {@link SSIHandler} will resolve and insert the content of /static/chunk.html. + *

+ * + *

delimiters

+ *

+ * Default delimiter are: <-- and -->. You can override this using + * {@link #delimiters(String, String)} function: + *

+ * + *
{@code
+ * {
+ *   get("/static/**", new SSIHandler().delimiters("{{", "}}");
+ * }
+ * }
+ * + * @author edgar + * @since 1.1.0 + */ +public class SSIHandler extends AssetHandler { + + private String startDelimiter = ""; + + /** + *

+ * Creates a new {@link SSIHandler}. The location pattern can be one of. + *

+ * + * Given / like in assets("/assets/**", "/") with: + * + *
+   *   GET /assets/js/index.js it translates the path to: /assets/js/index.js
+   * 
+ * + * Given /assets like in assets("/js/**", "/assets") with: + * + *
+   *   GET /js/index.js it translate the path to: /assets/js/index.js
+   * 
+ * + * Given /META-INF/resources/webjars/{0} like in + * assets("/webjars/**", "/META-INF/resources/webjars/{0}") with: + * + *
+   *   GET /webjars/jquery/2.1.3/jquery.js it translate the path to: /META-INF/resources/webjars/jquery/2.1.3/jquery.js
+   * 
+ * + * @param pattern Pattern to locate static resources. + */ + public SSIHandler(final String pattern) { + super(pattern); + } + + /** + *

+ * Creates a new {@link SSIHandler}. Location pattern is set to: /. + *

+ */ + public SSIHandler() { + this("/"); + } + + /** + * Set/override delimiters. + * + * @param start Start delimiter. + * @param end Stop/end delimiter. + * @return This handler. + */ + public SSIHandler delimiters(final String start, final String end) { + this.startDelimiter = start; + this.endDelimiter = end; + return this; + } + + @Override + protected void send(final Request req, final Response rsp, final Asset asset) throws Throwable { + Env env = req.require(Env.class); + CharSequence text = process(env, text(asset.stream())); + + rsp.type(asset.type()) + .send(text); + } + + private String process(final Env env, final String src) { + return env.resolver() + .delimiters(startDelimiter, endDelimiter) + .source(key -> process(env, file(key))) + .ignoreMissing() + .resolve(src); + } + + private String file(final String key) { + String file = Route.normalize(key.trim()); + return text(getClass().getResourceAsStream(file)); + } + + private String text(final InputStream stream) { + try (InputStream in = stream) { + return CharStreams.toString(new InputStreamReader(stream, StandardCharsets.UTF_8)); + } catch (IOException | NullPointerException x) { + throw new NoSuchElementException(); + } + } +} diff --git a/jooby/src/main/java/org/jooby/internal/AbstractRendererContext.java b/jooby/src/main/java/org/jooby/internal/AbstractRendererContext.java new file mode 100644 index 00000000..d6c233b6 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/AbstractRendererContext.java @@ -0,0 +1,206 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.io.Reader; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; + +import org.jooby.Err; +import org.jooby.MediaType; +import org.jooby.MediaType.Matcher; +import org.jooby.Renderer; +import org.jooby.Status; +import org.jooby.View; + +import com.google.common.base.Joiner; + +public abstract class AbstractRendererContext implements Renderer.Context { + + private Locale locale; + + private List renderers; + + private Matcher matcher; + + protected final Charset charset; + + private Map locals; + + private List produces; + + private boolean committed; + + private int rsize; + + public AbstractRendererContext(final List renderers, + final List produces, final Charset charset, final Locale locale, + final Map locals) { + this.renderers = renderers; + this.produces = produces; + this.charset = charset; + this.locale = locale; + this.locals = locals; + rsize = this.renderers.size(); + } + + public void render(final Object value) throws Exception { + int i = 0; + FileNotFoundException notFound = null; + while (!committed && i < rsize) { + Renderer next = renderers.get(i); + try { + next.render(value, this); + } catch (FileNotFoundException x) { + // view engine should recover from a template not found + if (next instanceof View.Engine) { + if (notFound == null) { + notFound = x; + } + } else { + throw x; + } + } + i += 1; + } + if (!committed) { + if (notFound != null) { + throw notFound; + } + throw new Err(Status.NOT_ACCEPTABLE, Joiner.on(", ").join(produces)); + } + } + + @Override + public Locale locale() { + return locale; + } + + @Override + public Map locals() { + return locals; + } + + @Override + public boolean accepts(final MediaType type) { + if (matcher == null) { + matcher = MediaType.matcher(produces); + } + return matcher.matches(type); + } + + @Override + public Renderer.Context type(final MediaType type) { + // NOOP + return this; + } + + @Override + public Renderer.Context length(final long length) { + // NOOP + return this; + } + + @Override + public Charset charset() { + return charset; + } + + @Override + public void send(final CharBuffer buffer) throws Exception { + type(MediaType.html); + send(charset.encode(buffer)); + } + + @Override + public void send(final Reader reader) throws Exception { + type(MediaType.html); + send(new ReaderInputStream(reader, charset)); + } + + @Override + public void send(final String text) throws Exception { + type(MediaType.html); + byte[] bytes = text.getBytes(charset); + length(bytes.length); + _send(bytes); + committed = true; + } + + @Override + public void send(final byte[] bytes) throws Exception { + type(MediaType.octetstream); + length(bytes.length); + _send(bytes); + committed = true; + } + + @Override + public void send(final ByteBuffer buffer) throws Exception { + type(MediaType.octetstream); + length(buffer.remaining()); + _send(buffer); + committed = true; + } + + @Override + public void send(final FileChannel file) throws Exception { + type(MediaType.octetstream); + length(file.size()); + _send(file); + committed = true; + } + + @Override + public void send(final InputStream stream) throws Exception { + type(MediaType.octetstream); + if (stream instanceof FileInputStream) { + send(((FileInputStream) stream).getChannel()); + } else { + _send(stream); + } + committed = true; + } + + protected void setCommitted() { + committed = true; + } + + @Override + public String toString() { + return renderers.stream().map(Renderer::name).collect(Collectors.joining(", ")); + } + + protected abstract void _send(final byte[] bytes) throws Exception; + + protected abstract void _send(final ByteBuffer buffer) throws Exception; + + protected abstract void _send(final FileChannel file) throws Exception; + + protected abstract void _send(final InputStream stream) throws Exception; + +} diff --git a/jooby/src/main/java/org/jooby/internal/AppPrinter.java b/jooby/src/main/java/org/jooby/internal/AppPrinter.java new file mode 100644 index 00000000..373a1b50 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/AppPrinter.java @@ -0,0 +1,153 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import com.google.common.base.Strings; +import com.typesafe.config.Config; +import org.jooby.Route; +import org.jooby.WebSocket; +import org.slf4j.Logger; + +import java.util.Set; +import java.util.function.Function; + +public class AppPrinter { + + private Set routes; + + private Set sockets; + + private String[] urls; + + private boolean http2; + + private boolean h2cleartext; + + public AppPrinter(final Set routes, + final Set sockets, + final Config conf) { + this.routes = routes; + this.sockets = sockets; + String host = conf.getString("application.host"); + String port = conf.getString("application.port"); + String path = conf.getString("application.path"); + this.urls = new String[2]; + this.urls[0] = "http://" + host + ":" + port + path; + if (conf.hasPath("application.securePort")) { + this.urls[1] = "https://" + host + ":" + conf.getString("application.securePort") + path; + } + http2 = conf.getBoolean("server.http2.enabled"); + h2cleartext = conf.getBoolean("server.http2.cleartext"); + } + + public void printConf(final Logger log, final Config conf) { + if (log.isDebugEnabled()) { + String desc = configTree(conf.origin().description()); + log.debug("config tree:\n{}", desc); + } + } + + private String configTree(final String description) { + return configTree(description.split(":\\s+\\d+,|,"), 0); + } + + private String configTree(final String[] sources, final int i) { + if (i < sources.length) { + return new StringBuilder() + .append(Strings.padStart("", i, ' ')) + .append("└── ") + .append(sources[i]) + .append("\n") + .append(configTree(sources, i + 1)) + .toString(); + } + return ""; + } + + @Override + public String toString() { + StringBuilder buffer = new StringBuilder(); + + routes(buffer); + String[] h2 = {h2(" ", http2 && h2cleartext), h2("", http2)}; + buffer.append("\nlistening on:"); + for (int i = 0; i < urls.length; i++) { + if (urls[i] != null) { + buffer.append("\n ").append(urls[i]).append(h2[i]); + } + } + return buffer.toString(); + } + + private String h2(final String prefix, final boolean h2) { + return h2 ? prefix + " +h2" : ""; + } + + private void routes(final StringBuilder buffer) { + Function p = route -> { + Route.Filter filter = route.filter(); + if (filter instanceof Route.Before) { + return "{before}" + route.pattern(); + } else if (filter instanceof Route.After) { + return "{after}" + route.pattern(); + } else if (filter instanceof Route.Complete) { + return "{complete}" + route.pattern(); + } + return route.pattern(); + }; + + int verbMax = 0, routeMax = 0, consumesMax = 0, producesMax = 0; + for (Route.Definition route : routes) { + verbMax = Math.max(verbMax, route.method().length()); + + routeMax = Math.max(routeMax, p.apply(route).length()); + + consumesMax = Math.max(consumesMax, route.consumes().toString().length()); + + producesMax = Math.max(producesMax, route.produces().toString().length()); + } + + String format = " %-" + verbMax + "s %-" + routeMax + "s %" + consumesMax + + "s %" + producesMax + "s (%s)\n"; + + for (Route.Definition route : routes) { + buffer.append( + String.format(format, route.method(), p.apply(route), route.consumes(), + route.produces(), route.name())); + } + + sockets(buffer, Math.max(verbMax, "WS".length()), routeMax, consumesMax, producesMax); + } + + private void sockets(final StringBuilder buffer, final int verbMax, int routeMax, + int consumesMax, int producesMax) { + for (WebSocket.Definition socket : sockets) { + routeMax = Math.max(routeMax, socket.pattern().length()); + + consumesMax = Math.max(consumesMax, socket.consumes().toString().length() + 2); + + producesMax = Math.max(producesMax, socket.produces().toString().length() + 2); + } + + String format = " %-" + verbMax + "s %-" + routeMax + "s %" + consumesMax + "s %" + + producesMax + "s\n"; + + for (WebSocket.Definition socket : sockets) { + buffer.append(String.format(format, "WS", socket.pattern(), + "[" + socket.consumes() + "]", "[" + socket.produces() + "]")); + } + } +} diff --git a/jooby/src/main/java/org/jooby/internal/AssetSource.java b/jooby/src/main/java/org/jooby/internal/AssetSource.java new file mode 100644 index 00000000..8e19d866 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/AssetSource.java @@ -0,0 +1,59 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import com.google.common.base.Strings; + +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; + +public interface AssetSource { + URL getResource(String name); + + static AssetSource fromClassPath(ClassLoader loader, String source) { + if (Strings.isNullOrEmpty(source) || "/".equals(source.trim())) { + throw new IllegalArgumentException( + "For security reasons root classpath access is not allowed: " + source); + } + return path -> { + URL resource = loader.getResource(path); + if (resource == null) { + return null; + } + String realPath = resource.getPath(); + if (realPath.startsWith(source)) { + return resource; + } + return null; + }; + } + + static AssetSource fromFileSystem(Path basedir) { + return name -> { + Path path = basedir.resolve(name).normalize(); + if (Files.exists(path) && path.startsWith(basedir)) { + try { + return path.toUri().toURL(); + } catch (MalformedURLException x) { + // shh + } + } + return null; + }; + } +} diff --git a/jooby/src/main/java/org/jooby/internal/BodyReferenceImpl.java b/jooby/src/main/java/org/jooby/internal/BodyReferenceImpl.java new file mode 100644 index 00000000..992c30fd --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/BodyReferenceImpl.java @@ -0,0 +1,100 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.Charset; +import java.nio.file.Files; + +import org.jooby.Parser; + +import com.google.common.io.ByteStreams; +import org.jooby.funzy.Try; + +public class BodyReferenceImpl implements Parser.BodyReference { + + private Charset charset; + + private long length; + + private File file; + + private byte[] bytes; + + public BodyReferenceImpl(final long length, final Charset charset, final File file, + final InputStream in, final long bufferSize) throws IOException { + this.length = length; + this.charset = charset; + if (length < bufferSize) { + bytes = toByteArray(in); + } else { + this.file = copy(file, in); + } + } + + @Override + public long length() { + return length; + } + + @Override + public byte[] bytes() throws IOException { + if (bytes == null) { + return Files.readAllBytes(file.toPath()); + } else { + return bytes; + } + } + + @Override + public String text() throws IOException { + return new String(bytes(), charset); + } + + @Override + public void writeTo(final OutputStream output) throws IOException { + if (bytes == null) { + Files.copy(file.toPath(), output); + } else { + output.write(bytes); + } + + } + + private static byte[] toByteArray(final InputStream in) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + copy(in, out); + return out.toByteArray(); + } + + private static File copy(final File file, final InputStream in) throws IOException { + file.getParentFile().mkdirs(); + copy(in, new FileOutputStream(file)); + return file; + } + + private static void copy(final InputStream in, final OutputStream out) { + Try.of(in, out) + .run(ByteStreams::copy) + .throwException(); + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/BuiltinParser.java b/jooby/src/main/java/org/jooby/internal/BuiltinParser.java new file mode 100644 index 00000000..7ebc11f6 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/BuiltinParser.java @@ -0,0 +1,211 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import java.lang.reflect.ParameterizedType; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.SortedSet; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.jooby.Parser; + +import com.google.common.collect.ImmutableCollection; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ImmutableSortedSet; +import com.google.inject.TypeLiteral; + +@SuppressWarnings({"unchecked", "rawtypes" }) +public enum BuiltinParser implements Parser { + + Basic { + private final Map, Function> parsers = ImmutableMap + ., Function> builder() + .put(BigDecimal.class, NOT_EMPTY.andThen(BigDecimal::new)) + .put(BigInteger.class, NOT_EMPTY.andThen(BigInteger::new)) + .put(Byte.class, NOT_EMPTY.andThen(Byte::valueOf)) + .put(byte.class, NOT_EMPTY.andThen(Byte::valueOf)) + .put(Double.class, NOT_EMPTY.andThen(Double::valueOf)) + .put(double.class, NOT_EMPTY.andThen(Double::valueOf)) + .put(Float.class, NOT_EMPTY.andThen(Float::valueOf)) + .put(float.class, NOT_EMPTY.andThen(Float::valueOf)) + .put(Integer.class, NOT_EMPTY.andThen(Integer::valueOf)) + .put(int.class, NOT_EMPTY.andThen(Integer::valueOf)) + .put(Long.class, NOT_EMPTY.andThen(this::toLong)) + .put(long.class, NOT_EMPTY.andThen(this::toLong)) + .put(Short.class, NOT_EMPTY.andThen(Short::valueOf)) + .put(short.class, NOT_EMPTY.andThen(Short::valueOf)) + .put(Boolean.class, NOT_EMPTY.andThen(this::toBoolean)) + .put(boolean.class, NOT_EMPTY.andThen(this::toBoolean)) + .put(Character.class, NOT_EMPTY.andThen(this::toCharacter)) + .put(char.class, NOT_EMPTY.andThen(this::toCharacter)) + .put(String.class, this::toString) + .build(); + + @Override + public Object parse(final TypeLiteral type, final Parser.Context ctx) throws Throwable { + Function parser = parsers.get(type.getRawType()); + if (parser != null) { + return ctx + .param(values -> parser.apply(values.get(0))).body(body -> parser.apply(body.text())); + } + return ctx.next(); + } + + private String toString(final String value) { + return value; + } + + private char toCharacter(final String value) { + return value.charAt(0); + } + + private Boolean toBoolean(final String value) { + if ("true".equals(value)) { + return Boolean.TRUE; + } else if ("false".equals(value)) { + return Boolean.FALSE; + } + throw new IllegalArgumentException("Not a boolean: " + value); + } + + private Long toLong(final String value) { + try { + return Long.valueOf(value); + } catch (NumberFormatException ex) { + // long as date, like If-Modified-Since + try { + LocalDateTime date = LocalDateTime.parse(value, Headers.fmt); + Instant instant = date.toInstant(ZoneOffset.UTC); + return instant.toEpochMilli(); + } catch (DateTimeParseException ignored) { + throw ex; + } + } + + } + }, + + Collection { + private final Map, Supplier>> parsers = ImmutableMap., Supplier>> builder() + .put(List.class, ImmutableList.Builder::new) + .put(Set.class, ImmutableSet.Builder::new) + .put(SortedSet.class, ImmutableSortedSet::naturalOrder) + .build(); + + private boolean matches(final TypeLiteral toType) { + return parsers.containsKey(toType.getRawType()) + && toType.getType() instanceof ParameterizedType; + } + + @Override + public Object parse(final TypeLiteral type, final Parser.Context ctx) throws Throwable { + if (matches(type)) { + return ctx.param(values -> { + ImmutableCollection.Builder builder = parsers.get(type.getRawType()).get(); + TypeLiteral paramType = TypeLiteral.get(((ParameterizedType) type.getType()) + .getActualTypeArguments()[0]); + for (Object value : values) { + builder.add(ctx.next(paramType, value)); + } + return builder.build(); + }); + } else { + return ctx.next(); + } + } + }, + + Optional { + private boolean matches(final TypeLiteral toType) { + return Optional.class == toType.getRawType() && toType.getType() instanceof ParameterizedType; + } + + @Override + public Object parse(final TypeLiteral type, final Parser.Context ctx) + throws Throwable { + if (matches(type)) { + TypeLiteral paramType = TypeLiteral.get(((ParameterizedType) type.getType()) + .getActualTypeArguments()[0]); + return ctx + .param(values -> { + if (values.size() == 0) { + return java.util.Optional.empty(); + } + return java.util.Optional.of(ctx.next(paramType)); + }).body(body -> { + if (body.length() == 0) { + return java.util.Optional.empty(); + } + return java.util.Optional.of(ctx.next(paramType)); + }); + } else { + return ctx.next(); + } + } + }, + + Enum { + @Override + public Object parse(final TypeLiteral type, final Parser.Context ctx) + throws Throwable { + Class rawType = type.getRawType(); + if (Enum.class.isAssignableFrom(rawType)) { + return ctx + .param(values -> toEnum(rawType, values.get(0))) + .body(body -> toEnum(rawType, body.text())); + } else { + return ctx.next(); + } + } + + Object toEnum(final Class type, final String value) { + Set set = EnumSet.allOf(type); + return set.stream() + .filter(e -> e.name().equalsIgnoreCase(value)) + .findFirst() + .orElseGet(() -> java.lang.Enum.valueOf(type, value)); + } + }, + + Bytes { + @Override + public Object parse(final TypeLiteral type, final Parser.Context ctx) throws Throwable { + if (type.getRawType() == byte[].class) { + return ctx.body(body -> body.bytes()); + } + return ctx.next(); + } + + @Override + public String toString() { + return "byte[]"; + } + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/BuiltinRenderer.java b/jooby/src/main/java/org/jooby/internal/BuiltinRenderer.java new file mode 100644 index 00000000..d5d1937b --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/BuiltinRenderer.java @@ -0,0 +1,129 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.io.Reader; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.channels.FileChannel; + +import org.jooby.Asset; +import org.jooby.MediaType; +import org.jooby.Renderer; +import org.jooby.View; + +public enum BuiltinRenderer implements Renderer { + + asset { + @Override + public void render(final Object value, final Context ctx) throws Exception { + if (value instanceof Asset) { + Asset resource = ((Asset) value); + ctx.type(resource.type()) + .length(resource.length()) + .send(resource.stream()); + } + } + }, + + stream { + @Override + public void render(final Object object, final Renderer.Context ctx) throws Exception { + if (object instanceof InputStream) { + InputStream in = (InputStream) object; + ctx.type(MediaType.octetstream) + .send(in); + } + } + }, + + reader { + @Override + public void render(final Object object, final Renderer.Context ctx) throws Exception { + if (object instanceof Reader) { + ctx.type(MediaType.html) + .send((Reader) object); + } + } + }, + + bytes { + @Override + public void render(final Object object, final Renderer.Context ctx) throws Exception { + if (object instanceof byte[]) { + ctx.type(MediaType.octetstream) + .send((byte[]) object); + } + } + }, + + byteBuffer { + @Override + public void render(final Object object, final Renderer.Context ctx) throws Exception { + if (object instanceof ByteBuffer) { + ByteBuffer buffer = (ByteBuffer) object; + ctx.type(MediaType.octetstream) + .send(buffer); + } + } + }, + + file { + @Override + public void render(final Object object, final Renderer.Context ctx) throws Exception { + if (object instanceof File) { + File file = (java.io.File) object; + ctx.type(MediaType.byFile(file).orElse(MediaType.octetstream)); + ctx.send(new FileInputStream(file)); + } + } + }, + + charBuffer { + @Override + public void render(final Object object, final Renderer.Context ctx) throws Exception { + if (object instanceof CharBuffer) { + CharBuffer buffer = (CharBuffer) object; + ctx.type(MediaType.html) + .send(buffer); + } + } + }, + + fileChannel { + @Override + public void render(final Object object, final Renderer.Context ctx) throws Exception { + if (object instanceof FileChannel) { + ctx.type(MediaType.octetstream); + ctx.send((FileChannel) object); + } + } + }, + + text { + @Override + public void render(final Object object, final Renderer.Context ctx) throws Exception { + if (!(object instanceof View)) { + ctx.type(MediaType.html); + ctx.send(object.toString()); + } + } + }; + +} diff --git a/jooby/src/main/java/org/jooby/internal/ByteRange.java b/jooby/src/main/java/org/jooby/internal/ByteRange.java new file mode 100644 index 00000000..335b0383 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/ByteRange.java @@ -0,0 +1,60 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import com.google.common.base.Splitter; +import org.jooby.Err; +import org.jooby.Status; + +import java.util.Iterator; +import java.util.function.BiFunction; + +public class ByteRange { + + private static final String BYTES_EQ = "bytes="; + + public static long[] parse(final String value) { + if (!value.startsWith(BYTES_EQ)) { + throw new Err(Status.REQUESTED_RANGE_NOT_SATISFIABLE, value); + } + BiFunction number = (it, offset) -> { + try { + return Long.parseLong(it.substring(offset)); + } catch (NumberFormatException | IndexOutOfBoundsException x) { + throw new Err(Status.REQUESTED_RANGE_NOT_SATISFIABLE, value); + } + }; + + Iterator ranges = Splitter.on(',') + .trimResults() + .omitEmptyStrings() + .split(value.substring(BYTES_EQ.length())) + .iterator(); + if (ranges.hasNext()) { + String range = ranges.next(); + int dash = range.indexOf('-'); + if (dash == 0) { + return new long[]{-1L, number.apply(range, 1)}; + } else if (dash > 0) { + Long start = number.apply(range.substring(0, dash), 0); + int endidx = dash + 1; + Long end = endidx < range.length() ? number.apply(range, endidx) : -1L; + return new long[]{start, end}; + } + } + throw new Err(Status.REQUESTED_RANGE_NOT_SATISFIABLE, value); + } +} diff --git a/jooby/src/main/java/org/jooby/internal/ConnectionResetByPeer.java b/jooby/src/main/java/org/jooby/internal/ConnectionResetByPeer.java new file mode 100644 index 00000000..cdedc1f7 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/ConnectionResetByPeer.java @@ -0,0 +1,32 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import java.io.IOException; +import java.util.Objects; +import java.util.Optional; + +public class ConnectionResetByPeer { + + public static boolean test(final Throwable cause) { + return Optional.ofNullable(cause) + .filter(IOException.class::isInstance) + .map(x -> x.getMessage()) + .filter(Objects::nonNull) + .map(message -> message.toLowerCase().contains("connection reset by peer")) + .orElse(false); + } +} diff --git a/jooby/src/main/java/org/jooby/internal/CookieImpl.java b/jooby/src/main/java/org/jooby/internal/CookieImpl.java new file mode 100644 index 00000000..1781f2ff --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/CookieImpl.java @@ -0,0 +1,193 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.Optional; +import java.util.function.Consumer; + +import org.jooby.Cookie; + +public class CookieImpl implements Cookie { + + static final DateTimeFormatter fmt = DateTimeFormatter + .ofPattern("EEE, dd-MMM-yyyy HH:mm:ss z", Locale.ENGLISH) + .withZone(ZoneId.of("GMT")); + + private static final String __COOKIE_DELIM = "\",;\\ \t"; + + private String name; + + private Optional value; + + private Optional comment; + + private Optional domain; + + private int maxAge; + + private Optional path; + + private boolean secure; + + private boolean httpOnly; + + public CookieImpl(final Cookie.Definition cookie) { + this.name = cookie.name().orElseThrow(() -> new IllegalArgumentException("Cookie name missing")); + this.value = cookie.value(); + this.comment = cookie.comment(); + this.domain = cookie.domain(); + this.maxAge = cookie.maxAge().orElse(-1); + this.path = cookie.path(); + this.secure = cookie.secure().orElse(Boolean.FALSE); + this.httpOnly = cookie.httpOnly().orElse(Boolean.FALSE); + } + + @Override + public String name() { + return name; + } + + @Override + public Optional value() { + return value; + } + + @Override + public Optional comment() { + return comment; + } + + @Override + public Optional domain() { + return domain; + } + + @Override + public int maxAge() { + return maxAge; + } + + @Override + public Optional path() { + return path; + } + + @Override + public boolean secure() { + return secure; + } + + @Override + public boolean httpOnly() { + return httpOnly; + } + + @Override + public String encode() { + StringBuilder sb = new StringBuilder(); + + Consumer appender = (str) -> { + if (needQuote(str)) { + sb.append('"'); + for (int i = 0; i < str.length(); ++i) { + char c = str.charAt(i); + if (c == '"' || c == '\\') { + sb.append('\\'); + } + sb.append(c); + } + sb.append('"'); + } else { + sb.append(str); + } + }; + + // name = value + appender.accept(name()); + sb.append("="); + value().ifPresent(appender); + + sb.append(";Version=1"); + + // Path + path().ifPresent(path -> { + sb.append(";Path="); + appender.accept(path); + }); + + // Domain + domain().ifPresent(domain -> { + sb.append(";Domain="); + appender.accept(domain); + }); + + // Secure + if (secure()) { + sb.append(";Secure"); + } + + // HttpOnly + if (httpOnly()) { + sb.append(";HttpOnly"); + } + + // Max-Age + int maxAge = maxAge(); + if (maxAge >= 0) { + sb.append(";Max-Age=").append(maxAge); + + Instant instant = Instant + .ofEpochMilli(maxAge > 0 ? System.currentTimeMillis() + maxAge * 1000L : 0); + sb.append(";Expires=").append(fmt.format(instant)); + } + + // Comment + comment().ifPresent(comment -> { + sb.append(";Comment="); + appender.accept(comment); + }); + + return sb.toString(); + } + + @Override + public String toString() { + return encode(); + } + + private static boolean needQuote(final String s) { + if (s.length() > 1 && s.charAt(0) == '"' && s.charAt(s.length() - 1) == '"') { + return false; + } + + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (__COOKIE_DELIM.indexOf(c) >= 0) { + return true; + } + + if (c < 0x20 || c >= 0x7f) { + throw new IllegalArgumentException("Illegal character fount at: [" + i + "]"); + } + } + + return false; + } +} diff --git a/jooby/src/main/java/org/jooby/internal/CookieSessionManager.java b/jooby/src/main/java/org/jooby/internal/CookieSessionManager.java new file mode 100644 index 00000000..97d9fade --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/CookieSessionManager.java @@ -0,0 +1,131 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import org.jooby.Cookie; +import org.jooby.Cookie.Definition; +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Route; +import org.jooby.Session; +import org.jooby.internal.parser.ParserExecutor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.inject.Inject; +import javax.inject.Named; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +/** + * Save session data in a cookie. + * + * @author edgar + */ +public class CookieSessionManager implements SessionManager { + + /** The logging system. */ + private final Logger log = LoggerFactory.getLogger(SessionManager.class); + + private final ParserExecutor resolver; + + private Definition cookie; + + private long timeout; + + private String secret; + + @Inject + public CookieSessionManager(final ParserExecutor resolver, final Session.Definition cookie, + @Named("application.secret") final String secret) { + this.resolver = resolver; + this.cookie = cookie.cookie(); + this.timeout = TimeUnit.SECONDS.toMillis(this.cookie.maxAge().get()); + this.secret = secret; + } + + @Override + public Session create(final Request req, final Response rsp) { + Session session = new SessionImpl.Builder(resolver, true, Session.COOKIE_SESSION, -1).build(); + log.debug("session created: {}", session); + rsp.after(saveCookie()); + return session; + } + + @Override + public Session get(final Request req, final Response rsp) { + return req.cookie(cookie.name().get()).toOptional().map(raw -> { + SessionImpl.Builder session = new SessionImpl.Builder(resolver, false, Session.COOKIE_SESSION, + -1); + Map attributes = attributes(raw); + session.set(attributes); + rsp.after(saveCookie()); + return session.build(); + }).orElse(null); + } + + @Override + public void destroy(final Session session) { + // NOOP + } + + @Override + public void requestDone(final Session session) { + // NOOP + } + + @Override + public Definition cookie() { + return new Definition(cookie); + } + + @Override public void renewId(Session session, Response rsp) { + // NOOP + } + + private Map attributes(final String raw) { + String unsigned = Cookie.Signature.unsign(raw, secret); + return Cookie.URL_DECODER.apply(unsigned); + } + + private Route.After saveCookie() { + return (req, rsp, result) -> { + req.ifSession().ifPresent(session -> { + Optional value = req.cookie(cookie.name().get()).toOptional(); + Map initial = value + .map(this::attributes) + .orElse(Collections.emptyMap()); + Map attributes = session.attributes(); + // is dirty? + boolean dirty = !initial.equals(attributes); + log.debug("session dirty: {}", dirty); + if (dirty) { + log.debug("saving session cookie"); + String encoded = Cookie.URL_ENCODER.apply(attributes); + String signed = Cookie.Signature.sign(encoded, secret); + rsp.cookie(new Cookie.Definition(cookie).value(signed)); + } else if (timeout > 0) { + // touch session + value.ifPresent(raw -> rsp.cookie(new Cookie.Definition(cookie).value(raw))); + } + }); + return result; + }; + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/DefaulErrRenderer.java b/jooby/src/main/java/org/jooby/internal/DefaulErrRenderer.java new file mode 100644 index 00000000..f09eaa41 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/DefaulErrRenderer.java @@ -0,0 +1,98 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import java.util.Arrays; +import java.util.Map; + +import org.jooby.Err; +import org.jooby.MediaType; +import org.jooby.Renderer; +import org.jooby.View; + +public class DefaulErrRenderer implements Renderer { + + @SuppressWarnings("unchecked") + @Override + public void render(final Object object, final Context ctx) throws Exception { + if (object instanceof View) { + View view = (View) object; + // assume it is the default error handler + if (Err.DefHandler.VIEW.equals(view.name())) { + Map model = (Map) view.model().get("err"); + Object status = model.get("status"); + Object reason = model.get("reason"); + Object message = model.get("message"); + String[] stacktrace = (String[]) model.get("stacktrace"); + + StringBuilder html = new StringBuilder("\n") + .append("\n") + .append("\n") + .append("\n") + .append("\n") + .append("\n") + .append(status).append(" ").append(reason) + .append("\n\n") + .append("\n") + .append("

").append(reason).append("

\n") + .append("
"); + + html.append("

message: ").append(message).append("

\n"); + html.append("

status: ").append(status).append("

\n"); + + if (stacktrace != null) { + html.append("

stack:

\n") + .append("
\n"); + + Arrays.stream(stacktrace).forEach(line -> { + html.append("

") + .append("") + .append(line.replace("\t", " ")) + .append("") + .append("

\n"); + }); + html.append("
\n"); + } + + html.append("\n") + .append("\n"); + + ctx.type(MediaType.html) + .send(html.toString()); + } + } + + } + + @Override + public String name() { + return "defaultErr"; + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/DeferredExecution.java b/jooby/src/main/java/org/jooby/internal/DeferredExecution.java new file mode 100644 index 00000000..cdf964f5 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/DeferredExecution.java @@ -0,0 +1,35 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import org.jooby.Deferred; + +/** + * Use to detach the request from current thread (async mode). Internal use only. + * + * @author edgar + * @since 0.10.0 + */ +@SuppressWarnings("serial") +public class DeferredExecution extends RuntimeException { + + public final Deferred deferred; + + public DeferredExecution(final Deferred deferred) { + this.deferred = deferred; + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/EmptyBodyReference.java b/jooby/src/main/java/org/jooby/internal/EmptyBodyReference.java new file mode 100644 index 00000000..d74f5fcb --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/EmptyBodyReference.java @@ -0,0 +1,47 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import java.io.IOException; +import java.io.OutputStream; + +import org.jooby.Err; +import org.jooby.Parser; +import org.jooby.Status; + +public class EmptyBodyReference implements Parser.BodyReference { + + @Override + public byte[] bytes() throws IOException { + throw new Err(Status.BAD_REQUEST); + } + + @Override + public String text() throws IOException { + throw new Err(Status.BAD_REQUEST); + } + + @Override + public long length() { + return 0; + } + + @Override + public void writeTo(final OutputStream output) throws Exception { + throw new Err(Status.BAD_REQUEST); + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/FallbackRoute.java b/jooby/src/main/java/org/jooby/internal/FallbackRoute.java new file mode 100644 index 00000000..a5acddf1 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/FallbackRoute.java @@ -0,0 +1,123 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.jooby.MediaType; +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Route; + +public class FallbackRoute implements RouteWithFilter { + + private Route.Filter filter; + + private String path; + + private String method; + + private String name; + + private List produces; + + public FallbackRoute(final String name, final String method, final String path, + final List produces, final Route.Filter filter) { + this.name = name; + this.path = path; + this.method = method; + this.filter = filter; + this.produces = produces; + } + + @Override + public String renderer() { + return null; + } + + @Override + public String path() { + return Route.unerrpath(path); + } + + @Override + public String method() { + return method; + } + + @Override + public String pattern() { + return Route.unerrpath(path); + } + + @Override + public String name() { + return name; + } + + @Override + public Map vars() { + return Collections.emptyMap(); + } + + @Override + public List consumes() { + return MediaType.ALL; + } + + @Override + public List produces() { + return produces; + } + + @Override + public Map attributes() { + return Collections.emptyMap(); + } + + @Override + public boolean glob() { + return false; + } + + @Override + public String reverse(final Map vars) { + return Route.unerrpath(path); + } + + @Override + public String reverse(final Object... values) { + return Route.unerrpath(path); + } + + @Override + public Source source() { + return Source.BUILTIN; + } + + @Override + public void handle(final Request req, final Response rsp, final Chain chain) throws Throwable { + filter.handle(req, rsp, chain); + } + + @Override + public boolean apply(final String prefix) { + return true; + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/Headers.java b/jooby/src/main/java/org/jooby/internal/Headers.java new file mode 100644 index 00000000..2c050f13 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/Headers.java @@ -0,0 +1,42 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Calendar; +import java.util.Date; +import java.util.Locale; + +public class Headers { + + public static final DateTimeFormatter fmt = DateTimeFormatter + .ofPattern("EEE, dd MMM yyyy HH:mm:ss z", Locale.ENGLISH) + .withZone(ZoneId.of("GMT")); + + public static String encode(final Object value) { + if (value instanceof String) { + return (String) value; + } else if (value instanceof Date) { + return fmt.format(Instant.ofEpochMilli(((Date) value).getTime())); + } else if (value instanceof Calendar) { + return fmt.format(Instant.ofEpochMilli(((Calendar) value).getTimeInMillis())); + } + return value.toString(); + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/HttpHandlerImpl.java b/jooby/src/main/java/org/jooby/internal/HttpHandlerImpl.java new file mode 100644 index 00000000..fef1c095 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/HttpHandlerImpl.java @@ -0,0 +1,552 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import com.google.common.base.Strings; +import com.google.common.base.Throwables; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import com.google.common.collect.Sets; +import com.google.inject.Injector; +import com.google.inject.Key; +import com.google.inject.name.Names; +import com.typesafe.config.Config; +import static java.util.Objects.requireNonNull; +import org.jooby.Deferred; +import org.jooby.Err; +import org.jooby.Err.Handler; +import org.jooby.MediaType; +import org.jooby.Renderer; +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Route; +import org.jooby.Session; +import org.jooby.Sse; +import org.jooby.Status; +import org.jooby.WebSocket; +import org.jooby.WebSocket.Definition; +import org.jooby.internal.parser.ParserExecutor; +import org.jooby.spi.HttpHandler; +import org.jooby.spi.NativeRequest; +import org.jooby.spi.NativeResponse; +import org.jooby.spi.NativeWebSocket; +import org.jooby.funzy.Try; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Provider; +import javax.inject.Singleton; +import java.nio.charset.Charset; +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedList; +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.function.Function; +import java.util.stream.Collectors; + +@Singleton +public class HttpHandlerImpl implements HttpHandler { + + private static class RouteKey { + + private final String method; + + private final String path; + + private final MediaType consumes; + + private final List produces; + + private final String key; + + public RouteKey(final String method, final String path, final MediaType consumes, + final List produces) { + String c = consumes.name(); + String p = produces.toString(); + key = new StringBuilder(method.length() + path.length() + c.length() + p.length()) + .append(method) + .append(path) + .append(c) + .append(p) + .toString(); + this.method = method; + this.path = path; + this.consumes = consumes; + this.produces = produces; + } + + @Override + public int hashCode() { + return key.hashCode(); + } + + @Override + public boolean equals(final Object obj) { + RouteKey that = (RouteKey) obj; + return key.equals(that.key); + } + } + + private static final String NO_CACHE = "must-revalidate,no-cache,no-store"; + + private static final String WEB_SOCKET = "WebSocket"; + + private static final String UPGRADE = "Upgrade"; + + private static final String REFERER = "Referer"; + + private static final String PATH = "path"; + + private static final String CONTEXT_PATH = "contextPath"; + + private static final Key REQ = Key.get(Request.class); + + private static final Key CHAIN = Key.get(Route.Chain.class); + + private static final Key RSP = Key.get(Response.class); + + private static final Key SSE = Key.get(Sse.class); + + private static final Key SESS = Key.get(Session.class); + + private static final Key DEF_EXEC = Key.get(String.class, Names.named("deferred")); + + private static final String BYTE_RANGE = "Range"; + + /** + * The logging system. + */ + private Injector injector; + + private Set err; + + private String applicationPath; + + private RequestScope requestScope; + + private Set socketDefs; + + private Config config; + + private int port; + + private String _method; + + private Charset charset; + + private List renderers; + + private ParserExecutor parserExecutor; + + private List locales; + + private final LoadingCache routeCache; + + private final String redirectHttps; + + private Function rpath = null; + + private String contextPath; + + private boolean hasSockets; + + private final Map rendererMap; + + private StatusCodeProvider sc; + + /** + * Global deferred executor. + */ + private Key gexec; + + @Inject + public HttpHandlerImpl(final Injector injector, + final RequestScope requestScope, + final Set routes, + final Set sockets, + final @Named("application.path") String path, + final ParserExecutor parserExecutor, + final Set renderers, + final Set err, + final StatusCodeProvider sc, + final Charset charset, + final List locale) { + this.injector = requireNonNull(injector, "An injector is required."); + this.requestScope = requireNonNull(requestScope, "A request scope is required."); + this.socketDefs = requireNonNull(sockets, "Sockets are required."); + this.hasSockets = socketDefs.size() > 0; + this.applicationPath = normalizeURI(requireNonNull(path, "An application.path is required.")); + this.err = requireNonNull(err, "An err handler is required."); + this.sc = sc; + this.config = injector.getInstance(Config.class); + _method = Strings.emptyToNull(this.config.getString("server.http.Method").trim()); + this.port = config.getInt("application.port"); + this.charset = charset; + this.locales = locale; + this.parserExecutor = parserExecutor; + this.renderers = new ArrayList<>(renderers); + rendererMap = new HashMap<>(); + this.renderers.forEach(r -> rendererMap.put(r.name(), r)); + + // route cache + routeCache = routeCache(routes, config); + // force https + String redirectHttps = config.getString("application.redirect_https").trim(); + this.redirectHttps = redirectHttps.length() > 0 ? redirectHttps : null; + + // custom path? + if (applicationPath.equals("/")) { + this.contextPath = ""; + } else { + this.contextPath = applicationPath; + this.rpath = rootpath(applicationPath); + } + // global deferred executor + this.gexec = Key.get(Executor.class, Names.named(injector.getInstance(DEF_EXEC))); + } + + @Override + public void handle(final NativeRequest request, final NativeResponse response) throws Exception { + long start = System.currentTimeMillis(); + + Map locals = new HashMap<>(16); + + Map scope = new HashMap<>(16); + + String method = _method == null ? request.method() : method(_method, request); + String path = normalizeURI(request.path()); + if (rpath != null) { + path = rpath.apply(path); + } + + // put request attributes first to make sure we don't override defaults + Map nativeAttrs = request.attributes(); + if (nativeAttrs.size() > 0) { + locals.putAll(nativeAttrs); + } + // default locals + locals.put(CONTEXT_PATH, contextPath); + locals.put(PATH, path); + + Route notFound = RouteImpl.notFound(method, path); + + RequestImpl req = new RequestImpl(injector, request, contextPath, port, notFound, charset, + locales, scope, locals, start); + + ResponseImpl rsp = new ResponseImpl(req, parserExecutor, response, notFound, renderers, + rendererMap, locals, req.charset(), request.header(REFERER), request.header(BYTE_RANGE)); + + MediaType type = req.type(); + + // seed req & rsp + scope.put(REQ, req); + scope.put(RSP, rsp); + + // seed sse + Provider sse = () -> Try.apply(() -> request.upgrade(Sse.class)).get(); + scope.put(SSE, sse); + + // seed session + Provider session = () -> req.session(); + scope.put(SESS, session); + + boolean deferred = false; + Throwable x = null; + try { + + requestScope.enter(scope); + + // force https? + if (redirectHttps != null) { + if (!req.secure()) { + rsp.redirect(MessageFormat.format(redirectHttps, path.substring(1))); + return; + } + } + + // websocket? + if (hasSockets) { + if (upgrade(request)) { + Optional sockets = findSockets(socketDefs, path); + if (sockets.isPresent()) { + NativeWebSocket ws = request.upgrade(NativeWebSocket.class); + ws.onConnect(() -> ((WebSocketImpl) sockets.get()).connect(injector, req, ws)); + return; + } + } + } + + // usual req/rsp + Route[] routes = routeCache + .getUnchecked(new RouteKey(method, path, type, req.accept())); + + RouteChain chain = new RouteChain(req, rsp, routes); + scope.put(CHAIN, chain); + chain.next(req, rsp); + + } catch (DeferredExecution ex) { + deferred = true; + onDeferred(scope, request, req, rsp, ex.deferred); + } catch (Throwable ex) { + x = ex; + } finally { + cleanup(req, rsp, true, x, !deferred); + } + } + + private boolean upgrade(final NativeRequest request) { + Optional upgrade = request.header(UPGRADE); + return upgrade.isPresent() && upgrade.get().equalsIgnoreCase(WEB_SOCKET); + } + + private void done(final RequestImpl req, final ResponseImpl rsp, final Throwable x, + final boolean close) { + // mark request/response as done. + req.done(); + if (close) { + rsp.done(Optional.ofNullable(x)); + } + } + + private void onDeferred(final Map scope, final NativeRequest request, + final RequestImpl req, final ResponseImpl rsp, final Deferred deferred) { + /** Deferred executor. */ + Key execKey = deferred.executor() + .map(it -> Key.get(Executor.class, Names.named(it))) + .orElse(gexec); + + /** Get executor. */ + Executor executor = injector.getInstance(execKey); + + request.startAsync(executor, () -> { + try { + deferred.handler(req, (success, x) -> { + boolean close = false; + Optional failure = Optional.ofNullable(x); + try { + requestScope.enter(scope); + if (success != null) { + close = true; + rsp.send(success); + } + } catch (Throwable exerr) { + failure = Optional.of(failure.orElse(exerr)); + } finally { + Throwable cause = failure.orElse(null); + if (cause != null) { + close = true; + } + cleanup(req, rsp, close, cause, true); + } + }); + } catch (Exception ex) { + handleErr(req, rsp, ex); + } + }); + } + + private void cleanup(final RequestImpl req, final ResponseImpl rsp, final boolean close, + final Throwable x, final boolean done) { + if (x != null) { + handleErr(req, rsp, x); + } + if (done) { + done(req, rsp, x, close); + } + requestScope.exit(); + } + + private void handleErr(final RequestImpl req, final ResponseImpl rsp, final Throwable ex) { + Logger log = LoggerFactory.getLogger(HttpHandler.class); + try { + log.debug("execution of: {}{} resulted in exception", req.method(), req.path(), ex); + // execution failed, find status code + Status status = sc.apply(ex); + + if (status == Status.REQUESTED_RANGE_NOT_SATISFIABLE) { + String range = rsp.header("Content-Length").toOptional().map(it -> "bytes */" + it) + .orElse("*"); + rsp.reset(); + rsp.header("Content-Range", range); + } else { + rsp.reset(); + } + + rsp.header("Cache-Control", NO_CACHE); + rsp.status(status); + + Err err = ex instanceof Err ? (Err) ex : new Err(status, ex); + + Iterator it = this.err.iterator(); + while (!rsp.committed() && it.hasNext()) { + Err.Handler next = it.next(); + log.debug("handling err with: {}", next); + next.handle(req, rsp, err); + } + } catch (Throwable errex) { + log.error("error handler resulted in exception: {}{}\nRoute:\n{}\n\nStacktrace:\n{}\nSource:", + req.method(), req.path(), req.route().print(6), Throwables.getStackTraceAsString(errex), + ex); + } + } + + private static String normalizeURI(final String uri) { + int len = uri.length(); + return len > 1 && uri.charAt(len - 1) == '/' ? uri.substring(0, len - 1) : uri; + } + + private static Route[] routes(final Set routeDefs, final String method, + final String path, final MediaType type, final List accept) { + List routes = findRoutes(routeDefs, method, path, type, accept); + + routes.add(RouteImpl.fallback((req, rsp, chain) -> { + if (!rsp.status().isPresent()) { + // 406 or 415 + Err ex = handle406or415(routeDefs, method, path, type, accept); + if (ex != null) { + throw ex; + } + // 405 + ex = handle405(routeDefs, method, path, type, accept); + if (ex != null) { + throw ex; + } + // favicon.ico + if (path.equals("/favicon.ico")) { + // default /favicon.ico handler: + rsp.status(Status.NOT_FOUND).end(); + } else { + throw new Err(Status.NOT_FOUND, req.path()); + } + } + }, method, path, "err", accept)); + + return routes.toArray(new Route[routes.size()]); + } + + private static List findRoutes(final Set routeDefs, final String method, + final String path, final MediaType type, final List accept) { + + List routes = new ArrayList<>(); + for (Route.Definition routeDef : routeDefs) { + Optional route = routeDef.matches(method, path, type, accept); + if (route.isPresent()) { + routes.add(route.get()); + } + } + return routes; + } + + private static Optional findSockets(final Set sockets, + final String path) { + for (WebSocket.Definition socketDef : sockets) { + Optional match = socketDef.matches(path); + if (match.isPresent()) { + return match; + } + } + return Optional.empty(); + } + + private static Err handle405(final Set routeDefs, final String method, + final String path, final MediaType type, final List accept) { + + if (alternative(routeDefs, method, path).size() > 0) { + return new Err(Status.METHOD_NOT_ALLOWED, method); + } + + return null; + } + + private static List alternative(final Set routeDefs, final String verb, + final String uri) { + List routes = new LinkedList<>(); + Set verbs = Sets.newHashSet(Route.METHODS); + verbs.remove(verb); + for (String alt : verbs) { + findRoutes(routeDefs, alt, uri, MediaType.all, MediaType.ALL) + .stream() + // skip glob pattern + .filter(r -> !r.pattern().contains("*")) + .forEach(routes::add); + + } + return routes; + } + + private static Err handle406or415(final Set routeDefs, final String method, + final String path, final MediaType contentType, final List accept) { + for (Route.Definition routeDef : routeDefs) { + Optional route = routeDef.matches(method, path, MediaType.all, MediaType.ALL); + if (route.isPresent() && !route.get().pattern().contains("*")) { + if (!routeDef.canProduce(accept)) { + return new Err(Status.NOT_ACCEPTABLE, accept.stream() + .map(MediaType::name) + .collect(Collectors.joining(", "))); + } + if (!contentType.isAny()) { + return new Err(Status.UNSUPPORTED_MEDIA_TYPE, contentType.name()); + } + } + } + return null; + } + + private static String method(final String methodParam, final NativeRequest request) + throws Exception { + Optional header = request.header(methodParam); + if (header.isPresent()) { + return header.get(); + } + List param = request.params(methodParam); + return param.size() == 0 ? request.method() : param.get(0); + } + + private static LoadingCache routeCache(final Set routes, + final Config conf) { + return CacheBuilder.from(conf.getString("server.routes.Cache")) + .build(new CacheLoader() { + @Override + public Route[] load(final RouteKey key) throws Exception { + return routes(routes, key.method, key.path, key.consumes, key.produces); + } + }); + } + + private static Function rootpath(final String applicationPath) { + return p -> { + if (applicationPath.equals(p)) { + return "/"; + } else if (p.startsWith(applicationPath)) { + return p.substring(applicationPath.length()); + } else { + // mark as failure + return Route.errpath(p); + } + }; + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/HttpRendererContext.java b/jooby/src/main/java/org/jooby/internal/HttpRendererContext.java new file mode 100644 index 00000000..faf64831 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/HttpRendererContext.java @@ -0,0 +1,130 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import com.google.common.io.ByteStreams; +import org.jooby.Err; +import org.jooby.MediaType; +import org.jooby.Renderer; +import org.jooby.Renderer.Context; +import org.jooby.Status; +import org.jooby.spi.NativeResponse; + +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.Charset; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; + +public class HttpRendererContext extends AbstractRendererContext { + + private Consumer length; + + private Consumer type; + + private NativeResponse rsp; + + private Optional byteRange; + + public HttpRendererContext(final List renderers, + final NativeResponse rsp, final Consumer len, final Consumer type, + final Map locals, final List produces, final Charset charset, + final Locale locale, final Optional byteRange) { + super(renderers, produces, charset, locale, locals); + this.byteRange = byteRange; + this.rsp = rsp; + this.length = len; + this.type = type; + } + + @Override + public Context length(final long length) { + this.length.accept(length); + return this; + } + + @Override + public Context type(final MediaType type) { + this.type.accept(type); + return this; + } + + @Override + protected void _send(final ByteBuffer buffer) throws Exception { + rsp.send(buffer); + } + + @Override + protected void _send(final byte[] bytes) throws Exception { + rsp.send(bytes); + } + + @Override + protected void _send(final FileChannel file) throws Exception { + long[] byteRange = byteRange(); + if (byteRange == null) { + rsp.send(file); + } else { + rsp.send(file, byteRange[0], byteRange[1]); + } + } + + @Override + protected void _send(final InputStream stream) throws Exception { + long[] byteRange = byteRange(); + if (byteRange == null) { + rsp.send(stream); + } else { + stream.skip(byteRange[0]); + rsp.send(ByteStreams.limit(stream, byteRange[1])); + } + } + + private long[] byteRange() { + long len = rsp.header("Content-Length").map(Long::parseLong).orElse(-1L); + if (len > 0) { + if (byteRange.isPresent()) { + String raw = byteRange.get(); + long[] range = ByteRange.parse(raw); + long start = range[0]; + long end = range[1]; + if (start == -1) { + start = len - end; + end = len - 1; + } + if (end == -1 || end > len - 1) { + end = len - 1; + } + if (start > end) { + throw new Err(Status.REQUESTED_RANGE_NOT_SATISFIABLE, raw); + } + // offset + long limit = (end - start + 1); + rsp.header("Accept-Ranges", "bytes"); + rsp.header("Content-Range", "bytes " + start + "-" + end + "/" + len); + rsp.header("Content-Length", Long.toString(limit)); + rsp.statusCode(Status.PARTIAL_CONTENT.value()); + return new long[]{start, limit}; + } + } + return null; + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/InputStreamAsset.java b/jooby/src/main/java/org/jooby/internal/InputStreamAsset.java new file mode 100644 index 00000000..1f426875 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/InputStreamAsset.java @@ -0,0 +1,75 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static java.util.Objects.requireNonNull; + +import java.io.InputStream; +import java.net.URL; + +import org.jooby.Asset; +import org.jooby.MediaType; + +public class InputStreamAsset implements Asset { + + private InputStream stream; + + private String name; + + private MediaType type; + + public InputStreamAsset(final InputStream stream, final String name, final MediaType type) { + this.stream = requireNonNull(stream, "InputStream is required."); + this.name = requireNonNull(name, "Name is required."); + this.type = requireNonNull(type, "Type is required."); + } + + @Override + public String name() { + return name; + } + + @Override + public URL resource() { + throw new UnsupportedOperationException(); + } + + @Override + public String path() { + return name; + } + + @Override + public long length() { + return -1; + } + + @Override + public long lastModified() { + return -1; + } + + @Override + public InputStream stream() throws Exception { + return stream; + } + + @Override + public MediaType type() { + return type; + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/JvmInfo.java b/jooby/src/main/java/org/jooby/internal/JvmInfo.java new file mode 100644 index 00000000..44136877 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/JvmInfo.java @@ -0,0 +1,32 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import java.lang.management.ManagementFactory; + +public class JvmInfo { + + /** + * @return Get JVM PID. + */ + public static long pid() { + try { + return Long.parseLong(ManagementFactory.getRuntimeMXBean().getName().split("@")[0]); + } catch (Exception e) { + return -1; + } + } +} diff --git a/jooby/src/main/java/org/jooby/internal/LocaleUtils.java b/jooby/src/main/java/org/jooby/internal/LocaleUtils.java new file mode 100644 index 00000000..8309241b --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/LocaleUtils.java @@ -0,0 +1,61 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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. + */ +/** + * This copy of Woodstox XML processor is licensed under the + * Apache (Software) License, version 2.0 ("the License"). + * See the License for details about distribution rights, and the + * specific rights regarding derivate works. + * + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/ + * + * A copy is also included in the downloadable source code package + * containing Woodstox, in file "ASL2.0", under the same directory + * as this file. + */ +package org.jooby.internal; + +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + +public class LocaleUtils { + + public static List parse(final String value) { + return range(value).stream() + .map(r -> Locale.forLanguageTag(r.getRange())) + .collect(Collectors.toList()); + } + + public static Locale parseOne(final String value) { + return parse(value).get(0); + } + + public static List range(final String value) { + // remove trailing ';' well-formed vs ill-formed + String wellformed = value; + if (wellformed.charAt(wellformed.length() - 1) == ';') { + wellformed = wellformed.substring(0, wellformed.length() - 1); + } + List range = Locale.LanguageRange.parse(wellformed); + return range.stream() + .sorted(Comparator.comparing(Locale.LanguageRange::getWeight).reversed()) + .collect(Collectors.toList()); + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/MappedHandler.java b/jooby/src/main/java/org/jooby/internal/MappedHandler.java new file mode 100644 index 00000000..8a453173 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/MappedHandler.java @@ -0,0 +1,55 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Route; +import org.jooby.Route.Chain; +import org.jooby.Route.Filter; +import org.jooby.Route.Mapper; +import org.jooby.funzy.Throwing; +import org.jooby.funzy.Try; + +@SuppressWarnings({"unchecked", "rawtypes"}) +public class MappedHandler implements Filter { + + private Throwing.Function3 supplier; + private Mapper mapper; + + public MappedHandler(final Throwing.Function3 supplier, + final Route.Mapper mapper) { + this.supplier = supplier; + this.mapper = mapper; + } + + public MappedHandler(final Throwing.Function2 supplier, + final Route.Mapper mapper) { + this((req, rsp, chain) -> supplier.apply(req, rsp), mapper); + } + + @Override + public void handle(final Request req, final Response rsp, final Chain chain) throws Throwable { + Object input = supplier.apply(req, rsp, chain); + Object output = Try + .apply(() -> mapper.map(input)) + .recover(ClassCastException.class, input) + .get(); + rsp.send(output); + chain.next(req, rsp); + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/MutantImpl.java b/jooby/src/main/java/org/jooby/internal/MutantImpl.java new file mode 100644 index 00000000..56ef6594 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/MutantImpl.java @@ -0,0 +1,128 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import com.google.common.collect.ImmutableMap; +import com.google.inject.TypeLiteral; +import org.jooby.Err; +import org.jooby.MediaType; +import org.jooby.Mutant; +import org.jooby.Parser; +import org.jooby.Status; +import org.jooby.internal.parser.ParserExecutor; + +import java.util.HashMap; +import java.util.Map; +import java.util.NoSuchElementException; + +/** + * Default mutant implementation. + * + * NOTE: This class isn't thread-safe. + * + * @author edgar + */ +public class MutantImpl implements Mutant { + + private static final String REQUIRED = "Required %s is not present"; + + private static final String FAILURE = "Failed to parse %s to '%s'"; + + private final Map results = new HashMap<>(1); + + private final ParserExecutor parser; + + private Object data; + + private MediaType type; + + public MutantImpl(final ParserExecutor parser, final MediaType type, final Object data) { + this.parser = parser; + this.type = type; + this.data = data; + } + + public MutantImpl(final ParserExecutor parser, final Object data) { + this(parser, MediaType.plain, data); + } + + @Override + public T to(final TypeLiteral type) { + return to(type, this.type); + } + + @SuppressWarnings("unchecked") + @Override + public T to(final TypeLiteral type, final MediaType mtype) { + T result = (T) results.get(type); + if (result == null) { + try { + result = parser.convert(type, mtype, data); + if (result == ParserExecutor.NO_PARSER) { + Object[] md = md(); + throw new Err((Status) md[2], String.format(FAILURE, md[1], type)); + } + results.put(type, result); + } catch (NoSuchElementException ex) { + Object[] md = md(); + throw new Err.Missing(String.format(REQUIRED, md[1])); + } catch (Err ex) { + throw ex; + } catch (Throwable ex) { + Object[] md = md(); + throw new Err(parser.statusCode(ex), String.format(FAILURE, md[1], type), ex); + } + } + return result; + } + + @SuppressWarnings("unchecked") + @Override + public Map toMap() { + if (data instanceof Map) { + return (Map) data; + } + return ImmutableMap.of((String) md()[0], this); + } + + @SuppressWarnings("rawtypes") + @Override + public boolean isSet() { + if (data instanceof ParamReferenceImpl) { + return ((ParamReferenceImpl) data).size() > 0; + } + if (data instanceof Parser.BodyReference) { + return ((Parser.BodyReference) data).length() > 0; + } + return ((Map) data).size() > 0; + } + + private Object[] md() { + if (data instanceof ParamReferenceImpl) { + ParamReferenceImpl p = (ParamReferenceImpl) data; + return new Object[]{p.name(), p.type() + " '" + p.name() + "'", Status.BAD_REQUEST}; + } else if (data instanceof Parser.BodyReference) { + return new Object[]{"body", "body", Status.UNSUPPORTED_MEDIA_TYPE}; + } + return new Object[]{"params", "parameters", Status.BAD_REQUEST}; + } + + @Override + public String toString() { + return data.toString(); + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/ParamReferenceImpl.java b/jooby/src/main/java/org/jooby/internal/ParamReferenceImpl.java new file mode 100644 index 00000000..a110bf91 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/ParamReferenceImpl.java @@ -0,0 +1,81 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; + +import org.jooby.Parser; + +public class ParamReferenceImpl implements Parser.ParamReference { + + private String type; + + private String name; + + private List values; + + public ParamReferenceImpl(final String type, final String name, final List values) { + this.type = type; + this.name = name; + this.values = values; + } + + @Override + public String type() { + return type; + } + + @Override + public String name() { + return name; + } + + @Override + public T first() { + return get(0); + } + + @Override + public T last() { + return get(values.size() - 1); + } + + @Override + public T get(final int index) { + if (index >= 0 && index < values.size()) { + return values.get(index); + } + throw new NoSuchElementException(name); + } + + @Override + public Iterator iterator() { + return values.iterator(); + } + + @Override + public int size() { + return values.size(); + } + + @Override + public String toString() { + return values.toString(); + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/ParameterNameProvider.java b/jooby/src/main/java/org/jooby/internal/ParameterNameProvider.java new file mode 100644 index 00000000..f422056c --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/ParameterNameProvider.java @@ -0,0 +1,35 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import java.lang.reflect.Executable; + +/** + * Extract parameter names from an executable: method or constructor. + * + * @author edgar + * @since 0.6.0 + */ +public interface ParameterNameProvider { + + /** + * Extract parameter names from a executable: method or constructor. + * + * @param exec Method or constructor. + * @return Names or zero array length. + */ + String[] names(Executable exec); +} diff --git a/jooby/src/main/java/org/jooby/internal/ReaderInputStream.java b/jooby/src/main/java/org/jooby/internal/ReaderInputStream.java new file mode 100644 index 00000000..df04f90d --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/ReaderInputStream.java @@ -0,0 +1,203 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.Charset; +import java.nio.charset.CharsetEncoder; +import java.nio.charset.CoderResult; + +/** + * {@link InputStream} implementation that reads a character stream from a {@link Reader} and + * transforms it to a byte stream using a specified charset encoding. The stream + * is transformed using a {@link CharsetEncoder} object, guaranteeing that all charset + * encodings supported by the JRE are handled correctly. In particular for charsets such as + * UTF-16, the implementation ensures that one and only one byte order marker + * is produced. + *

+ * Since in general it is not possible to predict the number of characters to be read from the + * {@link Reader} to satisfy a read request on the {@link ReaderInputStream}, all reads from the + * {@link Reader} are buffered. There is therefore no well defined correlation between the current + * position of the {@link Reader} and that of the {@link ReaderInputStream}. This also implies that + * in general there is no need to wrap the underlying {@link Reader} in a + * {@link java.io.BufferedReader}. + *

+ * {@link ReaderInputStream} implements the inverse transformation of + * {@link java.io.InputStreamReader}; in the following example, reading from in2 would + * return the same byte sequence as reading from in (provided that the initial byte + * sequence is legal with respect to the charset encoding): + * + *

+ * InputStream in = ...
+ * Charset cs = ...
+ * InputStreamReader reader = new InputStreamReader(in, cs);
+ * ReaderInputStream in2 = new ReaderInputStream(reader, cs);
+ * {@link ReaderInputStream} implements the same transformation as + * {@link java.io.OutputStreamWriter}, except that the control flow is reversed: both classes + * transform a character stream into a byte stream, but {@link java.io.OutputStreamWriter} pushes + * data to the underlying stream, while {@link ReaderInputStream} pulls it from the underlying + * stream. + *

+ * Note that while there are use cases where there is no alternative to using this class, very often + * the need to use this class is an indication of a flaw in the design of the code. This class is + * typically used in situations where an existing API only accepts an {@link InputStream}, but where + * the most natural way to produce the data is as a character stream, i.e. by providing a + * {@link Reader} instance. An example of a situation where this problem may appear is when + * implementing the {@link javax.activation.DataSource} interface from the Java Activation + * Framework. + *

+ * Given the fact that the {@link Reader} class doesn't provide any way to predict whether the next + * read operation will block or not, it is not possible to provide a meaningful implementation of + * the {@link InputStream#available()} method. A call to this method will always return 0. Also, + * this class doesn't support {@link InputStream#mark(int)}. + *

+ * Instances of {@link ReaderInputStream} are not thread safe. + * + * @author Andreas Veithen + * @since Commons IO 2.0 + */ +public class ReaderInputStream extends InputStream { + private static final int DEFAULT_BUFFER_SIZE = 1024; + + private final Reader reader; + private final CharsetEncoder encoder; + + /** + * CharBuffer used as input for the decoder. It should be reasonably + * large as we read data from the underlying Reader into this buffer. + */ + private final CharBuffer encoderIn; + + /** + * ByteBuffer used as output for the decoder. This buffer can be small + * as it is only used to transfer data from the decoder to the + * buffer provided by the caller. + */ + private final ByteBuffer encoderOut = ByteBuffer.allocate(128); + + private CoderResult lastCoderResult; + + private boolean endOfInput; + + /** + * Construct a new {@link ReaderInputStream}. + * + * @param reader the target {@link Reader} + * @param charset the charset encoding + * @param bufferSize the size of the input buffer in number of characters + */ + private ReaderInputStream(final Reader reader, final Charset charset, final int bufferSize) { + this.reader = reader; + encoder = charset.newEncoder(); + encoderIn = CharBuffer.allocate(bufferSize); + encoderIn.flip(); + } + + /** + * Construct a new {@link ReaderInputStream} with a default input buffer size of + * 1024 characters. + * + * @param reader the target {@link Reader} + * @param charset the charset encoding + */ + public ReaderInputStream(final Reader reader, final Charset charset) { + this(reader, charset, DEFAULT_BUFFER_SIZE); + } + + /** + * Read the specified number of bytes into an array. + * + * @param b the byte array to read into + * @param off the offset to start reading bytes into + * @param len the number of bytes to read + * @return the number of bytes read or -1 if the end of the stream has been reached + * @throws IOException if an I/O error occurs + */ + @Override + public int read(final byte[] b, int off, int len) throws IOException { + int read = 0; + while (len > 0) { + if (encoderOut.position() > 0) { + encoderOut.flip(); + int c = Math.min(encoderOut.remaining(), len); + encoderOut.get(b, off, c); + off += c; + len -= c; + read += c; + encoderOut.compact(); + } else { + if (!endOfInput && (lastCoderResult == null || lastCoderResult.isUnderflow())) { + encoderIn.compact(); + int position = encoderIn.position(); + // We don't use Reader#read(CharBuffer) here because it is more efficient + // to write directly to the underlying char array (the default implementation + // copies data to a temporary char array). + int c = reader.read(encoderIn.array(), position, encoderIn.remaining()); + if (c == -1) { + endOfInput = true; + } else { + encoderIn.position(position + c); + } + encoderIn.flip(); + } + lastCoderResult = encoder.encode(encoderIn, encoderOut, endOfInput); + if (endOfInput && encoderOut.position() == 0) { + break; + } + } + } + return read == 0 && endOfInput ? -1 : read; + } + + /** + * Read the specified number of bytes into an array. + * + * @param b the byte array to read into + * @return the number of bytes read or -1 if the end of the stream has been reached + * @throws IOException if an I/O error occurs + */ + @Override + public int read(final byte[] b) throws IOException { + return read(b, 0, b.length); + } + + /** + * Read a single byte. + * + * @return either the byte read or -1 if the end of the stream + * has been reached + * @throws IOException if an I/O error occurs + */ + @Override + public int read() throws IOException { + byte[] b = new byte[1]; + return read(b) == -1 ? -1 : b[0] & 0xFF; + } + + /** + * Close the stream. This method will cause the underlying {@link Reader} to be closed. + * + * @throws IOException if an I/O error occurs + */ + @Override + public void close() throws IOException { + reader.close(); + } +} diff --git a/jooby/src/main/java/org/jooby/internal/RegexRouteMatcher.java b/jooby/src/main/java/org/jooby/internal/RegexRouteMatcher.java new file mode 100644 index 00000000..3c6d1d30 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/RegexRouteMatcher.java @@ -0,0 +1,74 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static java.util.Objects.requireNonNull; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; + +public class RegexRouteMatcher implements RouteMatcher { + + private final Matcher matcher; + + private final List varNames; + + private final Map vars = new HashMap<>(); + + private final String path; + + public RegexRouteMatcher(final String path, final Matcher matcher, + final List varNames) { + this.path = requireNonNull(path, "A path is required."); + this.matcher = requireNonNull(matcher, "A matcher is required."); + this.varNames = requireNonNull(varNames, "The varNames are required."); + } + + @Override + public String path() { + return path; + } + + @Override + public boolean matches() { + boolean matches = matcher.matches(); + if (matches) { + int varCount = varNames.size(); + int groupCount = matcher.groupCount(); + for (int idx = 0; idx < groupCount; idx++) { + String var = matcher.group(idx + 1); + if (var.startsWith("/")) { + var = var.substring(1); + } + // idx indices + vars.put(idx, var); + // named vars + if (idx < varCount) { + vars.put(varNames.get(idx), matcher.group("v" + idx)); + } + } + } + return matches; + } + + @Override + public Map vars() { + return vars; + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/RequestImpl.java b/jooby/src/main/java/org/jooby/internal/RequestImpl.java new file mode 100644 index 00000000..50ed8cf8 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/RequestImpl.java @@ -0,0 +1,474 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import com.google.common.collect.ImmutableList; +import com.google.inject.Injector; +import com.google.inject.Key; +import com.typesafe.config.Config; +import org.jooby.*; +import org.jooby.funzy.Try; +import org.jooby.internal.parser.ParserExecutor; +import org.jooby.spi.NativeRequest; +import org.jooby.spi.NativeUpload; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.*; +import java.util.Locale.LanguageRange; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import static java.util.Objects.requireNonNull; + +public class RequestImpl implements Request { + + private final Map params = new HashMap<>(); + + private final List accept; + + private final MediaType type; + + private final Injector injector; + + private final NativeRequest req; + + private final Map scope; + + private final Map locals; + + private Route route; + + private Optional reqSession; + + private Charset charset; + + private List files; + + private int port; + + private String contextPath; + + private Optional lang; + + private List locales; + + private long timestamp; + + public RequestImpl(final Injector injector, final NativeRequest req, final String contextPath, + final int port, final Route route, final Charset charset, final List locales, + final Map scope, final Map locals, final long timestamp) { + this.injector = injector; + this.req = req; + this.route = route; + this.scope = scope; + this.locals = locals; + + this.contextPath = contextPath; + + Optional accept = req.header("Accept"); + this.accept = accept.isPresent() ? MediaType.parse(accept.get()) : MediaType.ALL; + + this.lang = req.header("Accept-Language"); + this.locales = locales; + + this.port = port; + + Optional type = req.header("Content-Type"); + this.type = type.isPresent() ? MediaType.valueOf(type.get()) : MediaType.all; + + String cs = this.type.params().get("charset"); + this.charset = cs != null ? Charset.forName(cs) : charset; + + this.files = new ArrayList<>(); + this.timestamp = timestamp; + } + + @Override + public String contextPath() { + return contextPath; + } + + @Override + public Optional queryString() { + return req.queryString(); + } + + @SuppressWarnings("unchecked") + @Override + public Optional ifGet(final String name) { + requireNonNull(name, "A local's name is required."); + return Optional.ofNullable((T) locals.get(name)); + } + + @Override + public boolean matches(final String pattern) { + RoutePattern p = new RoutePattern("*", pattern); + return p.matcher(route.path()).matches(); + } + + @Override + public Map attributes() { + return Collections.unmodifiableMap(locals); + } + + @SuppressWarnings("unchecked") + @Override + public Optional unset(final String name) { + requireNonNull(name, "A local's name is required."); + return Optional.ofNullable((T) locals.remove(name)); + } + + @Override + public MediaType type() { + return type; + } + + @Override + public List accept() { + return accept; + } + + @Override + public Optional accepts(final List types) { + requireNonNull(types, "Media types are required."); + return MediaType.matcher(accept()).first(types); + } + + @Override + public Mutant params(final String... xss) { + return _params(xss(xss)); + } + + @Override + public Mutant params() { + return _params(null); + } + + private Mutant _params(final Function xss) { + Map params = new HashMap<>(); + for (Object segment : route.vars().keySet()) { + if (segment instanceof String) { + String name = (String) segment; + params.put(name, _param(name, xss)); + } + } + for (String name : paramNames()) { + params.put(name, _param(name, xss)); + } + return new MutantImpl(require(ParserExecutor.class), params); + } + + @Override + public Mutant param(final String name, final String... xss) { + return _param(name, xss(xss)); + } + + @Override + public Mutant param(final String name) { + return _param(name, null); + } + + @Override + public List files(final String name) throws IOException { + List files = req.files(name); + List uploads = files.stream() + .map(upload -> new UploadImpl(injector, upload)) + .collect(Collectors.toList()); + return uploads; + } + + public List files() throws IOException { + return req.files() + .stream() + .map(upload -> new UploadImpl(injector, upload)) + .collect(Collectors.toList()); + } + + private Mutant _param(final String name, final Function xss) { + Mutant param = this.params.get(name); + if (param == null) { + StrParamReferenceImpl paramref = new StrParamReferenceImpl("parameter", name, + params(name, xss)); + param = new MutantImpl(require(ParserExecutor.class), paramref); + + if (paramref.size() > 0) { + this.params.put(name, param); + } + } + return param; + } + + @Override + public Mutant header(final String name) { + return _header(name, null); + } + + @Override + public Mutant header(final String name, final String... xss) { + return _header(name, xss(xss)); + } + + private Mutant _header(final String name, final Function xss) { + requireNonNull(name, "Name required."); + List headers = req.headers(name); + if (xss != null) { + headers = headers.stream() + .map(xss::apply) + .collect(Collectors.toList()); + } + return new MutantImpl(require(ParserExecutor.class), + new StrParamReferenceImpl("header", name, headers)); + } + + @Override + public Map headers() { + Map headers = new LinkedHashMap<>(); + req.headerNames().forEach(name -> headers.put(name, header(name))); + return headers; + } + + @Override + public Mutant cookie(final String name) { + List values = req.cookies().stream().filter(c -> c.name().equalsIgnoreCase(name)) + .findFirst() + .map(cookie -> ImmutableList.of(cookie.value().orElse(""))) + .orElse(ImmutableList.of()); + + return new MutantImpl(require(ParserExecutor.class), + new StrParamReferenceImpl("cookie", name, values)); + } + + @Override + public List cookies() { + return req.cookies(); + } + + @Override + public Mutant body() throws Exception { + long length = length(); + if (length > 0) { + MediaType type = type(); + Config conf = require(Config.class); + // TODO: sanitization of arguments + File fbody = new File(conf.getString("application.tmpdir"), + Integer.toHexString(System.identityHashCode(this))); + files.add(fbody); + int bufferSize = conf.getBytes("server.http.RequestBufferSize").intValue(); + Parser.BodyReference body = new BodyReferenceImpl(length, charset(), fbody, req.in(), bufferSize); + return new MutantImpl(require(ParserExecutor.class), type, body); + } + return new MutantImpl(require(ParserExecutor.class), type, new EmptyBodyReference()); + } + + @Override + public T require(final Key key) { + return injector.getInstance(key); + } + + @Override + public Charset charset() { + return charset; + } + + @Override + public long length() { + return req.header("Content-Length") + .map(Long::parseLong) + .orElse(-1L); + } + + @Override + public List locales( + final BiFunction, List, List> filter) { + return lang.map(h -> filter.apply(LocaleUtils.range(h), locales)) + .orElseGet(() -> filter.apply(ImmutableList.of(), locales)); + } + + @Override + public Locale locale(final BiFunction, List, Locale> filter) { + Supplier def = () -> filter.apply(ImmutableList.of(), locales); + // don't fail on bad Accept-Language header, just fallback to default locale. + return lang.map(h -> Try.apply(() -> filter.apply(LocaleUtils.range(h), locales)).orElseGet(def)) + .orElseGet(def); + } + + @Override + public String ip() { + return req.ip(); + } + + @Override + public Route route() { + return route; + } + + @Override + public String rawPath() { + return req.rawPath(); + } + + @Override + public String hostname() { + return req.header("host").map(host -> host.split(":")[0]).orElse(ip()); + } + + @Override + public int port() { + return req.header("host").map(host -> { + String[] parts = host.split(":"); + if (parts.length > 1) { + return Integer.parseInt(parts[1]); + } + // fallback to default ports + return secure() ? 443 : 80; + }).orElse(port); + } + + @Override + public Session session() { + return ifSession().orElseGet(() -> { + SessionManager sm = require(SessionManager.class); + Response rsp = require(Response.class); + Session gsession = sm.create(this, rsp); + return setSession(sm, rsp, gsession); + }); + } + + @Override + public Optional ifSession() { + if (reqSession == null) { + SessionManager sm = require(SessionManager.class); + Response rsp = require(Response.class); + Session gsession = sm.get(this, rsp); + if (gsession == null) { + reqSession = Optional.empty(); + } else { + setSession(sm, rsp, gsession); + } + } + return reqSession; + } + + @Override + public String protocol() { + return req.protocol(); + } + + @Override + public boolean secure() { + return req.secure(); + } + + @Override + public Request set(final String name, final Object value) { + requireNonNull(name, "A local's name is required."); + requireNonNull(value, "A local's value is required."); + locals.put(name, value); + return this; + } + + @Override + public Request set(final Key key, final Object value) { + requireNonNull(key, "A local's jey is required."); + requireNonNull(value, "A local's value is required."); + scope.put(key, value); + return this; + } + + @Override + public Request push(final String path, final Map headers) { + if (protocol().equalsIgnoreCase("HTTP/2.0")) { + require(Response.class).after((req, rsp, value) -> { + this.req.push("GET", contextPath + path, headers); + return value; + }); + return this; + } else { + throw new UnsupportedOperationException("Push promise not available"); + } + } + + @Override + public String toString() { + return route().toString(); + } + + private List paramNames() { + try { + return req.paramNames(); + } catch (Exception ex) { + throw new Err(Status.BAD_REQUEST, "Unable to get parameter names", ex); + } + } + + private Function xss(final String... xss) { + return require(Env.class).xss(xss); + } + + private List params(final String name, final Function xss) { + try { + List values = new ArrayList<>(); + String pathvar = route.vars().get(name); + if (pathvar != null) { + values.add(pathvar); + } + values.addAll(req.params(name)); + if (xss == null) { + return values; + } + for (int i = 0; i < values.size(); i++) { + values.set(i, xss.apply(values.get(i))); + } + return values; + } catch (Throwable ex) { + throw new Err(Status.BAD_REQUEST, "Parameter '" + name + "' resulted in error", ex); + } + } + + void route(final Route route) { + this.route = route; + } + + public void done() { + if (reqSession != null) { + reqSession.ifPresent(session -> require(SessionManager.class).requestDone(session)); + } + if (files.size() > 0) { + for (File file : files) { + file.delete(); + } + } + } + + @Override + public long timestamp() { + return timestamp; + } + + private Session setSession(final SessionManager sm, final Response rsp, final Session gsession) { + Session rsession = new RequestScopedSession(sm, rsp, (SessionImpl) gsession, this::destroySession); + reqSession = Optional.of(rsession); + return rsession; + } + + private void destroySession() { + this.reqSession = Optional.empty(); + } +} diff --git a/jooby/src/main/java/org/jooby/internal/RequestScope.java b/jooby/src/main/java/org/jooby/internal/RequestScope.java new file mode 100644 index 00000000..7a798738 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/RequestScope.java @@ -0,0 +1,72 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import java.util.Map; + +import com.google.inject.Key; +import com.google.inject.OutOfScopeException; +import com.google.inject.Provider; +import com.google.inject.Scope; +import com.google.inject.Scopes; + +public class RequestScope implements Scope { + + private final ThreadLocal> scope = new ThreadLocal<>(); + + public void enter(final Map locals) { + scope.set(locals); + } + + public void exit() { + scope.remove(); + } + + @SuppressWarnings({"unchecked", "rawtypes" }) + @Override + public Provider scope(final Key key, final Provider unscoped) { + return () -> { + Map scopedObjects = getScopedObjectMap(key); + + T current = (T) scopedObjects.get(key); + if (current == null && !scopedObjects.containsKey(key)) { + current = unscoped.get(); + + // don't remember proxies; these exist only to serve circular dependencies + if (Scopes.isCircularProxy(current)) { + return current; + } + + scopedObjects.put(key, current); + } + if (current instanceof javax.inject.Provider) { + if (!javax.inject.Provider.class.isAssignableFrom(key.getTypeLiteral().getRawType())) { + return (T) ((javax.inject.Provider) current).get(); + } + } + return current; + }; + } + + private Map getScopedObjectMap(final Key key) { + Map scopedObjects = scope.get(); + if (scopedObjects == null) { + throw new OutOfScopeException("Cannot access " + key + " outside of a scoping block"); + } + return scopedObjects; + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/RequestScopedSession.java b/jooby/src/main/java/org/jooby/internal/RequestScopedSession.java new file mode 100644 index 00000000..8fa59c24 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/RequestScopedSession.java @@ -0,0 +1,180 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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. + */ +/** + * This copy of Woodstox XML processor is licensed under the + * Apache (Software) License, version 2.0 ("the License"). + * See the License for details about distribution rights, and the + * specific rights regarding derivate works. + * + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/ + * + * A copy is also included in the downloadable source code package + * containing Woodstox, in file "ASL2.0", under the same directory + * as this file. + */ +package org.jooby.internal; + +import org.jooby.Mutant; +import org.jooby.Response; +import org.jooby.Session; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; + +public class RequestScopedSession implements Session { + + /** The logging system. */ + private final Logger log = LoggerFactory.getLogger(Session.class); + + private SessionManager sm; + + private Response rsp; + + private SessionImpl session; + + private Runnable resetSession; + + public RequestScopedSession(final SessionManager sm, final Response rsp, + final SessionImpl session, final Runnable resetSession) { + this.sm = sm; + this.rsp = rsp; + this.session = session; + this.resetSession = resetSession; + } + + @Override + public String id() { + notDestroyed(); + return session.id(); + } + + @Override + public long createdAt() { + notDestroyed(); + return session.createdAt(); + } + + @Override + public long accessedAt() { + notDestroyed(); + return session.accessedAt(); + } + + @Override + public long savedAt() { + notDestroyed(); + return session.savedAt(); + } + + @Override + public long expiryAt() { + notDestroyed(); + return session.expiryAt(); + } + + @Override + public Mutant get(final String name) { + notDestroyed(); + return session.get(name); + } + + @Override + public Map attributes() { + notDestroyed(); + return session.attributes(); + } + + @Override + public Session set(final String name, final String value) { + notDestroyed(); + session.set(name, value); + return this; + } + + @Override + public boolean isSet(final String name) { + notDestroyed(); + return session.isSet(name); + } + + @Override + public Mutant unset(final String name) { + notDestroyed(); + return session.unset(name); + } + + @Override + public Session unset() { + notDestroyed(); + session.unset(); + return this; + } + + @Override + public void destroy() { + if (this.session != null) { + // clear attributes + log.debug("destroying session: {}", session.id()); + session.destroy(); + // reset req session + resetSession.run(); + // clear cookie + org.jooby.Cookie.Definition cookie = sm.cookie(); + log.debug(" removing cookie: {}", cookie); + rsp.cookie(cookie.maxAge(0)); + // destroy session from storage + sm.destroy(session); + + // null everything + this.resetSession = null; + this.rsp = null; + this.session = null; + this.sm = null; + } + } + + @Override public boolean isDestroyed() { + if (session == null) { + return true; + } + return session.isDestroyed(); + } + + @Override public Session renewId() { + // Ignore client sessions + sm.renewId(session, rsp); + return this; + } + + @Override + public String toString() { + return session.toString(); + } + + public Session session() { + notDestroyed(); + return session; + } + + private void notDestroyed() { + if (isDestroyed()) { + throw new Session.Destroyed(); + } + } +} diff --git a/jooby/src/main/java/org/jooby/internal/ResponseImpl.java b/jooby/src/main/java/org/jooby/internal/ResponseImpl.java new file mode 100644 index 00000000..67515f2f --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/ResponseImpl.java @@ -0,0 +1,467 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import com.google.common.collect.ImmutableList; +import static java.util.Objects.requireNonNull; +import org.jooby.Asset; +import org.jooby.Cookie; +import org.jooby.Cookie.Definition; +import org.jooby.Deferred; +import org.jooby.MediaType; +import org.jooby.Mutant; +import org.jooby.Renderer; +import org.jooby.Response; +import org.jooby.Result; +import org.jooby.Results; +import org.jooby.Route; +import org.jooby.Route.After; +import org.jooby.Route.Complete; +import org.jooby.Status; +import org.jooby.internal.parser.ParserExecutor; +import org.jooby.spi.NativeResponse; +import org.jooby.funzy.Try; +import org.slf4j.LoggerFactory; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +public class ResponseImpl implements Response { + + private static final String LOCATION = "Location"; + + /** Char encoded content disposition. */ + private static final String CONTENT_DISPOSITION = "attachment; filename=\"%s\"; filename*=%s''%s"; + + private final NativeResponse rsp; + + private final Map locals; + + private Route route; + + private Charset charset; + + private final Optional referer; + + private Status status; + + private MediaType type; + + private long len = -1; + + private Map cookies = new HashMap<>(); + + private List renderers; + + private ParserExecutor parserExecutor; + + private Map rendererMap; + + private List after = new ArrayList<>(); + + private List complete = new ArrayList<>(); + + private RequestImpl req; + + private boolean failure; + + private Optional byteRange; + + private boolean resetHeadersOnError = true; + + public ResponseImpl(final RequestImpl req, final ParserExecutor parserExecutor, + final NativeResponse rsp, final Route route, final List renderers, + final Map rendererMap, final Map locals, + final Charset charset, final Optional referer, final Optional byteRange) { + this.req = req; + this.parserExecutor = parserExecutor; + this.rsp = rsp; + this.route = route; + this.locals = locals; + this.renderers = renderers; + this.rendererMap = rendererMap; + this.charset = charset; + this.referer = referer; + this.byteRange = byteRange; + } + + @Override public boolean isResetHeadersOnError() { + return resetHeadersOnError; + } + + @Override public void setResetHeadersOnError(boolean resetHeadersOnError) { + this.resetHeadersOnError = resetHeadersOnError; + } + + @Override + public void download(final String filename, final InputStream stream) throws Throwable { + requireNonNull(filename, "A file's name is required."); + requireNonNull(stream, "A stream is required."); + + // handle type + type(type().orElseGet(() -> MediaType.byPath(filename).orElse(MediaType.octetstream))); + + Asset asset = new InputStreamAsset(stream, filename, type); + contentDisposition(filename); + send(Results.with(asset.stream())); + } + + @Override + public void download(final String filename, final String location) throws Throwable { + URL url = getClass().getResource(location.startsWith("/") ? location : "/" + location); + if (url == null) { + throw new FileNotFoundException(location); + } + // handle type + type(type().orElseGet(() -> MediaType.byPath(filename).orElse(MediaType.byPath(location) + .orElse(MediaType.octetstream)))); + + URLAsset asset = new URLAsset(url, location, type); + length(asset.length()); + + contentDisposition(filename); + send(Results.with(asset)); + } + + @Override + public Response clearCookie(final String name) { + requireNonNull(name, "Cookie's name required."); + return cookie(new Cookie.Definition(name).maxAge(0)); + } + + @Override + public Response cookie(final Definition cookie) { + requireNonNull(cookie, "Cookie required."); + // use default path if none-set + cookie.path(cookie.path().orElse(Route.normalize(req.contextPath() + "/"))); + return cookie(cookie.toCookie()); + } + + @Override + public Response cookie(final Cookie cookie) { + requireNonNull(cookie, "Cookie required."); + String name = cookie.name(); + // clear cookie? + if (cookie.maxAge() == 0) { + // clear previously set cookie, otherwise ignore them + if (cookies.remove(name) == null) { + // cookie was set in a previous req, we must send a expire header. + cookies.put(name, cookie); + } + } else { + cookies.put(name, cookie); + } + return this; + } + + @Override + public Mutant header(final String name) { + requireNonNull(name, "A header's name is required."); + return new MutantImpl(parserExecutor, + new StrParamReferenceImpl("header", name, rsp.headers(name))); + } + + @Override + public Response header(final String name, final Object value) { + requireNonNull(name, "Header's name is required."); + requireNonNull(value, "Header's value is required."); + + return setHeader(name, value); + } + + @Override + public Response header(final String name, final Iterable values) { + requireNonNull(name, "Header's name is required."); + requireNonNull(values, "Header's values are required."); + + return setHeader(name, values); + } + + @Override + public Charset charset() { + return charset; + } + + @Override + public Response charset(final Charset charset) { + this.charset = requireNonNull(charset, "A charset is required."); + type(type().orElse(MediaType.html)); + return this; + } + + @Override + public Response length(final long length) { + len = length; + rsp.header("Content-Length", Long.toString(length)); + return this; + } + + @Override + public Optional type() { + return Optional.ofNullable(type); + } + + @Override + public Response type(final MediaType type) { + this.type = type; + if (type.isText()) { + setHeader("Content-Type", type.name() + ";charset=" + charset.name()); + } else { + setHeader("Content-Type", type.name()); + } + return this; + } + + @Override + public void redirect(final Status status, final String location) throws Throwable { + requireNonNull(status, "Status required."); + requireNonNull(location, "Location required."); + + send(Results.with(status).header(LOCATION, location)); + } + + @Override + public Optional status() { + return Optional.ofNullable(status); + } + + @Override + public Response status(final Status status) { + this.status = requireNonNull(status, "Status required."); + rsp.statusCode(status.value()); + failure = status.isError(); + return this; + } + + @Override + public boolean committed() { + return rsp.committed(); + } + + public void done(final Optional cause) { + if (complete.size() > 0) { + for (Route.Complete h : complete) { + Try.run(() -> h.handle(req, this, cause)) + .onFailure(x -> LoggerFactory.getLogger(Response.class) + .error("complete listener resulted in error", x)); + } + complete.clear(); + } + end(); + } + + @Override + public void end() { + if (!rsp.committed()) { + if (status == null) { + status(rsp.statusCode()); + } + + writeCookies(); + + /** + * Do we need to figure it out Content-Length? + */ + boolean lenSet = rsp.header("Content-Length").isPresent() + || rsp.header("Transfer-Encoding").isPresent(); + if (!lenSet) { + int statusCode = status.value(); + boolean hasBody = true; + if (statusCode >= 100 && statusCode < 200) { + hasBody = false; + } else if (statusCode == 204 || statusCode == 304) { + hasBody = false; + } + if (hasBody) { + rsp.header("Content-Length", "0"); + } + } + } + rsp.end(); + } + + @Override + public void send(final Result result) throws Throwable { + if (result instanceof Deferred) { + throw new DeferredExecution((Deferred) result); + } + + Result finalResult = result; + + if (!failure) { + // after filter + for (int i = after.size() - 1; i >= 0; i--) { + finalResult = after.get(i).handle(req, this, finalResult); + } + } + + Optional rtype = finalResult.type(); + if (rtype.isPresent()) { + type(rtype.get()); + } + + Optional finalStatus = finalResult.status(); + if (finalStatus.isPresent()) { + status(finalStatus.get()); + } else if (this.status == null) { + status(Status.OK); + } + + Map headers = finalResult.headers(); + if (headers.size() > 0) { + headers.forEach(this::setHeader); + } + + writeCookies(); + + if (Route.HEAD.equals(route.method())) { + end(); + return; + } + + /** + * Do we need to figure it out Content-Length? + */ + List produces = this.type == null ? route.produces() : ImmutableList.of(type); + Object value = finalResult.get(produces); + + if (value != null) { + Consumer setLen = len -> { + if (this.len == -1 && len >= 0) { + length(len); + } + }; + + Consumer setType = type -> { + if (this.type == null) { + type(type); + } + }; + + HttpRendererContext ctx = new HttpRendererContext( + renderers, + rsp, + setLen, + setType, + locals, + produces, + charset, + req.locale(), + byteRange); + + // explicit renderer? + Renderer renderer = rendererMap.get(route.renderer()); + if (renderer != null) { + renderer.render(value, ctx); + } else { + ctx.render(value); + } + } + // end response + end(); + } + + @Override + public void after(final After handler) { + after.add(handler); + } + + @Override + public void complete(final Complete handler) { + complete.add(handler); + } + + private void writeCookies() { + if (cookies.size() > 0) { + List setCookie = cookies.values().stream() + .map(Cookie::encode) + .collect(Collectors.toList()); + rsp.header("Set-Cookie", setCookie); + cookies.clear(); + } + } + + public void reset() { + if (resetHeadersOnError) { + status = null; + this.cookies.clear(); + rsp.reset(); + } + } + + void route(final Route route) { + this.route = route; + } + + private void contentDisposition(final String filename) throws IOException { + List headers = rsp.headers("Content-Disposition"); + if (headers.isEmpty()) { + String basename = filename; + int last = filename.lastIndexOf('/'); + if (last >= 0) { + basename = basename.substring(last + 1); + } + + String cs = charset.name(); + String ebasename = URLEncoder.encode(basename, cs).replaceAll("\\+", "%20"); + header("Content-Disposition", String.format(CONTENT_DISPOSITION, basename, cs, ebasename)); + } + } + + @SuppressWarnings("unchecked") + private Response setHeader(final String name, final Object value) { + if (!committed()) { + if (value instanceof Iterable) { + List values = StreamSupport.stream(((Iterable) value).spliterator(), false) + .map(Headers::encode) + .collect(Collectors.toList()); + rsp.header(name, values); + } else { + if (LOCATION.equalsIgnoreCase(name)) { + String location = value.toString(); + String cpath = req.contextPath(); + if ("back".equalsIgnoreCase(location)) { + location = referer.orElse(cpath + "/"); + } else if (location.startsWith("/") && !location.startsWith(cpath)) { + location = cpath + location; + } + rsp.header(LOCATION, location); + } else { + if ("Content-Type".equalsIgnoreCase(name)) { + // keep type reference + this.type = MediaType.valueOf(value.toString()); + } + rsp.header(name, Headers.encode(value)); + } + } + } + + return this; + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/RouteChain.java b/jooby/src/main/java/org/jooby/internal/RouteChain.java new file mode 100644 index 00000000..56636d3d --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/RouteChain.java @@ -0,0 +1,114 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Route; + +public class RouteChain implements Route.Chain { + + private Route[] routes; + + private String prefix; + + private int i = 0; + + private RequestImpl rreq; + + private ResponseImpl rrsp; + + private boolean hasAttrs; + + public RouteChain(final RequestImpl req, final ResponseImpl rsp, final Route[] routes) { + this.routes = routes; + this.rreq = req; + this.rrsp = rsp; + + // eager decision if we need to wrap a route to get all the attrs within the change. + this.hasAttrs = hasAttributes(routes); + } + + private boolean hasAttributes(final Route[] routes) { + for (int i = 0; i < routes.length; i++) { + if (routes[i].attributes().size() > 0) { + return true; + } + } + return false; + } + + @Override + public void next(final String prefix, final Request req, final Response rsp) throws Throwable { + if (rsp.committed()) { + return; + } + + if (prefix != null) { + this.prefix = prefix; + } + + Route route = next(this.prefix); + // set route + rreq.route(hasAttrs ? attrs(route, routes, i - 1) : route); + rrsp.route(route); + + get(route).handle(req, rsp, this); + } + + private Route next(final String prefix) { + Route route = routes[i++]; + if (prefix == null) { + return route; + } + while (!route.apply(prefix)) { + route = routes[i++]; + } + return route; + } + + @Override + public List routes() { + return Arrays.asList(routes).subList(i, routes.length - 1); + } + + private RouteWithFilter get(final Route next) { + return (RouteWithFilter) Route.Forwarding.unwrap(next); + } + + private static Route attrs(final Route route, final Route[] routes, final int i) { + Map attrs = new HashMap<>(16); + for (int t = i; t < routes.length; t++) { + routes[t].attributes().forEach((name, value) -> attrs.putIfAbsent(name, value)); + } + return new Route.Forwarding(route) { + @Override public T attr(String name) { + return (T) attrs.get(name); + } + + @Override + public Map attributes() { + return attrs; + } + }; + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/RouteImpl.java b/jooby/src/main/java/org/jooby/internal/RouteImpl.java new file mode 100644 index 00000000..ac65af3c --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/RouteImpl.java @@ -0,0 +1,165 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import org.jooby.Err; +import org.jooby.MediaType; +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Route; +import org.jooby.Status; +import org.jooby.internal.mvc.MvcHandler; + +import java.util.List; +import java.util.Map; + +public class RouteImpl implements RouteWithFilter { + + private Definition route; + + private String path; + + private Map vars; + + private Filter filter; + + private List produces; + + private String method; + + private Source source; + + public static RouteWithFilter notFound(final String method, final String path) { + return new FallbackRoute("404", method, path, MediaType.ALL, (req, rsp, chain) -> { + if (!rsp.status().isPresent()) { + throw new Err(Status.NOT_FOUND, req.path()); + } + }); + } + + public static RouteWithFilter fallback(final Filter filter, final String method, + final String path, final String name, final List produces) { + return new FallbackRoute(name, method, path, produces, filter); + } + + public RouteImpl(final Filter filter, final Definition route, final String method, + final String path, final List produces, final Map vars, + final Mapper mapper, final Source source) { + this.filter = filter; + if (mapper != null) { + if (filter instanceof Route.OneArgHandler) { + this.filter = new MappedHandler((req, rsp) -> ((Route.OneArgHandler) filter).handle(req), + mapper); + } else if (filter instanceof Route.ZeroArgHandler) { + this.filter = new MappedHandler((req, rsp) -> ((Route.ZeroArgHandler) filter).handle(), + mapper); + } else if (filter instanceof MvcHandler) { + if (((MvcHandler) filter).method().getReturnType() == void.class) { + this.filter = filter; + } else { + this.filter = new MappedHandler((req, rsp, chain) -> ((MvcHandler) filter).invoke(req, rsp, + chain), + mapper); + } + } else { + this.filter = filter; + } + } + this.route = route; + this.method = method; + this.produces = produces; + this.vars = vars; + this.source = source; + this.path = Route.unerrpath(path); + } + + @Override + public void handle(final Request request, final Response response, final Chain chain) + throws Throwable { + filter.handle(request, response, chain); + } + + @Override + public Map attributes() { + return route.attributes(); + } + + @Override + public String path() { + return path; + } + + @Override + public String method() { + return method; + } + + @Override + public String pattern() { + return route.pattern().substring(route.pattern().indexOf('/')); + } + + @Override + public String name() { + return route.name(); + } + + @Override + public Map vars() { + return vars; + } + + @Override + public List consumes() { + return route.consumes(); + } + + @Override + public List produces() { + return produces; + } + + @Override + public boolean glob() { + return route.glob(); + } + + @Override + public String reverse(final Map vars) { + return route.reverse(vars); + } + + @Override + public String reverse(final Object... values) { + return route.reverse(values); + } + + @Override + public Source source() { + return source; + } + + @Override + public String renderer() { + return route.renderer(); + } + + @Override + public String toString() { + return print(); + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/RouteMatcher.java b/jooby/src/main/java/org/jooby/internal/RouteMatcher.java new file mode 100644 index 00000000..663090d6 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/RouteMatcher.java @@ -0,0 +1,42 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import java.util.Collections; +import java.util.Map; + +public interface RouteMatcher { + + /** + * @return Current path under test. + */ + String path(); + + /** + * @return True, if {@link #path()} matches a path pattern. + */ + boolean matches(); + + /** + * Get path vars from current path. Or empty map if there is none. + * This method must be invoked after {@link #matches()}. + * + * @return Get path vars from current path. Or empty map if there is none. + */ + default Map vars() { + return Collections.emptyMap(); + } +} diff --git a/jooby/src/main/java/org/jooby/internal/RouteMetadata.java b/jooby/src/main/java/org/jooby/internal/RouteMetadata.java new file mode 100644 index 00000000..3ee0a058 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/RouteMetadata.java @@ -0,0 +1,188 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import java.io.InputStream; +import java.lang.reflect.Constructor; +import java.lang.reflect.Executable; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import org.jooby.Env; +import org.jooby.funzy.Try; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.Label; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; + +import com.google.common.base.Throwables; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import com.google.common.io.Closeables; +import com.google.common.io.Resources; +import com.google.common.util.concurrent.UncheckedExecutionException; + +public class RouteMetadata implements ParameterNameProvider { + + private static final String[] NO_ARG = new String[0]; + + private final LoadingCache, Map> cache; + + public RouteMetadata(final Env env) { + CacheLoader, Map> loader = CacheLoader + .from(RouteMetadata::extractMetadata); + + cache = env.name().equals("dev") + ? CacheBuilder.newBuilder().maximumSize(0).build(loader) + : CacheBuilder.newBuilder().build(loader); + } + + @Override + public String[] names(final Executable exec) { + Map md = md(exec); + String key = paramsKey(exec); + return (String[]) md.get(key); + } + + public int startAt(final Executable exec) { + Map md = md(exec); + return (Integer) md.getOrDefault(startAtKey(exec), -1); + } + + private Map md(final Executable exec) { + return Try.apply(() -> cache.getUnchecked(exec.getDeclaringClass())) + .unwrap(UncheckedExecutionException.class) + .get(); + } + + private static Map extractMetadata(final Class owner) { + InputStream stream = null; + try { + Map md = new HashMap<>(); + stream = Resources.getResource(owner, classfile(owner)).openStream(); + new ClassReader(stream).accept(visitor(md), 0); + return md; + } catch (Exception ex) { + // won't happen, but... + throw new IllegalStateException("Can't read class: " + owner.getName(), ex); + } finally { + Closeables.closeQuietly(stream); + } + } + + private static String classfile(final Class owner) { + StringBuilder sb = new StringBuilder(); + Class dc = owner.getDeclaringClass(); + while (dc != null) { + sb.insert(0, dc.getSimpleName()).append("$"); + dc = dc.getDeclaringClass(); + } + sb.append(owner.getSimpleName()); + sb.append(".class"); + return sb.toString(); + } + + private static ClassVisitor visitor(final Map md) { + return new ClassVisitor(Opcodes.ASM7) { + + @Override + public MethodVisitor visitMethod(final int access, final String name, + final String desc, final String signature, final String[] exceptions) { + boolean isPublic = ((access & Opcodes.ACC_PUBLIC) > 0) ? true : false; + boolean isStatic = ((access & Opcodes.ACC_STATIC) > 0) ? true : false; + if (!isPublic || isStatic) { + // ignore + return null; + } + final String seed = name + desc; + Type[] args = Type.getArgumentTypes(desc); + String[] names = args.length == 0 ? NO_ARG : new String[args.length]; + md.put(paramsKey(seed), names); + + int minIdx = ((access & Opcodes.ACC_STATIC) > 0) ? 0 : 1; + int maxIdx = Arrays.stream(args).mapToInt(Type::getSize).sum(); + + return new MethodVisitor(Opcodes.ASM7) { + + private int i = 0; + + private boolean skipLocalTable = false; + + @Override + public void visitParameter(final String name, final int access) { + skipLocalTable = true; + // save current parameter + names[i] = name; + // move to next + i += 1; + } + + @Override + public void visitLineNumber(final int line, final Label start) { + // save line number + md.putIfAbsent(startAtKey(seed), line); + } + + @Override + public void visitLocalVariable(final String name, final String desc, + final String signature, + final Label start, final Label end, final int index) { + if (!skipLocalTable) { + if (index >= minIdx && index <= maxIdx) { + // save current parameter + names[i] = name; + // move to next + i += 1; + } + } + } + + }; + } + + }; + } + + private static String paramsKey(final Executable exec) { + return paramsKey(key(exec)); + } + + private static String paramsKey(final String key) { + return key + ".params"; + } + + private static String startAtKey(final Executable exec) { + return startAtKey(key(exec)); + } + + private static String startAtKey(final String key) { + return key + ".startAt"; + } + + @SuppressWarnings("rawtypes") + private static String key(final Executable exec) { + if (exec instanceof Method) { + return exec.getName() + Type.getMethodDescriptor((Method) exec); + } else { + return "" + Type.getConstructorDescriptor((Constructor) exec); + } + } +} diff --git a/jooby/src/main/java/org/jooby/internal/RoutePattern.java b/jooby/src/main/java/org/jooby/internal/RoutePattern.java new file mode 100644 index 00000000..f5b9471d --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/RoutePattern.java @@ -0,0 +1,240 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static java.util.Objects.requireNonNull; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +public class RoutePattern { + + private static class Rewrite { + + private final Function fn; + private final List vars; + private final List reverse; + private final boolean glob; + + public Rewrite(final Function fn, final List vars, + final List reverse, final boolean glob) { + this.fn = fn; + this.vars = vars; + this.reverse = reverse; + this.glob = glob; + } + } + + private static final Pattern GLOB = Pattern + /** ?| ** | * | :var | {var(:.*)} */ + //.compile("\\?|/\\*\\*|\\*|\\:((?:[^/]+)+?) |\\{((?:\\{[^/]+?\\}|[^/{}]|\\\\[{}])+?)\\}"); + /** ? | **:name | * | :var | */ + .compile( + "\\?|/\\*\\*(\\:(?:[^/]+))?|\\*|\\:((?:[^/]+)+?)|\\{((?:\\{[^/]+?\\}|[^/{}]|\\\\[{}])+?)\\}"); + + private static final Pattern SLASH = Pattern.compile("//+"); + + private final Function matcher; + + private String pattern; + + private List vars; + + private List reverse; + + private boolean glob; + + public RoutePattern(final String verb, final String pattern) { + this(verb, pattern, false); + } + + public RoutePattern(final String verb, final String pattern, boolean ignoreCase) { + requireNonNull(verb, "A HTTP verb is required."); + requireNonNull(pattern, "A path pattern is required."); + this.pattern = normalize(pattern); + Rewrite rewrite = rewrite(this, verb.toUpperCase(), this.pattern.replace("/**/", "/**"), + ignoreCase); + matcher = rewrite.fn; + vars = rewrite.vars; + reverse = rewrite.reverse; + glob = rewrite.glob; + } + + public boolean glob() { + return glob; + } + + public List vars() { + return vars; + } + + public String pattern() { + return pattern; + } + + public String reverse(final Map vars) { + return reverse.stream() + .map(segment -> vars.getOrDefault(segment, segment).toString()) + .collect(Collectors.joining("")); + } + + public String reverse(final Object... value) { + List vars = vars(); + Map hash = new HashMap<>(); + for (int i = 0; i < Math.min(vars.size(), value.length); i++) { + hash.put(vars.get(i), value[i]); + } + return reverse(hash); + } + + public RouteMatcher matcher(final String path) { + requireNonNull(path, "A path is required."); + return matcher.apply(path); + } + + private static Rewrite rewrite(final RoutePattern owner, final String verb, final String pattern, + boolean ignoreCase) { + List vars = new LinkedList<>(); + String rwrverb = verbs(verb); + StringBuilder patternBuilder = new StringBuilder(rwrverb); + Matcher matcher = GLOB.matcher(pattern); + int end = 0; + boolean regex = !rwrverb.equals(verb); + List reverse = new ArrayList<>(); + boolean glob = false; + while (matcher.find()) { + String head = pattern.substring(end, matcher.start()); + patternBuilder.append(Pattern.quote(head)); + reverse.add(head); + String match = matcher.group(); + if ("?".equals(match)) { + patternBuilder.append("([^/])"); + reverse.add(match); + regex = true; + glob = true; + } else if ("*".equals(match)) { + patternBuilder.append("([^/]*)"); + reverse.add(match); + regex = true; + glob = true; + } else if (match.equals("/**")) { + reverse.add(match); + patternBuilder.append("($|/.*)"); + regex = true; + glob = true; + } else if (match.startsWith("/**:")) { + reverse.add(match.substring(1)); + String varName = match.substring(4); + patternBuilder.append("/(?($|.*))"); + vars.add(varName); + regex = true; + glob = true; + } else if (match.startsWith(":")) { + regex = true; + String varName = match.substring(1); + patternBuilder.append("(?[^/]+)"); + vars.add(varName); + reverse.add(varName); + } else if (match.startsWith("{") && match.endsWith("}")) { + regex = true; + int colonIdx = match.indexOf(':'); + if (colonIdx == -1) { + String varName = match.substring(1, match.length() - 1); + patternBuilder.append("(?[^/]+)"); + vars.add(varName); + reverse.add(varName); + } else { + String varName = match.substring(1, colonIdx); + String regexpr = match.substring(colonIdx + 1, match.length() - 1); + patternBuilder.append("(?"); + patternBuilder.append("**".equals(regexpr) ? "($|.*)" : regexpr); + patternBuilder.append(')'); + vars.add(varName); + reverse.add(varName); + } + } + end = matcher.end(); + } + String tail = pattern.substring(end, pattern.length()); + reverse.add(tail); + patternBuilder.append(Pattern.quote(tail)); + return new Rewrite(fn(owner, regex, regex ? patternBuilder.toString() : verb + pattern, vars, + ignoreCase), vars, reverse, glob); + } + + private static String verbs(final String verb) { + String[] verbs = verb.split("\\|"); + if (verbs.length == 1) { + return verb.equals("*") ? "(?:[^/]*)" : verb; + } + return "(?:" + verb + ")"; + } + + private static Function fn(final RoutePattern owner, final boolean complex, + final String pattern, final List vars, boolean ignoreCase) { + return new Function() { + final Pattern regex = complex + ? Pattern.compile(pattern, ignoreCase ? Pattern.CASE_INSENSITIVE : 0) + : null; + + @Override + public RouteMatcher apply(final String fullpath) { + String path = fullpath.substring(Math.max(0, fullpath.indexOf('/'))); + if (complex) { + return new RegexRouteMatcher(path, regex.matcher(fullpath), vars); + } + return ignoreCase + ? new SimpleRouteMatcherNoCase(pattern, path, fullpath) + : new SimpleRouteMatcher(pattern, path, fullpath); + } + }; + } + + public static String normalize(final String pattern) { + if (pattern.equals("*")) { + return "/**"; + } + if (pattern.equals("/")) { + return "/"; + } + String normalized = SLASH.matcher(pattern).replaceAll("/"); + if (normalized.equals("/")) { + return "/"; + } + StringBuilder buffer = new StringBuilder(); + if (!normalized.startsWith("/")) { + buffer.append("/"); + } + buffer.append(normalized); + if (normalized.endsWith("/")) { + buffer.setLength(buffer.length() - 1); + } + return buffer.toString(); + } + + @Override + public String toString() { + return pattern; + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/RouteSourceImpl.java b/jooby/src/main/java/org/jooby/internal/RouteSourceImpl.java new file mode 100644 index 00000000..3b0f37a0 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/RouteSourceImpl.java @@ -0,0 +1,47 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import java.util.Optional; + +import org.jooby.Route; + +public class RouteSourceImpl implements Route.Source { + + private Optional declaringClass; + + private int line; + + public RouteSourceImpl(final String declaringClass, final int line) { + this.declaringClass = Optional.ofNullable(declaringClass); + this.line = line; + } + + @Override + public int line() { + return line; + } + + @Override + public Optional declaringClass() { + return declaringClass; + } + + @Override + public String toString() { + return declaringClass.orElse("~unknown") + ":" + line; + } +} diff --git a/jooby/src/main/java/org/jooby/internal/RouteWithFilter.java b/jooby/src/main/java/org/jooby/internal/RouteWithFilter.java new file mode 100644 index 00000000..1ced5828 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/RouteWithFilter.java @@ -0,0 +1,21 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import org.jooby.Route; + +public interface RouteWithFilter extends Route, Route.Filter { +} diff --git a/jooby/src/main/java/org/jooby/internal/ServerExecutorProvider.java b/jooby/src/main/java/org/jooby/internal/ServerExecutorProvider.java new file mode 100644 index 00000000..1592d231 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/ServerExecutorProvider.java @@ -0,0 +1,52 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import com.google.common.util.concurrent.MoreExecutors; +import com.google.inject.Inject; +import com.google.inject.Provider; +import org.jooby.spi.Server; + +import java.util.concurrent.Executor; + +import static java.util.Objects.requireNonNull; + +public class ServerExecutorProvider implements Provider +{ + + private Executor executor; + + @Inject + public ServerExecutorProvider(final ServerHolder serverHolder) { + requireNonNull(serverHolder, "Server holder is required."); + + executor = (serverHolder.server != null) ? + serverHolder.server.executor().orElse(MoreExecutors.directExecutor()) : + MoreExecutors.directExecutor(); + } + + @Override + public Executor get() { + return executor; + } + + static class ServerHolder { + + @Inject(optional = true) Server server = null; + + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/ServerLookup.java b/jooby/src/main/java/org/jooby/internal/ServerLookup.java new file mode 100644 index 00000000..89c643f5 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/ServerLookup.java @@ -0,0 +1,46 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import org.jooby.Env; +import org.jooby.Jooby; +import org.jooby.Jooby.Module; +import org.jooby.spi.Server; + +import com.google.inject.Binder; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; + +public class ServerLookup implements Module { + + private Jooby.Module delegate = null; + + @Override + public void configure(final Env env, final Config config, final Binder binder) throws Throwable { + if (config.hasPath("server.module")) { + delegate = (Jooby.Module) getClass().getClassLoader() + .loadClass(config.getString("server.module")).newInstance(); + delegate.configure(env, config, binder); + } + } + + @Override + public Config config() { + return ConfigFactory.parseResources(Server.class, "server.conf") + .withFallback(ConfigFactory.parseResources(Server.class, "server-defaults.conf")); + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/ServerSessionManager.java b/jooby/src/main/java/org/jooby/internal/ServerSessionManager.java new file mode 100644 index 00000000..4b39fc15 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/ServerSessionManager.java @@ -0,0 +1,169 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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. + */ +/** + * This copy of Woodstox XML processor is licensed under the + * Apache (Software) License, version 2.0 ("the License"). + * See the License for details about distribution rights, and the + * specific rights regarding derivate works. + * + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/ + * + * A copy is also included in the downloadable source code package + * containing Woodstox, in file "ASL2.0", under the same directory + * as this file. + */ +package org.jooby.internal; + +import com.typesafe.config.Config; +import org.jooby.Cookie; +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Session; +import org.jooby.internal.parser.ParserExecutor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.inject.Inject; +import javax.inject.Singleton; +import java.util.concurrent.TimeUnit; + +@Singleton +public class ServerSessionManager implements SessionManager { + + /** The logging system. */ + private final Logger log = LoggerFactory.getLogger(SessionManager.class); + + private final Session.Store store; + + private final Cookie.Definition template; + + private final String secret; + + private final long saveInterval; + + private final ParserExecutor resolver; + + private final long timeout; + + @Inject + public ServerSessionManager(final Config config, final Session.Definition def, + final Session.Store store, final ParserExecutor resolver) { + this.store = store; + this.resolver = resolver; + this.secret = config.hasPath("application.secret") + ? config.getString("application.secret") + : null; + this.template = def.cookie(); + this.saveInterval = def.saveInterval().get(); + this.timeout = Math.max(-1, TimeUnit.SECONDS.toMillis(template.maxAge().get())); + } + + @Override + public Session create(final Request req, final Response rsp) { + Session session = new SessionImpl.Builder(resolver, true, store.generateID(), timeout) + .build(); + log.debug("session created: {}", session); + Cookie.Definition cookie = cookie(session); + log.debug(" new cookie: {}", cookie); + rsp.cookie(cookie); + return session; + } + + @Override + public Session get(final Request req, final Response rsp) { + return req.cookie(template.name().get()).toOptional() + .map(cookie -> { + String sessionId = unsign(cookie); + log.debug("loading session: {}", sessionId); + Session session = store.get( + new SessionImpl.Builder(resolver, false, sessionId, timeout)); + if (timeout > 0 && session != null) { + Cookie.Definition setCookie = cookie(session); + log.debug(" touch cookie: {}", setCookie); + rsp.cookie(setCookie); + } + return session; + }).orElse(null); + } + + @Override + public void destroy(final Session session) { + String sid = session.id(); + log.debug(" deleting: {}", sid); + store.delete(sid); + } + + @Override + public void requestDone(final Session session) { + try { + createOrUpdate((SessionImpl) ((RequestScopedSession) session).session()); + } catch (Exception ex) { + log.error("Unable to create/update HTTP session", ex); + } + } + + @Override public void renewId(Session session, Response rsp) { + destroy(session); + + ((SessionImpl) session).renewId(store.generateID()); + Cookie.Definition cookie = cookie(session); + log.debug(" renewing cookie: {}", cookie); + rsp.cookie(cookie); + } + + @Override + public Cookie.Definition cookie() { + return new Cookie.Definition(template); + } + + private void createOrUpdate(final SessionImpl session) { + session.touch(); + if (session.isNew()) { + session.aboutToSave(); + store.create(session); + } else if (session.isDirty()) { + session.aboutToSave(); + store.save(session); + } else { + long now = System.currentTimeMillis(); + long interval = now - session.savedAt(); + if (interval >= saveInterval) { + session.aboutToSave(); + store.save(session); + } + } + session.markAsSaved(); + } + + private String sign(final String sessionId) { + return secret == null ? sessionId : Cookie.Signature.sign(sessionId, secret); + } + + private String unsign(final String sessionId) { + if (secret == null) { + return sessionId; + } + return Cookie.Signature.unsign(sessionId, secret); + } + + private Cookie.Definition cookie(final Session session) { + // set cookie + return new Cookie.Definition(this.template).value(sign(session.id())); + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/SessionImpl.java b/jooby/src/main/java/org/jooby/internal/SessionImpl.java new file mode 100644 index 00000000..5dbb6401 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/SessionImpl.java @@ -0,0 +1,247 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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. + */ +/** + * This copy of Woodstox XML processor is licensed under the + * Apache (Software) License, version 2.0 ("the License"). + * See the License for details about distribution rights, and the + * specific rights regarding derivate works. + * + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/ + * + * A copy is also included in the downloadable source code package + * containing Woodstox, in file "ASL2.0", under the same directory + * as this file. + */ +package org.jooby.internal; + +import com.google.common.collect.ImmutableList; +import static java.util.Objects.requireNonNull; +import org.jooby.Mutant; +import org.jooby.Session; +import org.jooby.internal.parser.ParserExecutor; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +public class SessionImpl implements Session { + + static class Builder implements Session.Builder { + + private SessionImpl session; + + public Builder(final ParserExecutor resolver, final boolean isNew, final String sessionId, + final long timeout) { + this.session = new SessionImpl(resolver, isNew, sessionId, timeout); + } + + @Override + public String sessionId() { + return session.sessionId; + } + + @Override + public org.jooby.Session.Builder set(final String name, final String value) { + session.attributes.put(name, value); + return this; + } + + @Override + public Session.Builder set(final Map attributes) { + session.attributes.putAll(attributes); + return this; + } + + @Override + public Session.Builder createdAt(final long createdAt) { + session.createdAt = createdAt; + return this; + } + + @Override + public Session.Builder accessedAt(final long accessedAt) { + session.accessedAt = accessedAt; + return this; + } + + @Override + public Session.Builder savedAt(final long savedAt) { + session.savedAt = savedAt; + return this; + } + + @Override + public Session build() { + requireNonNull(session.sessionId, "Session's id wasn't set."); + return session; + } + + } + + private ConcurrentMap attributes = new ConcurrentHashMap<>(); + + private String sessionId; + + private long createdAt; + + private volatile long accessedAt; + + private volatile long timeout; + + private volatile boolean isNew; + + private volatile boolean dirty; + + private volatile long savedAt; + + private volatile boolean destroyed; + + private ParserExecutor resolver; + + public SessionImpl(final ParserExecutor resolver, final boolean isNew, final String sessionId, + final long timeout) { + this.resolver = resolver; + this.isNew = isNew; + this.sessionId = sessionId; + long now = COOKIE_SESSION.equals(sessionId) ? -1 : System.currentTimeMillis(); + this.createdAt = now; + this.accessedAt = now; + this.savedAt = -1; + this.timeout = timeout; + } + + @Override + public String id() { + return sessionId; + } + + @Override + public long createdAt() { + return createdAt; + } + + @Override + public long accessedAt() { + return accessedAt; + } + + @Override + public long expiryAt() { + if (timeout <= 0) { + return -1; + } + return accessedAt + timeout; + } + + @Override + public Mutant get(final String name) { + String value = attributes.get(name); + List values = value == null ? Collections.emptyList() : ImmutableList.of(value); + return new MutantImpl(resolver, new StrParamReferenceImpl("session attribute", name, values)); + } + + @Override + public boolean isSet(final String name) { + return attributes.containsKey(name); + } + + @Override + public Map attributes() { + return Collections.unmodifiableMap(attributes); + } + + @Override + public Session set(final String name, final String value) { + requireNonNull(name, "An attribute name is required."); + requireNonNull(value, "An attribute value is required."); + String existing = attributes.put(name, value); + dirty = existing == null || !existing.equals(value); + return this; + } + + @Override + public Mutant unset(final String name) { + String value = attributes.remove(name); + List values = Collections.emptyList(); + if (value != null) { + values = ImmutableList.of(value); + dirty = true; + } + return new MutantImpl(resolver, new StrParamReferenceImpl("session attribute", name, values)); + } + + @Override + public Session unset() { + attributes.clear(); + dirty = true; + return this; + } + + @Override + public void destroy() { + destroyed = true; + unset(); + } + + @Override public boolean isDestroyed() { + return destroyed; + } + + public boolean isNew() { + return isNew; + } + + public boolean isDirty() { + return dirty; + } + + @Override + public long savedAt() { + return savedAt; + } + + void markAsSaved() { + isNew = false; + dirty = false; + } + + @Override public Session renewId() { + // NOOP + return this; + } + + public void renewId(String newId) { + this.sessionId = newId; + isNew = true; + } + + public void touch() { + this.accessedAt = System.currentTimeMillis(); + } + + @Override + public String toString() { + return sessionId; + } + + public void aboutToSave() { + savedAt = System.currentTimeMillis(); + } +} diff --git a/jooby/src/main/java/org/jooby/internal/SessionManager.java b/jooby/src/main/java/org/jooby/internal/SessionManager.java new file mode 100644 index 00000000..5e37f142 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/SessionManager.java @@ -0,0 +1,37 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import org.jooby.Cookie; +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Session; + +public interface SessionManager { + + Session create(Request req, Response rsp); + + Session get(Request req, Response rsp); + + void destroy(Session session); + + void requestDone(Session session); + + Cookie.Definition cookie(); + + void renewId(Session session, Response rsp); + +} diff --git a/jooby/src/main/java/org/jooby/internal/SimpleRouteMatcher.java b/jooby/src/main/java/org/jooby/internal/SimpleRouteMatcher.java new file mode 100644 index 00000000..64e0ceea --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/SimpleRouteMatcher.java @@ -0,0 +1,44 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static java.util.Objects.requireNonNull; + +class SimpleRouteMatcher implements RouteMatcher { + + protected final String fullpath; + + private final String path; + + protected String pattern; + + public SimpleRouteMatcher(final String pattern, final String path, final String fullpath) { + this.pattern = requireNonNull(pattern, "A pattern is required."); + this.path = requireNonNull(path, "A path is required."); + this.fullpath = requireNonNull(fullpath, "A full path is required."); + } + + @Override + public String path() { + return path; + } + + @Override + public boolean matches() { + return fullpath.equals(pattern); + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/SimpleRouteMatcherNoCase.java b/jooby/src/main/java/org/jooby/internal/SimpleRouteMatcherNoCase.java new file mode 100644 index 00000000..64ce4233 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/SimpleRouteMatcherNoCase.java @@ -0,0 +1,29 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +class SimpleRouteMatcherNoCase extends SimpleRouteMatcher { + + public SimpleRouteMatcherNoCase(String pattern, String path, String fullpath) { + super(pattern, path, fullpath); + } + + @Override + public boolean matches() { + return fullpath.equalsIgnoreCase(pattern); + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/SourceProvider.java b/jooby/src/main/java/org/jooby/internal/SourceProvider.java new file mode 100644 index 00000000..3eb5d887 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/SourceProvider.java @@ -0,0 +1,61 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import java.util.Optional; +import java.util.function.Predicate; + +public class SourceProvider { + private static final Predicate JOOBY_PKG = pkg("org.jooby"); + private static final Predicate JAVALANG_PKG = pkg("javaslang"); + private static final Predicate GOOGLE_PKG = pkg("com.google"); + private static final Predicate SUN_PKG = pkg("sun.").or(pkg("com.sun")); + private static final Predicate JAVA_PKG = pkg("java."); + private static final Predicate SKIP = JOOBY_PKG.or(GOOGLE_PKG).or(JAVALANG_PKG) + .or(SUN_PKG).or(JAVA_PKG); + + public static final SourceProvider INSTANCE = new SourceProvider(SKIP); + + private final Predicate skip; + + private SourceProvider(Predicate skip) { + this.skip = skip; + } + + public Optional get() { + return get(new Throwable().getStackTrace()); + } + + public Optional get(StackTraceElement[] elements) { + for (StackTraceElement element : elements) { + String className = element.getClassName(); + + if (!skip.test(className)) { + int innerStart = className.indexOf('$'); + if (innerStart > 0) { + return Optional.of(new StackTraceElement(className.substring(0, innerStart), + element.getMethodName(), element.getFileName(), element.getLineNumber())); + } + return Optional.of(element); + } + } + return Optional.empty(); + } + + private static Predicate pkg(String pkg) { + return classname -> classname.startsWith(pkg); + } +} diff --git a/jooby/src/main/java/org/jooby/internal/SseRenderer.java b/jooby/src/main/java/org/jooby/internal/SseRenderer.java new file mode 100644 index 00000000..1ee18acf --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/SseRenderer.java @@ -0,0 +1,150 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import com.google.common.io.ByteSource; +import org.jooby.MediaType; +import org.jooby.Renderer; +import org.jooby.Sse; + +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + +public class SseRenderer extends AbstractRendererContext { + + static final ByteSource ID = bytes("id:"); + static final ByteSource EVENT = bytes("event:"); + static final ByteSource RETRY = bytes("retry:"); + static final ByteSource DATA = bytes("data:"); + static final ByteSource COMMENT = bytes(":"); + static final byte nl = '\n'; + static final ByteSource NL = bytes("\n"); + + private ByteSource data; + + public SseRenderer(final List renderers, final List produces, + final Charset charset, Locale locale, final Map locals) { + super(renderers, produces, charset, locale, locals); + } + + public byte[] format(final Sse.Event event) throws Exception { + // comment? + data = event.comment() + .map(comment -> ByteSource.concat(COMMENT, bytes(comment), NL)) + .orElse(ByteSource.empty()); + + // id? + data = event.id() + .map(id -> ByteSource.concat(data, ID, bytes(id.toString()), NL)) + .orElse(data); + + // event? + data = event.name() + .map(name -> ByteSource.concat(data, EVENT, bytes(name), NL)) + .orElse(data); + + // retry? + data = event.retry() + .map(retry -> ByteSource.concat(data, RETRY, bytes(Long.toString(retry)), NL)) + .orElse(data); + + Optional value = event.data(); + if (value.isPresent()) { + render(value.get()); + } + + data = ByteSource.concat(data, NL); + + byte[] bytes = data.read(); + data = null; + return bytes; + } + + @Override + protected void _send(final byte[] bytes) throws Exception { + List lines = split(bytes); + if (lines.size() == 1) { + data = ByteSource.concat(data, DATA, ByteSource.wrap(bytes), NL); + } else { + for (Integer[] line : lines) { + data = ByteSource.concat(data, DATA, ByteSource.wrap(bytes) + .slice(line[0], line[1] - line[0]), NL); + } + } + } + + @Override + protected void _send(final ByteBuffer buffer) throws Exception { + byte[] bytes; + if (buffer.hasArray()) { + _send(buffer.array()); + } else { + bytes = new byte[buffer.remaining()]; + buffer.get(bytes); + _send(bytes); + } + } + + @Override + protected void _send(final FileChannel file) throws Exception { + throw new UnsupportedOperationException(); + } + + @Override + protected void _send(final InputStream stream) throws Exception { + throw new UnsupportedOperationException(); + } + + private static ByteSource bytes(final String value) { + return ByteSource.wrap(value.getBytes(StandardCharsets.UTF_8)); + } + + private static List split(final byte[] bytes) { + List range = new ArrayList<>(); + + Function nextLine = start -> { + for (int i = start; i < bytes.length; i++) { + if (bytes[i] == nl) { + return i; + } + } + return bytes.length; + }; + + int from = 0; + int to = nextLine.apply(from); + int len = bytes.length; + range.add(new Integer[]{from, to}); + while (to != len) { + from = to + 1; + to = nextLine.apply(from); + if (to > from) { + range.add(new Integer[]{from, to}); + } + } + return range; + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/StaticMethodTypeConverter.java b/jooby/src/main/java/org/jooby/internal/StaticMethodTypeConverter.java new file mode 100644 index 00000000..94ceab79 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/StaticMethodTypeConverter.java @@ -0,0 +1,60 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import org.jooby.internal.parser.StaticMethodParser; + +import com.google.common.primitives.Primitives; +import com.google.inject.TypeLiteral; +import com.google.inject.matcher.AbstractMatcher; +import com.google.inject.spi.TypeConverter; + +class StaticMethodTypeConverter extends AbstractMatcher> + implements TypeConverter { + + private StaticMethodParser converter; + + public StaticMethodTypeConverter(final String name) { + converter = new StaticMethodParser(name); + } + + @Override + public Object convert(final String value, final TypeLiteral type) { + try { + return converter.parse(type, value); + } catch (Exception ex) { + throw new IllegalStateException("Can't convert: " + value + " to " + type, ex); + } + } + + @Override + public boolean matches(final TypeLiteral type) { + Class rawType = type.getRawType(); + if (rawType == Class.class) { + return false; + } + if (Primitives.isWrapperType(rawType)) { + return false; + } + return !Enum.class.isAssignableFrom(rawType) && converter.matches(type); + } + + @Override + public String toString() { + return converter.toString(); + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/StatusCodeProvider.java b/jooby/src/main/java/org/jooby/internal/StatusCodeProvider.java new file mode 100644 index 00000000..9bc2f074 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/StatusCodeProvider.java @@ -0,0 +1,64 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import java.util.Optional; +import java.util.function.Function; + +import javax.inject.Inject; + +import org.jooby.Err; +import org.jooby.Status; + +import com.typesafe.config.Config; + +public class StatusCodeProvider { + + private Config conf; + + @Inject + public StatusCodeProvider(final Config conf) { + this.conf = conf; + } + + public Status apply(final Throwable cause) { + if (cause instanceof Err) { + return Status.valueOf(((Err) cause).statusCode()); + } + /** + * usually a class name, except for inner classes where '$' is replaced it by '.' + */ + Function, String> name = type -> Optional.ofNullable(type.getDeclaringClass()) + .map(dc -> new StringBuilder(dc.getName()) + .append('.') + .append(type.getSimpleName()) + .toString()) + .orElse(type.getName()); + + Config err = conf.getConfig("err"); + int status = -1; + Class type = cause.getClass(); + while (type != Throwable.class && status == -1) { + String classname = name.apply(type); + if (err.hasPath(classname)) { + status = err.getInt(classname); + } else { + type = type.getSuperclass(); + } + } + return status == -1 ? Status.SERVER_ERROR : Status.valueOf(status); + } +} diff --git a/jooby/src/main/java/org/jooby/internal/StrParamReferenceImpl.java b/jooby/src/main/java/org/jooby/internal/StrParamReferenceImpl.java new file mode 100644 index 00000000..3b2b70a3 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/StrParamReferenceImpl.java @@ -0,0 +1,26 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import java.util.List; + +public class StrParamReferenceImpl extends ParamReferenceImpl { + + public StrParamReferenceImpl(final String type, final String name, final List values) { + super(type, name, values); + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/StringConstructTypeConverter.java b/jooby/src/main/java/org/jooby/internal/StringConstructTypeConverter.java new file mode 100644 index 00000000..9278991e --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/StringConstructTypeConverter.java @@ -0,0 +1,57 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import java.util.Locale; + +import org.jooby.internal.parser.StringConstructorParser; + +import com.google.common.primitives.Primitives; +import com.google.inject.TypeLiteral; +import com.google.inject.matcher.AbstractMatcher; +import com.google.inject.spi.TypeConverter; + +class StringConstructTypeConverter extends AbstractMatcher> + implements TypeConverter { + + @Override + public Object convert(final String value, final TypeLiteral type) { + Class rawType = type.getRawType(); + try { + if (rawType == Locale.class) { + return LocaleUtils.parseOne(value); + } + return StringConstructorParser.parse(type, value); + } catch (Exception ex) { + throw new IllegalStateException("Can't convert: " + value + " to " + type, ex); + } + } + + @Override + public boolean matches(final TypeLiteral type) { + Class rawType = type.getRawType(); + if (Primitives.isWrapperType(rawType)) { + return false; + } + return new StringConstructorParser().matches(type); + } + + @Override + public String toString() { + return "TypeConverter init(java.lang.String)"; + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/TypeConverters.java b/jooby/src/main/java/org/jooby/internal/TypeConverters.java new file mode 100644 index 00000000..8fa13614 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/TypeConverters.java @@ -0,0 +1,37 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import com.google.inject.Binder; + +public class TypeConverters { + + @SuppressWarnings({"unchecked", "rawtypes" }) + public void configure(final Binder binder) { + StaticMethodTypeConverter valueOf = new StaticMethodTypeConverter("valueOf"); + binder.convertToTypes(valueOf, valueOf); + + StaticMethodTypeConverter fromString = new StaticMethodTypeConverter("fromString"); + binder.convertToTypes(fromString, fromString); + + StaticMethodTypeConverter forName = new StaticMethodTypeConverter("forName"); + binder.convertToTypes(forName, forName); + + StringConstructTypeConverter stringConstruct = new StringConstructTypeConverter(); + binder.convertToTypes(stringConstruct, stringConstruct); + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/URLAsset.java b/jooby/src/main/java/org/jooby/internal/URLAsset.java new file mode 100644 index 00000000..5bf057aa --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/URLAsset.java @@ -0,0 +1,135 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static java.util.Objects.requireNonNull; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLConnection; +import java.util.function.BiConsumer; + +import org.jooby.Asset; +import org.jooby.MediaType; + +import com.google.common.io.Closeables; +import org.jooby.funzy.Try; + +public class URLAsset implements Asset { + + interface Supplier { + InputStream get() throws IOException; + } + + private URL url; + + private MediaType mediaType; + + private long lastModified = -1; + + private long length = -1; + + private Supplier stream; + + private String path; + + private boolean exists; + + public URLAsset(final URL url, final String path, final MediaType mediaType) throws Exception { + this.url = requireNonNull(url, "An url is required."); + this.path = requireNonNull(path, "Path is required."); + this.mediaType = requireNonNull(mediaType, "A mediaType is required."); + this.stream = attr(url, (len, lstMod) -> { + this.length = len(len); + this.lastModified = lmod(lstMod); + }); + this.exists = this.stream != null; + } + + @Override + public String path() { + return path; + } + + @Override + public URL resource() { + return url; + } + + @Override + public long length() { + return length; + } + + @Override + public InputStream stream() throws Exception { + return stream.get(); + } + + @Override + public long lastModified() { + return lastModified; + } + + @Override + public MediaType type() { + return mediaType; + } + + @Override + public String toString() { + return path() + "(" + type() + ")"; + } + + private static Supplier attr(final URL resource, final BiConsumer attrs) + throws Exception { + if ("file".equals(resource.getProtocol())) { + File file = new File(resource.toURI()); + if (file.exists() && file.isFile()) { + attrs.accept(file.length(), file.lastModified()); + return () -> new FileInputStream(file); + } + return null; + } else { + URLConnection cnn = resource.openConnection(); + cnn.setUseCaches(false); + attrs.accept(cnn.getContentLengthLong(), cnn.getLastModified()); + try { + Closeables.closeQuietly(cnn.getInputStream()); + } catch (NullPointerException ex) { + // dir entries throw NPE :S + return null; + } + return () -> resource.openStream(); + } + } + + private static long len(final long value) { + return value < 0 ? -1 : value; + } + + private static long lmod(final long value) { + return value > 0 ? value : -1; + } + + public boolean exists() { + return exists; + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/UploadImpl.java b/jooby/src/main/java/org/jooby/internal/UploadImpl.java new file mode 100644 index 00000000..61a0bcaf --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/UploadImpl.java @@ -0,0 +1,74 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static java.util.Objects.requireNonNull; + +import java.io.File; +import java.io.IOException; + +import org.jooby.MediaType; +import org.jooby.Mutant; +import org.jooby.Upload; +import org.jooby.internal.parser.ParserExecutor; +import org.jooby.spi.NativeUpload; + +import com.google.inject.Injector; + +public class UploadImpl implements Upload { + + private Injector injector; + + private NativeUpload upload; + + public UploadImpl(final Injector injector, final NativeUpload upload) { + this.injector = requireNonNull(injector, "An injector is required."); + this.upload = requireNonNull(upload, "An upload is required."); + } + + @Override + public void close() throws IOException { + upload.close(); + } + + @Override + public String name() { + return upload.name(); + } + + @Override + public MediaType type() { + return header("Content-Type").toOptional(MediaType.class) + .orElseGet(() -> MediaType.byPath(name()).orElse(MediaType.octetstream)); + } + + @Override + public Mutant header(final String name) { + return new MutantImpl(injector.getInstance(ParserExecutor.class), + new StrParamReferenceImpl("header", name, upload.headers(name))); + } + + @Override + public File file() throws IOException { + return upload.file(); + } + + @Override + public String toString() { + return name(); + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/WebSocketImpl.java b/jooby/src/main/java/org/jooby/internal/WebSocketImpl.java new file mode 100644 index 00000000..f4ad12f0 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/WebSocketImpl.java @@ -0,0 +1,380 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import com.google.common.collect.ImmutableList; +import com.google.inject.Injector; +import com.google.inject.Key; + +import static java.util.Objects.requireNonNull; + +import org.jooby.Err; +import org.jooby.MediaType; +import org.jooby.Mutant; +import org.jooby.Renderer; +import org.jooby.Request; +import org.jooby.WebSocket; +import org.jooby.funzy.Throwing; +import org.jooby.funzy.Try; +import org.jooby.internal.parser.ParserExecutor; +import org.jooby.spi.NativeWebSocket; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nullable; +import java.io.EOFException; +import java.nio.channels.ClosedChannelException; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Predicate; + +@SuppressWarnings("unchecked") +public class WebSocketImpl implements WebSocket { + + @SuppressWarnings({"rawtypes"}) + private static final OnMessage NOOP = arg -> { + }; + + private static final OnClose CLOSE_NOOP = arg -> { + }; + + private static final Predicate RESET_BY_PEER = ConnectionResetByPeer::test; + + private static final Predicate SILENT = RESET_BY_PEER + .or(ClosedChannelException.class::isInstance) + .or(EOFException.class::isInstance); + + /** The logging system. */ + private final Logger log = LoggerFactory.getLogger(WebSocket.class); + + /** All connected websocket. */ + private static final ConcurrentMap> sessions = new ConcurrentHashMap<>(); + + private Locale locale; + + private String path; + + private String pattern; + + private Map vars; + + private MediaType consumes; + + private MediaType produces; + + private OnOpen handler; + + private OnMessage messageCallback = NOOP; + + private OnClose closeCallback = CLOSE_NOOP; + + private OnError exceptionCallback = cause -> { + log.error("execution of WS" + path() + " resulted in exception", cause); + }; + + private NativeWebSocket ws; + + private Injector injector; + + private boolean suspended; + + private List renderers; + + private volatile boolean open; + + private ConcurrentMap attributes = new ConcurrentHashMap<>(); + + public WebSocketImpl(final OnOpen handler, final String path, + final String pattern, final Map vars, + final MediaType consumes, final MediaType produces) { + this.handler = handler; + this.path = path; + this.pattern = pattern; + this.vars = vars; + this.consumes = consumes; + this.produces = produces; + } + + @Override + public void close(final CloseStatus status) { + removeSession(this); + synchronized (this) { + open = false; + if (ws != null) { + ws.close(status.code(), status.reason()); + } + } + } + + @Override + public void resume() { + addSession(this); + synchronized (this) { + if (suspended) { + ws.resume(); + suspended = false; + } + } + } + + @Override + public void pause() { + removeSession(this); + synchronized (this) { + if (!suspended) { + ws.pause(); + suspended = true; + } + } + } + + @Override + public void terminate() throws Exception { + removeSession(this); + synchronized (this) { + open = false; + ws.terminate(); + } + } + + @Override + public boolean isOpen() { + return open && ws.isOpen(); + } + + @Override + public void broadcast(final Object data, final SuccessCallback success, final OnError err) + throws Exception { + for (WebSocket ws : sessions.getOrDefault(this.pattern, Collections.emptyList())) { + try { + ws.send(data, success, err); + } catch (Exception ex) { + err.onError(ex); + } + } + } + + @Override + public void send(final Object data, final SuccessCallback success, final OnError err) + throws Exception { + requireNonNull(data, "Message required."); + requireNonNull(success, "Success callback required."); + requireNonNull(err, "Error callback required."); + + synchronized (this) { + if (isOpen()) { + new WebSocketRendererContext( + renderers, + ws, + produces, + StandardCharsets.UTF_8, + locale, + success, + err).render(data); + } else { + throw new Err(WebSocket.NORMAL, "WebSocket is closed."); + } + } + } + + @Override + public void onMessage(final OnMessage callback) throws Exception { + this.messageCallback = requireNonNull(callback, "Message callback required."); + } + + public void connect(final Injector injector, final Request req, final NativeWebSocket ws) { + this.open = true; + this.injector = requireNonNull(injector, "Injector required."); + this.ws = requireNonNull(ws, "WebSocket is required."); + this.locale = req.locale(); + renderers = ImmutableList.copyOf(injector.getInstance(Renderer.KEY)); + + /** + * Bind callbacks + */ + ws.onBinaryMessage(buffer -> Try + .run(sync(() -> messageCallback.onMessage(new WsBinaryMessage(buffer)))) + .onFailure(this::handleErr)); + + ws.onTextMessage(message -> Try + .run(sync(() -> messageCallback.onMessage( + new MutantImpl(injector.getInstance(ParserExecutor.class), consumes, + new StrParamReferenceImpl("body", "message", ImmutableList.of(message)))))) + .onFailure(this::handleErr)); + + ws.onCloseMessage((code, reason) -> { + removeSession(this); + + Try.run(sync(() -> { + this.open = false; + if (closeCallback != null) { + closeCallback.onClose(reason.map(r -> WebSocket.CloseStatus.of(code, r)) + .orElse(WebSocket.CloseStatus.of(code))); + } + closeCallback = null; + })).onFailure(this::handleErr); + }); + + ws.onErrorMessage(this::handleErr); + + // connect now + try { + addSession(this); + handler.onOpen(req, this); + } catch (Throwable ex) { + handleErr(ex); + } + } + + @Override + public String path() { + return path; + } + + @Override + public String pattern() { + return pattern; + } + + @Override + public Map vars() { + return vars; + } + + @Override + public MediaType consumes() { + return consumes; + } + + @Override + public MediaType produces() { + return produces; + } + + @Override + public T require(final Key key) { + return injector.getInstance(key); + } + + @Override + public String toString() { + StringBuilder buffer = new StringBuilder(); + buffer.append("WS ").append(path()).append("\n"); + buffer.append(" pattern: ").append(pattern()).append("\n"); + buffer.append(" vars: ").append(vars()).append("\n"); + buffer.append(" consumes: ").append(consumes()).append("\n"); + buffer.append(" produces: ").append(produces()).append("\n"); + return buffer.toString(); + } + + @Override + public void onError(final WebSocket.OnError callback) { + this.exceptionCallback = requireNonNull(callback, "A callback is required."); + } + + @Override + public void onClose(final WebSocket.OnClose callback) throws Exception { + this.closeCallback = requireNonNull(callback, "A callback is required."); + } + + @Override public T get(String name) { + return (T) ifGet(name).orElseThrow(() -> new NullPointerException(name)); + } + + @Override public Optional ifGet(String name) { + return Optional.ofNullable((T) attributes.get(name)); + } + + @Nullable @Override public WebSocket set(String name, Object value) { + attributes.put(name, value); + return this; + } + + @Override public Optional unset(String name) { + return Optional.ofNullable((T) attributes.remove(name)); + } + + @Override public WebSocket unset() { + attributes.clear(); + return this; + } + + @Override public Map attributes() { + return Collections.unmodifiableMap(attributes); + } + + private void handleErr(final Throwable cause) { + Try.run(() -> { + if (SILENT.test(cause)) { + log.debug("execution of WS" + path() + " resulted in exception", cause); + } else { + exceptionCallback.onError(cause); + } + }) + .onComplete(() -> cleanup(cause)) + .throwException(); + } + + private void cleanup(final Throwable cause) { + open = false; + NativeWebSocket lws = ws; + this.ws = null; + this.injector = null; + this.handler = null; + this.closeCallback = null; + this.exceptionCallback = null; + this.messageCallback = null; + + if (lws != null && lws.isOpen()) { + WebSocket.CloseStatus closeStatus = WebSocket.SERVER_ERROR; + if (cause instanceof IllegalArgumentException) { + closeStatus = WebSocket.BAD_DATA; + } else if (cause instanceof NoSuchElementException) { + closeStatus = WebSocket.BAD_DATA; + } else if (cause instanceof Err) { + Err err = (Err) cause; + if (err.statusCode() == 400) { + closeStatus = WebSocket.BAD_DATA; + } + } + lws.close(closeStatus.code(), closeStatus.reason()); + } + } + + private Throwing.Runnable sync(final Throwing.Runnable task) { + return () -> { + synchronized (this) { + task.run(); + } + }; + } + + private static void addSession(WebSocketImpl ws) { + sessions.computeIfAbsent(ws.pattern, k -> new CopyOnWriteArrayList<>()).add(ws); + } + + private static void removeSession(WebSocketImpl ws) { + Optional.ofNullable(sessions.get(ws.pattern)).ifPresent(list -> list.remove(ws)); + } +} diff --git a/jooby/src/main/java/org/jooby/internal/WebSocketRendererContext.java b/jooby/src/main/java/org/jooby/internal/WebSocketRendererContext.java new file mode 100644 index 00000000..b6f9b229 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/WebSocketRendererContext.java @@ -0,0 +1,88 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.Charset; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +import org.jooby.MediaType; +import org.jooby.Renderer; +import org.jooby.WebSocket.OnError; +import org.jooby.WebSocket.SuccessCallback; +import org.jooby.spi.NativeWebSocket; + +import com.google.common.collect.ImmutableList; + +public class WebSocketRendererContext extends AbstractRendererContext { + + private NativeWebSocket ws; + + private SuccessCallback success; + + private OnError err; + + private MediaType type; + + public WebSocketRendererContext(final List renderers, final NativeWebSocket ws, + final MediaType type, final Charset charset, Locale locale, final SuccessCallback success, + final OnError err) { + super(renderers, ImmutableList.of(type), charset, locale, Collections.emptyMap()); + this.ws = ws; + this.type = type; + this.success = success; + this.err = err; + } + + @Override + public void send(final String text) throws Exception { + ws.sendText(text, success, err); + setCommitted(); + } + + @Override + protected void _send(final byte[] bytes) throws Exception { + if (type.isText()) { + ws.sendText(bytes, success, err); + } else { + ws.sendBytes(bytes, success, err); + } + } + + @Override + protected void _send(final ByteBuffer buffer) throws Exception { + if (type.isText()) { + ws.sendText(buffer, success, err); + } else { + ws.sendBytes(buffer, success, err); + } + } + + @Override + protected void _send(final FileChannel file) throws Exception { + throw new UnsupportedOperationException(); + } + + @Override + protected void _send(final InputStream stream) throws Exception { + throw new UnsupportedOperationException(); + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/WsBinaryMessage.java b/jooby/src/main/java/org/jooby/internal/WsBinaryMessage.java new file mode 100644 index 00000000..1a1c2907 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/WsBinaryMessage.java @@ -0,0 +1,154 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.SortedSet; + +import org.jooby.Err; +import org.jooby.MediaType; +import org.jooby.Mutant; +import org.jooby.Status; + +import com.google.common.base.Charsets; +import com.google.common.collect.ImmutableMap; +import com.google.inject.TypeLiteral; + +public class WsBinaryMessage implements Mutant { + + private ByteBuffer buffer; + + public WsBinaryMessage(final ByteBuffer buffer) { + this.buffer = buffer; + } + + @Override + public boolean booleanValue() { + throw typeError(boolean.class); + } + + @Override + public byte byteValue() { + throw typeError(byte.class); + } + + @Override + public short shortValue() { + throw typeError(short.class); + } + + @Override + public int intValue() { + throw typeError(int.class); + } + + @Override + public long longValue() { + throw typeError(long.class); + } + + @Override + public String value() { + throw typeError(String.class); + } + + @Override + public float floatValue() { + throw typeError(float.class); + } + + @Override + public double doubleValue() { + throw typeError(double.class); + } + + @Override + public > T toEnum(final Class type) { + throw typeError(type); + } + + @Override + public List toList(final Class type) { + throw typeError(type); + } + + @Override + public Set toSet(final Class type) { + throw typeError(type); + } + + @Override + public > SortedSet toSortedSet(final Class type) { + throw typeError(type); + } + + @Override + public Optional toOptional(final Class type) { + throw typeError(type); + } + + @Override + public T to(final TypeLiteral type) { + return to(type, MediaType.octetstream); + } + + @SuppressWarnings("unchecked") + @Override + public T to(final TypeLiteral type, final MediaType mtype) { + Class rawType = type.getRawType(); + if (rawType == byte[].class) { + if (buffer.hasArray()) { + return (T) buffer.array(); + } + byte[] bytes = new byte[buffer.remaining()]; + buffer.get(bytes); + return (T) bytes; + } + if (rawType == ByteBuffer.class) { + return (T) buffer; + } + if (rawType == InputStream.class) { + return (T) new ByteArrayInputStream(buffer.array()); + } + if (rawType == Reader.class) { + return (T) new InputStreamReader(new ByteArrayInputStream(buffer.array()), Charsets.UTF_8); + } + throw typeError(rawType); + } + + @Override + public Map toMap() { + return ImmutableMap.of("message", this); + } + + @Override + public boolean isSet() { + return true; + } + + private Err typeError(final Class type) { + return new Err(Status.BAD_REQUEST, "Can't convert to " + + ByteBuffer.class.getName() + " to " + type); + } +} diff --git a/jooby/src/main/java/org/jooby/internal/handlers/FlashScopeHandler.java b/jooby/src/main/java/org/jooby/internal/handlers/FlashScopeHandler.java new file mode 100644 index 00000000..0318e023 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/handlers/FlashScopeHandler.java @@ -0,0 +1,108 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.handlers; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + +import org.jooby.Cookie; +import org.jooby.FlashScope; +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Route; + +public class FlashScopeHandler implements Route.Filter { + + public static class FlashMap extends HashMap implements Request.Flash { + + private boolean keep; + + public FlashMap(Map source) { + super(source); + } + + @Override public void keep() { + keep = true; + } + } + + private Cookie.Definition template; + + private String cname; + + private Function> decoder; + + private Function, String> encoder; + + public FlashScopeHandler(final Cookie.Definition cookie, + final Function> decoder, + final Function, String> encoder) { + this.template = cookie; + this.cname = cookie.name().get(); + this.decoder = decoder; + this.encoder = encoder; + } + + @Override + public void handle(final Request req, final Response rsp, final Route.Chain chain) + throws Throwable { + Optional value = req.cookie(cname).toOptional(); + Map source = value.map(decoder::apply) + .orElseGet(HashMap::new); + FlashMap flashScope = new FlashMap(source); + + req.set(FlashScope.NAME, flashScope); + + // wrap & proceed + rsp.after(finalizeFlash(source, flashScope)); + + chain.next(req, rsp); + } + + private Route.After finalizeFlash(final Map initialScope, final FlashMap scope) { + return (req, rsp, result) -> { + if (scope.keep) { + // keep values, no matter what + if (scope.size() > 0) { + rsp.cookie(new Cookie.Definition(template).value(encoder.apply(scope))); + } else if (initialScope.size() > 0) { + rsp.cookie(new Cookie.Definition(template).maxAge(0)); + } + } else { + // 1. no change detect + if (scope.equals(initialScope)) { + // 1.a. existing data available, discard + if (scope.size() > 0) { + rsp.cookie(new Cookie.Definition(template).maxAge(0)); + } + } else { + // 2. change detected + if (scope.size() == 0) { + // 2.a everything was removed from app logic + rsp.cookie(new Cookie.Definition(template).maxAge(0)); + } else { + // 2.b there is something to see in the next request + rsp.cookie(new Cookie.Definition(template).value(encoder.apply(scope))); + } + } + } + return result; + }; + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/handlers/HeadHandler.java b/jooby/src/main/java/org/jooby/internal/handlers/HeadHandler.java new file mode 100644 index 00000000..ad780124 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/handlers/HeadHandler.java @@ -0,0 +1,62 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.handlers; + +import static java.util.Objects.requireNonNull; + +import java.util.Optional; +import java.util.Set; + +import org.jooby.MediaType; +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Route; +import org.jooby.Route.Definition; +import org.jooby.internal.RouteImpl; + +import com.google.inject.Inject; + +public class HeadHandler implements Route.Filter { + + private Set routes; + + @Inject + public HeadHandler(final Set routes) { + this.routes = requireNonNull(routes, "Routes are required."); + } + + @Override + public void handle(final Request req, final Response rsp, final Route.Chain chain) + throws Throwable { + + String path = req.path(); + for (Route.Definition router : routes) { + // ignore glob route + if (!router.glob()) { + Optional ifRoute = router + .matches(Route.GET, path, MediaType.all, MediaType.ALL); + if (ifRoute.isPresent()) { + // route found + rsp.length(0); + ((RouteImpl) ifRoute.get()).handle(req, rsp, chain); + return; + } + } + } + // not handled, just call next + chain.next(req, rsp); + } +} diff --git a/jooby/src/main/java/org/jooby/internal/handlers/OptionsHandler.java b/jooby/src/main/java/org/jooby/internal/handlers/OptionsHandler.java new file mode 100644 index 00000000..6390a0c2 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/handlers/OptionsHandler.java @@ -0,0 +1,64 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.handlers; + +import static java.util.Objects.requireNonNull; + +import java.util.LinkedHashSet; +import java.util.Set; + +import org.jooby.MediaType; +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Route; +import org.jooby.Route.Definition; +import org.jooby.Status; + +import com.google.common.base.Joiner; +import com.google.inject.Inject; + +public class OptionsHandler implements Route.Handler { + + private static final String SEP = ", "; + + private static final String ALLOW = "Allow"; + + private Set routes; + + @Inject + public OptionsHandler(final Set routes) { + this.routes = requireNonNull(routes, "Routes are required."); + } + + @Override + public void handle(final Request req, final Response rsp) throws Exception { + if (!rsp.header(ALLOW).isSet()) { + Set allow = new LinkedHashSet<>(); + Set methods = new LinkedHashSet<>(Route.METHODS); + String path = req.path(); + methods.remove(req.method()); + for (String method : methods) { + routes.stream() + .filter(route -> route.matches(method, path, MediaType.all, MediaType.ALL).isPresent()) + .forEach(route -> allow.add(route.method())); + } + rsp.header(ALLOW, Joiner.on(SEP).join(allow)) + .length(0) + .status(Status.OK); + } + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/handlers/TraceHandler.java b/jooby/src/main/java/org/jooby/internal/handlers/TraceHandler.java new file mode 100644 index 00000000..7a613f21 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/handlers/TraceHandler.java @@ -0,0 +1,47 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.handlers; + +import java.util.Map.Entry; +import java.util.stream.Collectors; + +import org.jooby.MediaType; +import org.jooby.Mutant; +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Route; + +public class TraceHandler implements Route.Handler { + + @Override + public void handle(final Request req, final Response rsp) throws Throwable { + String CRLF = "\r\n"; + StringBuilder buffer = new StringBuilder("TRACE ").append(req.path()) + .append(" ").append(req.protocol()); + + for (Entry entry : req.headers().entrySet()) { + buffer.append(CRLF).append(entry.getKey()).append(": ") + .append(entry.getValue().toList(String.class).stream().collect(Collectors.joining(", "))); + } + + buffer.append(CRLF); + + rsp.type(MediaType.valueOf("message/http")); + rsp.length(buffer.length()); + rsp.send(buffer.toString()); + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/jetty/JettyHandler.java b/jooby/src/main/java/org/jooby/internal/jetty/JettyHandler.java new file mode 100644 index 00000000..f7c87083 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/jetty/JettyHandler.java @@ -0,0 +1,103 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.jetty; + +import java.io.IOException; + +import javax.servlet.MultipartConfigElement; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.server.handler.AbstractHandler; +import org.jooby.MediaType; +import org.jooby.Sse; +import org.jooby.servlet.ServletServletRequest; +import org.jooby.servlet.ServletUpgrade; +import org.jooby.spi.HttpHandler; +import org.jooby.spi.NativePushPromise; +import org.jooby.spi.NativeWebSocket; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class JettyHandler extends AbstractHandler { + + private static final String MULTIPART_CONFIG_ELEMENT = "org.eclipse.jetty.multipartConfig"; + + /** The logging system. */ + private final Logger log = LoggerFactory.getLogger(getClass()); + + private HttpHandler dispatcher; + + private String tmpdir; + + private MultipartConfigElement multiPartConfig; + + public JettyHandler(final HttpHandler dispatcher, + final String tmpdir, final int fileSizeThreshold) { + this.dispatcher = dispatcher; + this.tmpdir = tmpdir; + this.multiPartConfig = new MultipartConfigElement(tmpdir, -1L, -1L, fileSizeThreshold); + } + + @Override + public void handle(final String target, final Request baseRequest, + final HttpServletRequest request, final HttpServletResponse response) throws IOException, + ServletException { + try { + + baseRequest.setHandled(true); + + String type = baseRequest.getContentType(); + boolean multipart = false; + if (type != null && type.toLowerCase().startsWith(MediaType.multipart.name())) { + baseRequest.setAttribute(MULTIPART_CONFIG_ELEMENT, multiPartConfig); + multipart = true; + } + + ServletServletRequest nreq = new ServletServletRequest(request, tmpdir, multipart) + .with(upgrade(baseRequest, response)); + dispatcher.handle(nreq, new JettyResponse(nreq, response)); + } catch (IOException | ServletException | RuntimeException ex) { + baseRequest.setHandled(false); + log.error("execution of: " + target + " resulted in error", ex); + throw ex; + } catch (Throwable ex) { + baseRequest.setHandled(false); + log.error("execution of: " + target + " resulted in error", ex); + throw new IllegalStateException(ex); + } + } + + private static ServletUpgrade upgrade(final Request baseRequest, + final HttpServletResponse response) { + return new ServletUpgrade() { + @SuppressWarnings("unchecked") + @Override + public T upgrade(final Class type) throws Exception { + if (type == Sse.class) { + return (T) new JettySse(baseRequest, (Response) response); + } else if (type == NativePushPromise.class) { + return (T) new JettyPush(baseRequest); + } + throw new UnsupportedOperationException("Not Supported: " + type); + } + }; + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/jetty/JettyPush.java b/jooby/src/main/java/org/jooby/internal/jetty/JettyPush.java new file mode 100644 index 00000000..91613dd9 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/jetty/JettyPush.java @@ -0,0 +1,42 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.jetty; + +import java.util.Map; + +import org.eclipse.jetty.server.Request; +import org.jooby.spi.NativePushPromise; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class JettyPush implements NativePushPromise { + + private static final Logger log = LoggerFactory.getLogger(JettyPush.class); + + private Request req; + + public JettyPush(final Request req) { + this.req = req; + } + + @Override + public void push(final String method, final String path, final Map headers) { + // HTTP/2 Server Push (PushBuilder) was removed in Jetty 10 / Servlet 5.0. + // It is deprecated in the HTTP/2 spec (RFC 9113) and unsupported by most browsers. + log.debug("HTTP/2 push ignored (not supported in Jetty 10+): {} {}", method, path); + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/jetty/JettyResponse.java b/jooby/src/main/java/org/jooby/internal/jetty/JettyResponse.java new file mode 100644 index 00000000..d979ed92 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/jetty/JettyResponse.java @@ -0,0 +1,120 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.jetty; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jetty.server.HttpOutput; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.util.Callback; +import org.jooby.servlet.ServletServletRequest; +import org.jooby.servlet.ServletServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class JettyResponse extends ServletServletResponse implements Callback { + + /** The logging system. */ + private final Logger log = LoggerFactory.getLogger(org.jooby.Response.class); + + private ServletServletRequest nreq; + + private volatile boolean endRequest = true; + + public JettyResponse(final ServletServletRequest nreq, final HttpServletResponse rsp) { + super(nreq.servletRequest(), rsp); + this.nreq = nreq; + } + + @Override + public void send(final byte[] bytes) throws Exception { + rsp.setHeader("Transfer-Encoding", null); + sender().sendContent(ByteBuffer.wrap(bytes)); + } + + @Override + public void send(final ByteBuffer buffer) throws Exception { + sender().sendContent(buffer); + } + + @Override + public void send(final InputStream stream) throws Exception { + endRequest = false; + startAsyncIfNeedIt(); + sender().sendContent(Channels.newChannel(stream), this); + } + + @Override + public void send(final FileChannel channel) throws Exception { + int bufferSize = rsp.getBufferSize(); + if (channel.size() < bufferSize) { + // sync version, file size is smaller than bufferSize + sender().sendContent(channel); + } else { + endRequest = false; + startAsyncIfNeedIt(); + sender().sendContent(channel, this); + } + } + + @Override + public void succeeded() { + endRequest = true; + end(); + } + + @Override + public void failed(final Throwable cause) { + endRequest = true; + log.error("execution of " + nreq.path() + " resulted in exception", cause); + end(); + } + + @Override + public void end() { + if (endRequest) { + super.end(); + nreq = null; + } + } + + @Override + protected void close() { + try { + sender().close(); + } catch (IOException e) { + throw new IllegalStateException("Failed to close response output", e); + } + } + + private HttpOutput sender() { + return ((Response) rsp).getHttpOutput(); + } + + private void startAsyncIfNeedIt() { + HttpServletRequest req = nreq.servletRequest(); + if (!req.isAsyncStarted()) { + req.startAsync(); + } + } +} diff --git a/jooby/src/main/java/org/jooby/internal/jetty/JettyServer.java b/jooby/src/main/java/org/jooby/internal/jetty/JettyServer.java new file mode 100644 index 00000000..0c7c5f61 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/jetty/JettyServer.java @@ -0,0 +1,228 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.jetty; + +import com.google.common.primitives.Primitives; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigException; +import org.eclipse.jetty.alpn.server.ALPNServerConnectionFactory; +import org.eclipse.jetty.http2.server.HTTP2CServerConnectionFactory; +import org.eclipse.jetty.http2.server.HTTP2ServerConnectionFactory; +import org.eclipse.jetty.server.HttpConfiguration; +import org.eclipse.jetty.server.HttpConnectionFactory; +import org.eclipse.jetty.server.SecureRequestCustomizer; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.SslConnectionFactory; +import org.eclipse.jetty.server.handler.ContextHandler; +import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.eclipse.jetty.util.thread.QueuedThreadPool; +import org.jooby.funzy.Try; +import org.jooby.spi.HttpHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.inject.Inject; +import javax.inject.Provider; +import javax.net.ssl.SSLContext; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.stream.Collectors; + +public class JettyServer implements org.jooby.spi.Server { + + private static final String H2 = "h2"; + private static final String H2_17 = "h2-17"; + private static final String HTTP_1_1 = "http/1.1"; + + private static final String JETTY_HTTP = "jetty.http"; + private static final String CONNECTOR = "connector"; + + /** The logging system. */ + private final Logger log = LoggerFactory.getLogger(org.jooby.spi.Server.class); + + private Server server; + + @Inject + public JettyServer(final HttpHandler handler, final Config conf, + final Provider sslCtx) { + this.server = server(handler, conf, sslCtx); + } + + private Server server(final HttpHandler handler, final Config conf, + final Provider sslCtx) { + System.setProperty("org.eclipse.jetty.util.UrlEncoded.charset", + conf.getString("jetty.url.charset")); + + System.setProperty("org.eclipse.jetty.server.Request.maxFormContentSize", + conf.getBytes("server.http.MaxRequestSize").toString()); + + QueuedThreadPool pool = conf(new QueuedThreadPool(), conf.getConfig("jetty.threads"), + "jetty.threads"); + + Server server = new Server(pool); + server.setStopAtShutdown(false); + + // HTTP connector + boolean http2 = conf.getBoolean("server.http2.enabled"); + + ServerConnector http = http(server, conf.getConfig(JETTY_HTTP), JETTY_HTTP, http2); + http.setPort(conf.getInt("application.port")); + http.setHost(conf.getString("application.host")); + + if (conf.hasPath("application.securePort")) { + + ServerConnector https = https(server, conf.getConfig(JETTY_HTTP), JETTY_HTTP, + sslCtx.get(), http2); + https.setPort(conf.getInt("application.securePort")); + + server.addConnector(https); + } + + server.addConnector(http); + + ContextHandler sch = new ContextHandler(); + + // always '/' context path is internally handle by jooby + sch.setContextPath("/"); + sch.setHandler(new JettyHandler(handler, conf + .getString("application.tmpdir"), conf.getBytes("jetty.FileSizeThreshold").intValue())); + + server.setHandler(sch); + + return server; + } + + private ServerConnector http(final Server server, final Config conf, final String path, + final boolean http2) { + HttpConfiguration httpConfig = conf(new HttpConfiguration(), conf.withoutPath(CONNECTOR), + path); + + ServerConnector connector; + if (http2) { + connector = new ServerConnector(server, new HttpConnectionFactory(httpConfig), + new HTTP2CServerConnectionFactory(httpConfig)); + } else { + connector = new ServerConnector(server, new HttpConnectionFactory(httpConfig)); + } + + return conf(connector, conf.getConfig(CONNECTOR), path + "." + CONNECTOR); + } + + private ServerConnector https(final Server server, final Config conf, final String path, + final SSLContext sslContext, final boolean http2) { + + HttpConfiguration httpConf = conf(new HttpConfiguration(), conf.withoutPath(CONNECTOR), + path); + + SslContextFactory.Server sslContextFactory = new SslContextFactory.Server(); + sslContextFactory.setSslContext(sslContext); + sslContextFactory.setIncludeProtocols("TLSv1.2"); + sslContextFactory.setIncludeCipherSuites("TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"); + sslContextFactory.setEndpointIdentificationAlgorithm("HTTPS"); + + HttpConfiguration httpsConf = new HttpConfiguration(httpConf); + httpsConf.addCustomizer(new SecureRequestCustomizer()); + + HttpConnectionFactory https11 = new HttpConnectionFactory(httpsConf); + + if (http2) { + ALPNServerConnectionFactory alpn = new ALPNServerConnectionFactory(H2, H2_17, HTTP_1_1); + alpn.setDefaultProtocol(HTTP_1_1); + + HTTP2ServerConnectionFactory https2 = new HTTP2ServerConnectionFactory(httpsConf); + + ServerConnector connector = new ServerConnector(server, + new SslConnectionFactory(sslContextFactory, "alpn"), alpn, https2, https11); + + return conf(connector, conf.getConfig(CONNECTOR), path + ".connector"); + } else { + + ServerConnector connector = new ServerConnector(server, + new SslConnectionFactory(sslContextFactory, HTTP_1_1), https11); + + return conf(connector, conf.getConfig(CONNECTOR), path + ".connector"); + } + } + + @Override + public void start() throws Exception { + server.start(); + } + + @Override + public void join() throws InterruptedException { + server.join(); + } + + @Override + public void stop() throws Exception { + server.stop(); + } + + @Override + public Optional executor() { + return Optional.ofNullable(server.getThreadPool()); + } + + private void tryOption(final Object source, final Config config, final Method option) { + Try.run(() -> { + String optionName = option.getName().replace("set", ""); + Object optionValue = config.getAnyRef(optionName); + Class optionType = Primitives.wrap(option.getParameterTypes()[0]); + if (Number.class.isAssignableFrom(optionType) && optionValue instanceof String) { + // either a byte or time unit + try { + optionValue = config.getBytes(optionName); + } catch (ConfigException.BadValue ex) { + optionValue = config.getDuration(optionName, TimeUnit.MILLISECONDS); + } + if (optionType == Integer.class) { + // to int + optionValue = ((Number) optionValue).intValue(); + } + } + log.debug("{}.{}({})", source.getClass().getSimpleName(), option.getName(), optionValue); + option.invoke(source, optionValue); + }).unwrap(InvocationTargetException.class) + .throwException(); + } + + private T conf(final T source, final Config config, final String path) { + Map methods = Arrays.stream(source.getClass().getMethods()) + .filter(m -> m.getName().startsWith("set") && m.getParameterCount() == 1) + .collect(Collectors.toMap(Method::getName, Function.identity())); + + config.entrySet().forEach(entry -> { + String key = "set" + entry.getKey(); + Method method = methods.get(key); + if (method != null) { + tryOption(source, config, method); + } else { + log.error("Unknown option: {}.{} for: {}", path, key, source.getClass().getName()); + } + }); + + return source; + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/jetty/JettySse.java b/jooby/src/main/java/org/jooby/internal/jetty/JettySse.java new file mode 100644 index 00000000..97b73f5b --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/jetty/JettySse.java @@ -0,0 +1,89 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.jetty; + +import org.eclipse.jetty.io.EofException; +import org.eclipse.jetty.server.Connector; +import org.eclipse.jetty.server.HttpChannel; +import org.eclipse.jetty.server.HttpOutput; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.jooby.Sse; +import org.jooby.funzy.Try; + +import javax.servlet.http.HttpServletResponse; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; + +public class JettySse extends Sse { + + private Request req; + + private Response rsp; + + private HttpOutput out; + + public JettySse(final Request request, final Response rsp) { + this.req = request; + this.rsp = rsp; + this.out = rsp.getHttpOutput(); + } + + @Override + protected void closeInternal() { + Try.run(() -> rsp.closeOutput()) + .onFailure(cause -> log.debug("error while closing connection", cause)); + } + + @Override + protected void handshake(final Runnable handler) throws Exception { + /** Infinite timeout because the continuation is never resumed but only completed on close. */ + req.getAsyncContext().setTimeout(0L); + /** Server sent events headers. */ + rsp.setStatus(HttpServletResponse.SC_OK); + rsp.setHeader("Connection", "Close"); + rsp.setContentType("text/event-stream; charset=utf-8"); + rsp.flushBuffer(); + + HttpChannel channel = rsp.getHttpChannel(); + Connector connector = channel.getConnector(); + Executor executor = connector.getExecutor(); + executor.execute(handler); + } + + @Override + protected CompletableFuture> send(final Optional id, final byte[] data) { + synchronized (this) { + CompletableFuture> future = new CompletableFuture<>(); + try { + out.write(data); + out.flush(); + future.complete(id); + } catch (Throwable ex) { + future.completeExceptionally(ex); + ifClose(ex); + } + return future; + } + } + + @Override + protected boolean shouldClose(final Throwable ex) { + return ex instanceof EofException || super.shouldClose(ex); + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/jetty/JettyWebSocket.java b/jooby/src/main/java/org/jooby/internal/jetty/JettyWebSocket.java new file mode 100644 index 00000000..7351ff74 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/jetty/JettyWebSocket.java @@ -0,0 +1,206 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.jetty; + +import static java.util.Objects.requireNonNull; +import org.eclipse.jetty.websocket.api.RemoteEndpoint; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.SuspendToken; +import org.eclipse.jetty.websocket.api.WebSocketListener; +import org.eclipse.jetty.websocket.api.WriteCallback; +import org.jooby.WebSocket; +import org.jooby.WebSocket.OnError; +import org.jooby.WebSocket.SuccessCallback; +import org.jooby.funzy.Try; +import org.jooby.spi.NativeWebSocket; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +public class JettyWebSocket implements NativeWebSocket, WebSocketListener { + + private static final String A_CALLBACK_IS_REQUIRED = "A callback is required."; + private static final String NO_DATA_TO_SEND = "No data to send."; + /** The logging system. */ + private final Logger log = LoggerFactory.getLogger(WebSocket.class); + + private Session session; + + private SuspendToken suspendToken; + + private Runnable onConnectCallback; + + private Consumer onTextCallback; + + private Consumer onBinaryCallback; + + private BiConsumer> onCloseCallback; + + private Consumer onErrorCallback; + + @Override + public void close(final int status, final String reason) { + session.close(status, reason); + } + + @Override + public void resume() { + if (suspendToken != null) { + suspendToken.resume(); + suspendToken = null; + } + } + + @Override + public void onConnect(final Runnable callback) { + this.onConnectCallback = requireNonNull(callback, A_CALLBACK_IS_REQUIRED); + } + + @Override + public void onTextMessage(final Consumer callback) { + this.onTextCallback = requireNonNull(callback, A_CALLBACK_IS_REQUIRED); + } + + @Override + public void onBinaryMessage(final Consumer callback) { + this.onBinaryCallback = requireNonNull(callback, A_CALLBACK_IS_REQUIRED); + } + + @Override + public void onCloseMessage(final BiConsumer> callback) { + this.onCloseCallback = requireNonNull(callback, A_CALLBACK_IS_REQUIRED); + } + + @Override + public void onErrorMessage(final Consumer callback) { + this.onErrorCallback = requireNonNull(callback, A_CALLBACK_IS_REQUIRED); + } + + @Override + public void pause() { + if (suspendToken == null) { + suspendToken = session.suspend(); + } + } + + @Override + public void terminate() throws IOException { + onCloseCallback.accept(1006, Optional.of("Harsh disconnect")); + session.disconnect(); + } + + @Override + public void sendBytes(final ByteBuffer data, final SuccessCallback success, + final OnError err) { + requireNonNull(data, NO_DATA_TO_SEND); + + RemoteEndpoint remote = session.getRemote(); + remote.sendBytes(data, callback(log, success, err)); + } + + @Override + public void sendBytes(final byte[] data, final SuccessCallback success, final OnError err) { + requireNonNull(data, NO_DATA_TO_SEND); + sendBytes(ByteBuffer.wrap(data), success, err); + } + + @Override + public void sendText(final String data, final SuccessCallback success, final OnError err) { + requireNonNull(data, NO_DATA_TO_SEND); + + RemoteEndpoint remote = session.getRemote(); + remote.sendString(data, callback(log, success, err)); + } + + @Override + public void sendText(final byte[] data, final SuccessCallback success, final OnError err) { + requireNonNull(data, NO_DATA_TO_SEND); + + RemoteEndpoint remote = session.getRemote(); + remote.sendString(new String(data, StandardCharsets.UTF_8), callback(log, success, err)); + } + + @Override + public void sendText(final ByteBuffer data, final SuccessCallback success, + final OnError err) { + requireNonNull(data, NO_DATA_TO_SEND); + + RemoteEndpoint remote = session.getRemote(); + CharBuffer buffer = StandardCharsets.UTF_8.decode(data); + // we need a TextFrame with ByteBuffer :( + remote.sendString(buffer.toString(), callback(log, success, err)); + } + + @Override + public boolean isOpen() { + return session.isOpen(); + } + + @Override + public void onWebSocketBinary(final byte[] payload, final int offset, final int len) { + this.onBinaryCallback.accept(ByteBuffer.wrap(payload, offset, len)); + } + + @Override + public void onWebSocketText(final String message) { + this.onTextCallback.accept(message); + } + + @Override + public void onWebSocketClose(final int statusCode, final String reason) { + onCloseCallback.accept(statusCode, Optional.ofNullable(reason)); + } + + @Override + public void onWebSocketConnect(final Session session) { + this.session = session; + this.onConnectCallback.run(); + } + + @Override + public void onWebSocketError(final Throwable cause) { + this.onErrorCallback.accept(cause); + } + + static WriteCallback callback(final Logger log, final SuccessCallback success, + final OnError err) { + requireNonNull(success, "Success callback is required."); + requireNonNull(err, "Error callback is required."); + + WriteCallback callback = new WriteCallback() { + @Override + public void writeSuccess() { + Try.run(success::invoke) + .onFailure(cause -> log.error("Error while invoking success callback", cause)); + } + + @Override + public void writeFailed(final Throwable cause) { + Try.run(() -> err.onError(cause)) + .onFailure(ex -> log.error("Error while invoking err callback", ex)); + } + }; + return callback; + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/mapper/CallableMapper.java b/jooby/src/main/java/org/jooby/internal/mapper/CallableMapper.java new file mode 100644 index 00000000..35387467 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/mapper/CallableMapper.java @@ -0,0 +1,37 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.mapper; + +import java.util.concurrent.Callable; + +import org.jooby.Deferred; +import org.jooby.Route; + +@SuppressWarnings("rawtypes") +public class CallableMapper implements Route.Mapper { + + @Override + public Object map(final Callable callable) throws Throwable { + return new Deferred(deferred -> { + try { + deferred.resolve(callable.call()); + } catch (Throwable x) { + deferred.reject(x); + } + }); + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/mapper/CompletableFutureMapper.java b/jooby/src/main/java/org/jooby/internal/mapper/CompletableFutureMapper.java new file mode 100644 index 00000000..4f4c26ef --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/mapper/CompletableFutureMapper.java @@ -0,0 +1,40 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.mapper; + +import java.util.concurrent.CompletableFuture; + +import org.jooby.Deferred; +import org.jooby.Route; + +@SuppressWarnings("rawtypes") +public class CompletableFutureMapper implements Route.Mapper { + + @SuppressWarnings("unchecked") + @Override + public Object map(final CompletableFuture future) throws Throwable { + return new Deferred(deferred -> { + future.whenComplete((value, x) -> { + if (x != null) { + deferred.reject((Throwable) x); + } else { + deferred.resolve(value); + } + }); + }); + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/mvc/MvcHandler.java b/jooby/src/main/java/org/jooby/internal/mvc/MvcHandler.java new file mode 100644 index 00000000..983ebe00 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/mvc/MvcHandler.java @@ -0,0 +1,96 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.mvc; + +import static java.util.Objects.requireNonNull; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.List; + +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Route; +import org.jooby.Status; + +import org.jooby.funzy.Try; + +public class MvcHandler implements Route.MethodHandler { + + private Method handler; + + private Class implementingClass; + + private RequestParamProvider provider; + + /** + * Constructor for MvcHandler. + * + * @param handler the method to handle the request + * @param implementingClass Target class (method owner). + * @param provider the request parameter provider + */ + public MvcHandler(final Method handler, final Class implementingClass, + final RequestParamProvider provider) { + this.handler = requireNonNull(handler, "Handler method is required."); + this.implementingClass = requireNonNull(implementingClass, "Implementing class is required."); + this.provider = requireNonNull(provider, "Param prodiver is required."); + } + + @Override + public Method method() { + return handler; + } + + public Class implementingClass() { + return implementingClass; + } + + @Override public void handle(Request req, Response rsp, Route.Chain chain) throws Throwable { + Object result = invoke(req, rsp, chain); + if (!rsp.committed()) { + Class returnType = handler.getReturnType(); + if (returnType == void.class) { + rsp.status(Status.NO_CONTENT); + } else { + rsp.status(Status.OK); + rsp.send(result); + } + } + chain.next(req, rsp); + } + + @Override public void handle(Request req, Response rsp) throws Throwable { + // NOOP + } + + public Object invoke(final Request req, final Response rsp, Route.Chain chain) { + return Try.apply(() -> { + Object target = req.require(implementingClass); + + List parameters = provider.parameters(handler); + Object[] args = new Object[parameters.size()]; + for (int i = 0; i < parameters.size(); i++) { + args[i] = parameters.get(i).value(req, rsp, chain); + } + + final Object result = handler.invoke(target, args); + + return result; + }).unwrap(InvocationTargetException.class) + .get(); + } +} diff --git a/jooby/src/main/java/org/jooby/internal/mvc/MvcRoutes.java b/jooby/src/main/java/org/jooby/internal/mvc/MvcRoutes.java new file mode 100644 index 00000000..35b4aef1 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/mvc/MvcRoutes.java @@ -0,0 +1,295 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.mvc; + +import com.google.common.base.CaseFormat; +import com.google.common.collect.ImmutableSet; +import org.jooby.Env; +import org.jooby.MediaType; +import org.jooby.Route; +import org.jooby.Route.Definition; +import org.jooby.funzy.Try; +import org.jooby.internal.RouteMetadata; +import org.jooby.mvc.CONNECT; +import org.jooby.mvc.Consumes; +import org.jooby.mvc.DELETE; +import org.jooby.mvc.GET; +import org.jooby.mvc.HEAD; +import org.jooby.mvc.OPTIONS; +import org.jooby.mvc.PATCH; +import org.jooby.mvc.POST; +import org.jooby.mvc.PUT; +import org.jooby.mvc.Path; +import org.jooby.mvc.Produces; +import org.jooby.mvc.TRACE; + +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Array; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; + +public class MvcRoutes { + + private static final String[] EMPTY = new String[0]; + + private static final Set> VERBS = ImmutableSet.of(GET.class, + POST.class, PUT.class, DELETE.class, PATCH.class, HEAD.class, OPTIONS.class, TRACE.class, + CONNECT.class); + + private static final Set> IGNORE = ImmutableSet + .>builder() + .addAll(VERBS) + .add(Path.class) + .add(Produces.class) + .add(Consumes.class) + .build(); + + public static List routes(final Env env, final RouteMetadata classInfo, + final String rpath, boolean caseSensitiveRouting, final Class routeClass) { + + // check and fail fast + methods(routeClass, methods -> { + routes(methods, (m, a) -> { + if (!Modifier.isPublic(m.getModifiers())) { + throw new IllegalArgumentException("Not a public method: " + m); + } + }); + }); + + RequestParamProvider provider = new RequestParamProviderImpl( + new RequestParamNameProviderImpl(classInfo)); + + String[] rootPaths = path(routeClass); + String[] rootExcludes = excludes(routeClass, EMPTY); + + // we are good, now collect them + Map>> methods = new HashMap<>(); + routes(routeClass.getMethods(), methods::put); + + List definitions = new ArrayList<>(); + Map attrs = attrs(routeClass.getAnnotations()); + methods + .keySet() + .stream() + .sorted((m1, m2) -> { + int l1 = classInfo.startAt(m1); + int l2 = classInfo.startAt(m2); + return l1 - l2; + }) + .forEach(method -> { + /** + * Param provider: dev vs none dev + */ + RequestParamProvider paramProvider = provider; + if (!env.name().equals("dev")) { + List params = provider.parameters(method); + paramProvider = (h) -> params; + } + + List> verbs = methods.get(method); + List produces = produces(method); + List consumes = consumes(method); + Map localAttrs = new HashMap<>(attrs); + localAttrs.putAll(attrs(method.getAnnotations())); + + for (String path : expandPaths(rootPaths, method)) { + for (Class verb : verbs) { + String name = routeClass.getSimpleName() + "." + method.getName(); + + String[] excludes = excludes(method, rootExcludes); + + Definition definition = new Route.Definition( + verb.getSimpleName(), rpath + "/" + path, + new MvcHandler(method, routeClass, paramProvider), caseSensitiveRouting) + .produces(produces) + .consumes(consumes) + .excludes(excludes) + .declaringClass(routeClass.getName()) + .line(classInfo.startAt(method) - 1) + .name(name); + + localAttrs.forEach((n, v) -> definition.attr(n, v)); + definitions.add(definition); + } + } + }); + + return definitions; + } + + private static void methods(final Class clazz, final Consumer callback) { + if (clazz != Object.class) { + callback.accept(clazz.getDeclaredMethods()); + methods(clazz.getSuperclass(), callback); + } + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static void routes(final Method[] methods, + final BiConsumer>> consumer) { + for (Method method : methods) { + List> annotations = new ArrayList<>(); + for (Class annotationType : VERBS) { + Annotation annotation = method.getAnnotation(annotationType); + if (annotation != null) { + annotations.add(annotationType); + } + } + if (annotations.size() > 0) { + consumer.accept(method, annotations); + } else if (method.isAnnotationPresent(Path.class)) { + consumer.accept(method, Arrays.asList(GET.class)); + } + } + } + + private static Map attrs(final Annotation[] annotations) { + Map result = new LinkedHashMap<>(); + for (Annotation annotation : annotations) { + result.putAll(attrs(annotation)); + } + return result; + } + + private static Map attrs(final Annotation annotation) { + Map result = new LinkedHashMap<>(); + Class annotationType = annotation.annotationType(); + if (!IGNORE.contains(annotationType)) { + Method[] attrs = annotation.annotationType().getDeclaredMethods(); + for (Method attr : attrs) { + Try.apply(() -> attr.invoke(annotation)) + .onSuccess(value -> { + if (value.getClass().isArray() && Annotation.class + .isAssignableFrom(value.getClass().getComponentType())) { + List> array = new ArrayList<>(); + for(int i = 0; i < Array.getLength(value); i ++) { + array.add(attrs((Annotation) Array.get(value, i))); + } + result.put(attrName(annotation, attr), array.toArray()); + } else { + result.put(attrName(annotation, attr), value); + } + }); + } + } + return result; + } + + private static String attrName(final Annotation annotation, final Method attr) { + String name = attr.getName(); + String scope = CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_CAMEL, annotation.annotationType().getSimpleName()); + if (name.equals("value")) { + return scope; + } + return scope + "." + name; + } + + private static List produces(final Method method) { + Function>> fn = (element) -> { + Produces produces = element.getAnnotation(Produces.class); + if (produces != null) { + return Optional.of(MediaType.valueOf(produces.value())); + } + return Optional.empty(); + }; + + // method level + return fn.apply(method) + // class level + .orElseGet(() -> fn.apply(method.getDeclaringClass()) + // none + .orElse(MediaType.ALL)); + } + + private static List consumes(final Method method) { + Function>> fn = (element) -> { + Consumes consumes = element.getAnnotation(Consumes.class); + if (consumes != null) { + return Optional.of(MediaType.valueOf(consumes.value())); + } + return Optional.empty(); + }; + + // method level + return fn.apply(method) + // class level + .orElseGet(() -> fn.apply(method.getDeclaringClass()) + // none + .orElse(MediaType.ALL)); + } + + private static String[] path(final AnnotatedElement owner) { + Path annotation = owner.getAnnotation(Path.class); + if (annotation == null) { + return EMPTY; + } + return annotation.value(); + } + + private static String[] excludes(final AnnotatedElement owner, final String[] parent) { + Path annotation = owner.getAnnotation(Path.class); + if (annotation == null) { + return parent; + } + String[] excludes = annotation.excludes(); + if (excludes.length == 0) { + return parent; + } + if (parent.length == 0) { + return excludes; + } + // join everything + int size = parent.length + excludes.length; + String[] result = new String[size]; + System.arraycopy(parent, 0, result, 0, parent.length); + System.arraycopy(excludes, 0, result, parent.length, excludes.length); + return result; + } + + private static String[] expandPaths(final String[] root, final Method m) { + String[] path = path(m); + if (root.length == 0) { + if (path.length == 0) { + throw new IllegalArgumentException("No path(s) found for: " + m); + } + return path; + } + if (path.length == 0) { + return root; + } + String[] result = new String[root.length * path.length]; + int k = 0; + for (String base : root) { + for (String element : path) { + result[k] = base + "/" + element; + k += 1; + } + } + return result; + } +} diff --git a/jooby/src/main/java/org/jooby/internal/mvc/MvcWebSocket.java b/jooby/src/main/java/org/jooby/internal/mvc/MvcWebSocket.java new file mode 100644 index 00000000..ec368dc1 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/mvc/MvcWebSocket.java @@ -0,0 +1,107 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.mvc; + +import com.google.inject.Injector; +import com.google.inject.TypeLiteral; +import org.jooby.Mutant; +import org.jooby.Request; +import org.jooby.WebSocket; +import org.jooby.WebSocket.CloseStatus; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Arrays; +import java.util.function.Predicate; + +@SuppressWarnings({"rawtypes", "unchecked"}) +public class MvcWebSocket implements WebSocket.Handler { + + private Object handler; + + private TypeLiteral messageType; + + MvcWebSocket(final WebSocket ws, final Class handler) { + Injector injector = ws.require(Injector.class) + .createChildInjector(binder -> binder.bind(WebSocket.class).toInstance(ws)); + this.handler = injector.getInstance(handler); + this.messageType = TypeLiteral.get(messageType(handler)); + } + + public static WebSocket.OnOpen newWebSocket(final Class handler) { + return (req, ws) -> { + MvcWebSocket socket = new MvcWebSocket(ws, handler); + socket.onOpen(req, ws); + if (socket.isClose()) { + ws.onClose(socket::onClose); + } + if (socket.isError()) { + ws.onError(socket::onError); + } + ws.onMessage(socket::onMessage); + }; + } + + @Override + public void onClose(final CloseStatus status) throws Exception { + if (isClose()) { + ((WebSocket.OnClose) handler).onClose(status); + } + } + + @Override + public void onMessage(final Mutant data) throws Exception { + ((WebSocket.OnMessage) handler).onMessage(data.to(messageType)); + } + + @Override + public void onError(final Throwable err) { + if (isError()) { + ((WebSocket.OnError) handler).onError(err); + } + } + + @Override + public void onOpen(final Request req, final WebSocket ws) throws Exception { + if (handler instanceof WebSocket.OnOpen) { + ((WebSocket.OnOpen) handler).onOpen(req, ws); + } + } + + private boolean isClose() { + return handler instanceof WebSocket.OnClose; + } + + private boolean isError() { + return handler instanceof WebSocket.OnError; + } + + static Type messageType(final Class handler) { + return Arrays.asList(handler.getGenericInterfaces()) + .stream() + .filter(rawTypeIs(WebSocket.Handler.class).or(rawTypeIs(WebSocket.OnMessage.class))) + .findFirst() + .filter(ParameterizedType.class::isInstance) + .map(it -> ((ParameterizedType) it).getActualTypeArguments()[0]) + .orElseThrow(() -> new IllegalArgumentException( + "Can't extract message type from: " + handler.getName())); + } + + private static Predicate rawTypeIs(Class type) { + return it -> TypeLiteral.get(it).getRawType().isAssignableFrom(type); + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/mvc/RequestParam.java b/jooby/src/main/java/org/jooby/internal/mvc/RequestParam.java new file mode 100644 index 00000000..7ed6f3cb --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/mvc/RequestParam.java @@ -0,0 +1,221 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.mvc; + +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableMap.Builder; +import com.google.inject.TypeLiteral; +import com.google.inject.util.Types; +import org.jooby.Cookie; +import org.jooby.Err; +import org.jooby.Mutant; +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Route; +import org.jooby.Session; +import org.jooby.Status; +import org.jooby.Upload; +import org.jooby.mvc.Body; +import org.jooby.mvc.Flash; +import org.jooby.mvc.Header; +import org.jooby.mvc.Local; + +import javax.inject.Named; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Parameter; +import java.lang.reflect.Type; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@SuppressWarnings({"rawtypes", "unchecked" }) +public class RequestParam { + + private interface GetValue { + + Object apply(Request req, Response rsp, Route.Chain chain, RequestParam param) throws Exception; + + } + + private static final TypeLiteral
headerType = TypeLiteral.get(Header.class); + + private static final TypeLiteral bodyType = TypeLiteral.get(Body.class); + + private static final TypeLiteral localType = TypeLiteral.get(Local.class); + + private static final TypeLiteral flashType = TypeLiteral.get(Flash.class); + + private static final Map injector; + + static { + Builder builder = ImmutableMap. builder(); + /** + * Body + */ + builder.put(bodyType, (req, rsp, chain, param) -> req.body().to(param.type)); + /** + * Request + */ + builder.put(TypeLiteral.get(Request.class), (req, rsp, chain, param) -> req); + /** + * Route + */ + builder.put(TypeLiteral.get(Route.class), (req, rsp, chain, param) -> req.route()); + /** + * Response + */ + builder.put(TypeLiteral.get(Response.class), (req, rsp, chain, param) -> rsp); + /** + * Route.Chain + */ + builder.put(TypeLiteral.get(Route.Chain.class), (req, rsp, chain, param) -> chain); + /** + * Session + */ + builder.put(TypeLiteral.get(Session.class), (req, rsp, chain, param) -> req.session()); + builder.put(TypeLiteral.get(Types.newParameterizedType(Optional.class, Session.class)), + (req, rsp, chain, param) -> req.ifSession()); + + /** + * Files + */ + builder.put(TypeLiteral.get(Upload.class), (req, rsp, chain, param) -> req.file(param.name)); + builder.put(TypeLiteral.get(Types.newParameterizedType(Optional.class, Upload.class)), + (req, rsp, chain, param) -> { + List files = req.files(param.name); + return files.size() == 0 ? Optional.empty() : Optional.of(files.get(0)); + }); + builder.put(TypeLiteral.get(Types.newParameterizedType(List.class, Upload.class)), + (req, rsp, chain, param) -> req.files(param.name)); + + /** + * Cookie + */ + builder.put(TypeLiteral.get(Cookie.class), (req, rsp, chain, param) -> req.cookies().stream() + .filter(c -> c.name().equalsIgnoreCase(param.name)).findFirst().get()); + builder.put(TypeLiteral.get(Types.listOf(Cookie.class)), (req, rsp, chain, param) -> req.cookies()); + builder.put(TypeLiteral.get(Types.newParameterizedType(Optional.class, Cookie.class)), + (req, rsp, chain, param) -> req.cookies().stream() + .filter(c -> c.name().equalsIgnoreCase(param.name)).findFirst()); + /** + * Header + */ + builder.put(headerType, (req, rsp, chain, param) -> req.header(param.name).to(param.type)); + + /** + * Local + */ + builder.put(localType, (req, rsp, chain, param) -> { + Optional local = req.ifGet(param.name); + if (param.optional) { + return local; + } + if(local.isPresent()) { + return local.get(); + } + if (param.type.getRawType() == Map.class) { + return req.attributes(); + } + throw new Err(Status.SERVER_ERROR, "Could not find required local '" + param.name + "', which was required on " + req.path()); + }); + + /** + * Flash + */ + builder.put(flashType, (req, rsp, chain, param) -> { + Class rawType = param.type.getRawType(); + if (Map.class.isAssignableFrom(rawType)) { + return req.flash(); + } + return param.optional ? req.ifFlash(param.name) : req.flash(param.name); + }); + + injector = builder.build(); + } + + public final String name; + + public final TypeLiteral type; + + private final GetValue strategy; + + private boolean optional; + + public RequestParam(final Parameter parameter, final String name) { + this(parameter, name, parameter.getParameterizedType()); + } + + public RequestParam(final AnnotatedElement elem, final String name, final Type type) { + this.name = name; + this.type = TypeLiteral.get(type); + this.optional = this.type.getRawType() == Optional.class; + final TypeLiteral strategyType; + if (elem.getAnnotation(Header.class) != null) { + strategyType = headerType; + } else if (elem.getAnnotation(Body.class) != null) { + strategyType = bodyType; + } else if (elem.getAnnotation(Local.class) != null) { + strategyType = localType; + } else if (elem.getAnnotation(Flash.class) != null) { + strategyType = flashType; + } else { + strategyType = this.type; + } + this.strategy = injector.getOrDefault(strategyType, param()); + } + + public Object value(final Request req, final Response rsp, final Route.Chain chain) throws Throwable { + return strategy.apply(req, rsp, chain, this); + } + + public static String nameFor(final Parameter param) { + String name = findName(param); + return name == null ? (param.isNamePresent() ? param.getName() : null) : name; + } + + private static String findName(final AnnotatedElement elem) { + Named named = elem.getAnnotation(Named.class); + if (named == null) { + com.google.inject.name.Named gnamed = elem + .getAnnotation(com.google.inject.name.Named.class); + if (gnamed == null) { + Header header = elem.getAnnotation(Header.class); + if (header == null) { + return null; + } + return Strings.emptyToNull(header.value()); + } + return gnamed.value(); + } + return Strings.emptyToNull(named.value()); + } + + private static final GetValue param() { + return (req, rsp, chain, param) -> { + Mutant mutant = req.param(param.name); + if (mutant.isSet() || param.optional) { + return mutant.to(param.type); + } + try { + return req.params().to(param.type); + } catch (Err ex) { + // force parsing + return mutant.to(param.type); + } + }; + } +} diff --git a/jooby/src/main/java/org/jooby/internal/mvc/RequestParamNameProviderImpl.java b/jooby/src/main/java/org/jooby/internal/mvc/RequestParamNameProviderImpl.java new file mode 100644 index 00000000..73a7bdae --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/mvc/RequestParamNameProviderImpl.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.mvc; + +import java.lang.reflect.Executable; +import java.lang.reflect.Parameter; +import java.util.stream.IntStream; + +import org.jooby.internal.ParameterNameProvider; + +public class RequestParamNameProviderImpl { + + private ParameterNameProvider nameProvider; + + public RequestParamNameProviderImpl(final ParameterNameProvider nameProvider) { + this.nameProvider = nameProvider; + } + + public String name(final Parameter parameter) { + String name = RequestParam.nameFor(parameter); + if (name != null) { + return name; + } + // asm + Executable exec = parameter.getDeclaringExecutable(); + Parameter[] params = exec.getParameters(); + int idx = IntStream.range(0, params.length) + .filter(i -> params[i].equals(parameter)) + .findFirst() + .getAsInt(); + String[] names = nameProvider.names(exec); + return names[idx]; + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/mvc/RequestParamProvider.java b/jooby/src/main/java/org/jooby/internal/mvc/RequestParamProvider.java new file mode 100644 index 00000000..d12902f7 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/mvc/RequestParamProvider.java @@ -0,0 +1,25 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.mvc; + +import java.lang.reflect.Executable; +import java.util.List; + +public interface RequestParamProvider { + + List parameters(Executable exec); + +} diff --git a/jooby/src/main/java/org/jooby/internal/mvc/RequestParamProviderImpl.java b/jooby/src/main/java/org/jooby/internal/mvc/RequestParamProviderImpl.java new file mode 100644 index 00000000..1788bd0a --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/mvc/RequestParamProviderImpl.java @@ -0,0 +1,50 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.mvc; + +import static java.util.Objects.requireNonNull; + +import java.lang.reflect.Executable; +import java.lang.reflect.Parameter; +import java.util.Collections; +import java.util.List; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableList.Builder; + +public class RequestParamProviderImpl implements RequestParamProvider { + + private RequestParamNameProviderImpl provider; + + public RequestParamProviderImpl(final RequestParamNameProviderImpl provider) { + this.provider = requireNonNull(provider, "Parameter name provider is required."); + } + + @Override + public List parameters(final Executable exec) { + Parameter[] parameters = exec.getParameters(); + if (parameters.length == 0) { + return Collections.emptyList(); + } + + Builder builder = ImmutableList.builder(); + for (Parameter parameter : parameters) { + builder.add(new RequestParam(parameter, provider.name(parameter))); + } + return builder.build(); + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/parser/BeanParser.java b/jooby/src/main/java/org/jooby/internal/parser/BeanParser.java new file mode 100644 index 00000000..fd9280e4 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/parser/BeanParser.java @@ -0,0 +1,115 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.parser; + +import com.google.common.primitives.Primitives; +import com.google.common.reflect.Reflection; +import com.google.inject.TypeLiteral; +import org.jooby.Err; +import org.jooby.Mutant; +import org.jooby.Parser; +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Route; +import org.jooby.internal.ParameterNameProvider; +import org.jooby.internal.mvc.RequestParam; +import org.jooby.internal.parser.bean.BeanPlan; +import org.jooby.funzy.Try; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +public class BeanParser implements Parser { + + private Function> MISSING = x -> { + return x instanceof Err.Missing ? Try.success(null) : Try.failure(x); + }; + + private Function> RETHROW = Try::failure; + + private Function> recoverMissing; + + @SuppressWarnings("rawtypes") + private final Map forms; + + public BeanParser(final boolean allowNulls) { + this.recoverMissing = allowNulls ? MISSING : RETHROW; + this.forms = new ConcurrentHashMap<>(); + } + + @Override + public Object parse(final TypeLiteral type, final Context ctx) throws Throwable { + Class beanType = type.getRawType(); + if (Primitives.isWrapperType(Primitives.wrap(beanType)) + || CharSequence.class.isAssignableFrom(beanType)) { + return ctx.next(); + } + return ctx.ifparams(map -> { + final Object bean; + if (List.class.isAssignableFrom(beanType)) { + bean = newBean(ctx.require(Request.class), ctx.require(Response.class), + ctx.require(Route.Chain.class), map, type); + } else if (beanType.isInterface()) { + bean = newBeanInterface(ctx.require(Request.class), ctx.require(Response.class), + ctx.require(Route.Chain.class), beanType); + } else { + bean = newBean(ctx.require(Request.class), ctx.require(Response.class), + ctx.require(Route.Chain.class), map, type); + } + + return bean; + }); + } + + @Override + public String toString() { + return "bean"; + } + + @SuppressWarnings("rawtypes") + private Object newBean(final Request req, final Response rsp, final Route.Chain chain, + final Map params, final TypeLiteral type) throws Throwable { + BeanPlan form = forms.get(type); + if (form == null) { + form = new BeanPlan(req.require(ParameterNameProvider.class), type); + forms.put(type, form); + } + return form.newBean(p -> value(p, req, rsp, chain), params.keySet()); + } + + private Object newBeanInterface(final Request req, final Response rsp, final Route.Chain chain, + final Class beanType) { + return Reflection.newProxy(beanType, (proxy, method, args) -> { + StringBuilder name = new StringBuilder(method.getName() + .replace("get", "") + .replace("is", "")); + name.setCharAt(0, Character.toLowerCase(name.charAt(0))); + return value(new RequestParam(method, name.toString(), method.getGenericReturnType()), req, + rsp, chain); + }); + } + + private Object value(final RequestParam param, final Request req, final Response rsp, + final Route.Chain chain) + throws Throwable { + return Try.apply(() -> param.value(req, rsp, chain)) + .recover(x -> recoverMissing.apply(x).get()) + .get(); + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/parser/DateParser.java b/jooby/src/main/java/org/jooby/internal/parser/DateParser.java new file mode 100644 index 00000000..84ae18bd --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/parser/DateParser.java @@ -0,0 +1,58 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.parser; + +import static java.util.Objects.requireNonNull; + +import java.text.SimpleDateFormat; +import java.util.Date; + +import org.jooby.Parser; + +import com.google.inject.TypeLiteral; + +public class DateParser implements Parser { + + private String dateFormat; + + public DateParser(final String dateFormat) { + this.dateFormat = requireNonNull(dateFormat, "A dateFormat is required."); + } + + @Override + public Object parse(final TypeLiteral type, final Parser.Context ctx) throws Throwable { + if (type.getRawType() == Date.class) { + return ctx + .param(values -> parse(dateFormat, NOT_EMPTY.apply(values.get(0)))) + .body(body -> parse(dateFormat, NOT_EMPTY.apply(body.text()))); + } else { + return ctx.next(); + } + } + + @Override + public String toString() { + return "Date"; + } + + private static Date parse(final String dateFormat, final String value) throws Throwable { + try { + return new Date(Long.parseLong(value)); + } catch (NumberFormatException ex) { + return new SimpleDateFormat(dateFormat).parse(value); + } + } +} diff --git a/jooby/src/main/java/org/jooby/internal/parser/LocalDateParser.java b/jooby/src/main/java/org/jooby/internal/parser/LocalDateParser.java new file mode 100644 index 00000000..9df748df --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/parser/LocalDateParser.java @@ -0,0 +1,71 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.parser; + +import static java.util.Objects.requireNonNull; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Optional; + +import javax.inject.Inject; + +import org.jooby.Parser; + +import com.google.inject.TypeLiteral; + +public class LocalDateParser implements Parser { + + private DateTimeFormatter formatter; + + @Inject + public LocalDateParser(final DateTimeFormatter formatter) { + this.formatter = requireNonNull(formatter, "A date time formatter is required."); + } + + @Override + public Object parse(final TypeLiteral type, final Parser.Context ctx) throws Throwable { + if (type.getRawType() == LocalDate.class) { + return ctx + .param(values -> parse(formatter, NOT_EMPTY.apply(values.get(0)))) + .body(body -> parse(formatter, NOT_EMPTY.apply(body.text()))); + } else { + return ctx.next(); + } + } + + @Override + public String toString() { + return "LocalDate"; + } + + private static LocalDate parse(final DateTimeFormatter formatter, final String value) { + try { + Instant epoch = Instant.ofEpochMilli(Long.parseLong(value)); + ZonedDateTime zonedDate = epoch.atZone( + Optional.ofNullable(formatter.getZone()) + .orElse(ZoneId.systemDefault()) + ); + return zonedDate.toLocalDate(); + } catch (NumberFormatException ex) { + return LocalDate.parse(value, formatter); + } + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/parser/LocaleParser.java b/jooby/src/main/java/org/jooby/internal/parser/LocaleParser.java new file mode 100644 index 00000000..bc99b8f2 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/parser/LocaleParser.java @@ -0,0 +1,43 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.parser; + +import java.util.Locale; + +import org.jooby.Parser; +import org.jooby.internal.LocaleUtils; + +import com.google.inject.TypeLiteral; + +public class LocaleParser implements Parser { + + @Override + public Object parse(final TypeLiteral type, final Parser.Context ctx) throws Throwable { + if (Locale.class == type.getRawType()) { + return ctx + .param(values -> LocaleUtils.parse(NOT_EMPTY.apply(values.get(0)))) + .body(body -> LocaleUtils.parseOne(NOT_EMPTY.apply(body.text()))); + } else { + return ctx.next(); + } + } + + @Override + public String toString() { + return "Locale"; + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/parser/ParserBuilder.java b/jooby/src/main/java/org/jooby/internal/parser/ParserBuilder.java new file mode 100644 index 00000000..ba3bc77a --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/parser/ParserBuilder.java @@ -0,0 +1,103 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.parser; + +import java.util.Map; + +import org.jooby.Mutant; +import org.jooby.Parser; +import org.jooby.Parser.Builder; +import org.jooby.Parser.Callback; +import org.jooby.internal.BodyReferenceImpl; +import org.jooby.internal.EmptyBodyReference; +import org.jooby.internal.StrParamReferenceImpl; + +import com.google.common.collect.ImmutableMap; +import com.google.inject.TypeLiteral; + +@SuppressWarnings("rawtypes") +public class ParserBuilder implements Parser.Builder { + + private ImmutableMap.Builder, Parser.Callback> strategies = ImmutableMap + .builder(); + + public final TypeLiteral toType; + + private final TypeLiteral type; + + public final Object value; + + private Parser.Context ctx; + + public ParserBuilder(final Parser.Context ctx, final TypeLiteral toType, final Object value) { + this.ctx = ctx; + this.toType = toType; + this.type = typeOf(value); + this.value = value; + } + + private TypeLiteral typeOf(final Object value) { + if (value instanceof Map) { + return TypeLiteral.get(Map.class); + } + return TypeLiteral.get(value.getClass()); + } + + @Override + public Builder body(final Callback callback) { + strategies.put(TypeLiteral.get(BodyReferenceImpl.class), callback); + strategies.put(TypeLiteral.get(EmptyBodyReference.class), callback); + return this; + } + + @Override + public Builder ifbody(final Callback callback) { + return body(callback); + } + + @Override + public Builder param(final Callback> callback) { + strategies.put(TypeLiteral.get(StrParamReferenceImpl.class), callback); + return this; + } + + @Override + public Builder ifparam(final Callback> callback) { + return param(callback); + } + + @Override + public Builder params(final Callback> callback) { + strategies.put(TypeLiteral.get(Map.class), callback); + return this; + } + + @Override + public Builder ifparams(final Callback> callback) { + return params(callback); + } + + @SuppressWarnings("unchecked") + public Object parse() throws Throwable { + Map, Callback> map = strategies.build(); + Callback callback = map.get(type); + if (callback == null) { + return ctx.next(toType, value); + } + return callback.invoke(value); + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/parser/ParserExecutor.java b/jooby/src/main/java/org/jooby/internal/parser/ParserExecutor.java new file mode 100644 index 00000000..49cab5bf --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/parser/ParserExecutor.java @@ -0,0 +1,185 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.parser; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.inject.Inject; + +import org.jooby.MediaType; +import org.jooby.Mutant; +import org.jooby.Parser; +import org.jooby.Parser.BodyReference; +import org.jooby.Parser.Builder; +import org.jooby.Parser.Callback; +import org.jooby.Parser.ParamReference; +import org.jooby.Status; +import org.jooby.internal.StatusCodeProvider; +import org.jooby.internal.StrParamReferenceImpl; + +import com.google.common.collect.ImmutableList; +import com.google.inject.Injector; +import com.google.inject.Key; +import com.google.inject.TypeLiteral; + +public class ParserExecutor { + + public static final Object NO_PARSER = new Object(); + + private List parsers; + + private Injector injector; + + private StatusCodeProvider sc; + + @Inject + public ParserExecutor(final Injector injector, final Set parsers, + final StatusCodeProvider sc) { + this.injector = injector; + this.parsers = ImmutableList.copyOf(parsers); + this.sc = sc; + } + + public Status statusCode(final Throwable cause) { + return sc.apply(cause); + } + + public T convert(final TypeLiteral type, final Object data) throws Throwable { + return convert(type, MediaType.plain, data); + } + + @SuppressWarnings("unchecked") + public T convert(final TypeLiteral type, final MediaType contentType, final Object data) + throws Throwable { + Object result = ctx(injector, contentType, type, parsers, data).next(type, data); + return (T) result; + } + + private static Parser.Context ctx(final Injector injector, + final MediaType contentType, final TypeLiteral seedType, final List parsers, + final Object seed) { + return new Parser.Context() { + int cursor = 0; + + TypeLiteral type = seedType; + + ParserBuilder builder = new ParserBuilder(this, type, seed); + + @Override + public MediaType type() { + return contentType; + } + + @Override + public Builder body(final Callback callback) { + return builder.body(callback); + } + + @Override + public Builder ifbody(final Callback callback) { + return builder.ifbody(callback); + } + + @Override + public Builder param(final Callback> callback) { + return builder.param(callback); + } + + @Override + public Builder ifparam(final Callback> callback) { + return builder.ifparam(callback); + } + + @Override + public Builder params(final Callback> callback) { + return builder.params(callback); + } + + @Override + public Builder ifparams(final Callback> callback) { + return builder.ifparams(callback); + } + + @Override + public Object next() throws Throwable { + return next(builder.toType, builder.value); + } + + @Override + public Object next(final TypeLiteral type) throws Throwable { + return next(type, builder.value); + } + + @Override + public Object next(final TypeLiteral nexttype, final Object nextval) + throws Throwable { + if (cursor == parsers.size()) { + return NO_PARSER; + } + if (!type.equals(nexttype)) { + // reset cursor on type changes. + cursor = 0; + type = nexttype; + } + Parser next = parsers.get(cursor); + cursor += 1; + ParserBuilder current = builder; + builder = new ParserBuilder(this, nexttype, wrap(nextval, builder.value)); + Object result = next.parse(nexttype, this); + if (result instanceof ParserBuilder) { + // call a parse + result = ((ParserBuilder) result).parse(); + } + builder = current; + cursor -= 1; + return result; + } + + @SuppressWarnings("rawtypes") + private Object wrap(final Object nextval, final Object value) { + if (nextval instanceof String) { + ParamReference pref = (ParamReference) value; + return new StrParamReferenceImpl(pref.type(), pref.name(), + ImmutableList.of((String) nextval)); + } + return nextval; + } + + @Override + public T require(final Key key) { + return injector.getInstance(key); + } + + @Override + public T require(final Class type) { + return injector.getInstance(type); + } + + @Override + public T require(final TypeLiteral type) { + return injector.getInstance(Key.get(type)); + } + + @Override + public String toString() { + return parsers.toString(); + } + }; + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/parser/StaticMethodParser.java b/jooby/src/main/java/org/jooby/internal/parser/StaticMethodParser.java new file mode 100644 index 00000000..377445a0 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/parser/StaticMethodParser.java @@ -0,0 +1,69 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.parser; + +import static java.util.Objects.requireNonNull; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; + +import org.jooby.Parser; + +import com.google.inject.TypeLiteral; + +public class StaticMethodParser implements Parser { + + private final String methodName; + + public StaticMethodParser(final String methodName) { + this.methodName = requireNonNull(methodName, "A method's name is required."); + } + + public boolean matches(final TypeLiteral toType) { + try { + return method(toType.getRawType()) != null; + } catch (NoSuchMethodException x) { + return false; + } + } + + @Override + public Object parse(final TypeLiteral type, final Parser.Context ctx) throws Exception { + return ctx.param(params -> { + try { + return method(type.getRawType()).invoke(null, params.get(0)); + } catch (NoSuchMethodException x) { + return ctx.next(); + } + }); + } + + public Object parse(final TypeLiteral type, final Object value) throws Exception { + return method(type.getRawType()).invoke(null, value); + } + + private Method method(final Class rawType) throws NoSuchMethodException { + Method method = rawType.getDeclaredMethod(methodName, String.class); + int mods = method.getModifiers(); + return Modifier.isPublic(mods) && Modifier.isStatic(mods) ? method : null; + } + + @Override + public String toString() { + return methodName + "(String)"; + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/parser/StringConstructorParser.java b/jooby/src/main/java/org/jooby/internal/parser/StringConstructorParser.java new file mode 100644 index 00000000..55bf6c11 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/parser/StringConstructorParser.java @@ -0,0 +1,60 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.parser; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Modifier; + +import org.jooby.Parser; + +import com.google.inject.TypeLiteral; + +public class StringConstructorParser implements Parser { + + public boolean matches(final TypeLiteral toType) { + try { + return constructor(toType.getRawType()) != null; + } catch (NoSuchMethodException x) { + return false; + } + } + + @Override + public Object parse(final TypeLiteral type, final Parser.Context ctx) throws Exception { + return ctx.param(params -> { + try { + return constructor(type.getRawType()).newInstance(params.get(0)); + } catch (NoSuchMethodException x) { + return ctx.next(); + } + }); + } + + @Override + public String toString() { + return "init(String)"; + } + + public static Object parse(final TypeLiteral type, final Object data) throws Exception { + return constructor(type.getRawType()).newInstance(data); + } + + private static Constructor constructor(final Class rawType) throws NoSuchMethodException { + Constructor constructor = rawType.getDeclaredConstructor(String.class); + return Modifier.isPublic(constructor.getModifiers()) ? constructor : null; + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/parser/ZonedDateTimeParser.java b/jooby/src/main/java/org/jooby/internal/parser/ZonedDateTimeParser.java new file mode 100644 index 00000000..b6aa752f --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/parser/ZonedDateTimeParser.java @@ -0,0 +1,55 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.parser; + +import com.google.inject.TypeLiteral; +import static java.util.Objects.requireNonNull; +import org.jooby.Parser; + +import javax.inject.Inject; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; + +public class ZonedDateTimeParser implements Parser { + + private DateTimeFormatter formatter; + + @Inject + public ZonedDateTimeParser(final DateTimeFormatter formatter) { + this.formatter = requireNonNull(formatter, "A date time formatter is required."); + } + + @Override + public Object parse(final TypeLiteral type, final Context ctx) throws Throwable { + if (type.getRawType() == ZonedDateTime.class) { + return ctx + .param(values -> parse(formatter, NOT_EMPTY.apply(values.get(0)))) + .body(body -> parse(formatter, NOT_EMPTY.apply(body.text()))); + } else { + return ctx.next(); + } + } + + @Override + public String toString() { + return "ZonedDateTime"; + } + + private static ZonedDateTime parse(final DateTimeFormatter formatter, final String value) { + return ZonedDateTime.parse(value, formatter); + } + +} diff --git a/jooby/src/main/java/org/jooby/internal/parser/bean/BeanComplexPath.java b/jooby/src/main/java/org/jooby/internal/parser/bean/BeanComplexPath.java new file mode 100644 index 00000000..10885174 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/parser/bean/BeanComplexPath.java @@ -0,0 +1,77 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.parser.bean; + +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Type; +import java.util.List; + +@SuppressWarnings("rawtypes") +class BeanComplexPath implements BeanPath { + + private List chain; + + private BeanPath setter; + + private String path; + + public BeanComplexPath(final List chain, final BeanPath setter, + final String path) { + this.chain = chain; + this.setter = setter; + this.path = path; + } + + @Override + public void set(final Object bean, final Object... args) throws Throwable { + Object target = get(bean); + setter.set(target, args); + } + + @Override + public Object get(final Object bean, final Object... args) throws Throwable { + Object target = bean; + for (BeanPath path : chain) { + Object next = path.get(target, args); + if (next == null) { + next = ((Class) path.settype()).newInstance(); + path.set(target, next); + } + target = next; + } + return target; + } + + @Override + public AnnotatedElement setelem() { + return setter.setelem(); + } + + @Override + public Type settype() { + return setter.settype(); + } + + @Override + public Type type() { + return setter.type(); + } + + @Override + public String toString() { + return path; + } +} \ No newline at end of file diff --git a/jooby/src/main/java/org/jooby/internal/parser/bean/BeanFieldPath.java b/jooby/src/main/java/org/jooby/internal/parser/bean/BeanFieldPath.java new file mode 100644 index 00000000..64ac72dd --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/parser/bean/BeanFieldPath.java @@ -0,0 +1,58 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.parser.bean; + +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Field; +import java.lang.reflect.Type; + +class BeanFieldPath implements BeanPath { + private String path; + + private Field field; + + public BeanFieldPath(final String path, final Field field) { + this.path = path; + this.field = field; + this.field.setAccessible(true); + } + + @Override + public void set(final Object bean, final Object... args) + throws Throwable { + field.set(bean, args[0]); + } + + @Override + public Object get(final Object bean, final Object... args) throws Throwable { + return field.get(bean); + } + + @Override + public Type type() { + return field.getGenericType(); + } + + @Override + public AnnotatedElement setelem() { + return field; + } + + @Override + public String toString() { + return path; + } +} \ No newline at end of file diff --git a/jooby/src/main/java/org/jooby/internal/parser/bean/BeanIndexedPath.java b/jooby/src/main/java/org/jooby/internal/parser/bean/BeanIndexedPath.java new file mode 100644 index 00000000..282c3ce8 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/parser/bean/BeanIndexedPath.java @@ -0,0 +1,92 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.parser.bean; + +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; + +import com.google.inject.TypeLiteral; + +import javax.annotation.Nullable; + +@SuppressWarnings("rawtypes") +class BeanIndexedPath implements BeanPath { + private int index; + + private BeanPath path; + + private Type type; + + public BeanIndexedPath(@Nullable final BeanPath path, final int index, final TypeLiteral ittype) { + this(path, index, path == null ? ittype.getType() : path.type()); + } + + public BeanIndexedPath(final BeanPath path, final int index, final Type ittype) { + this.path = path; + this.index = index; + this.type = ((ParameterizedType) ittype).getActualTypeArguments()[0]; + } + + @SuppressWarnings("unchecked") + @Override + public void set(final Object bean, final Object... args) throws Throwable { + List list = list(bean); + list.add(args[0]); + } + + @SuppressWarnings("unchecked") + @Override + public Object get(final Object bean, final Object... args) throws Throwable { + List list = list(bean); + if (index >= list.size()) { + Object item = ((Class) settype()).newInstance(); + list.add(item); + } + return list.get(index); + } + + private List list(final Object bean) throws Throwable { + List list = (List) (path == null ? bean : path.get(bean)); + if (list == null) { + list = new ArrayList<>(); + path.set(bean, list); + } + return list; + } + + @Override + public AnnotatedElement setelem() { + return path.setelem(); + } + + @Override + public Type settype() { + return type; + } + + @Override + public Type type() { + return type; + } + + @Override + public String toString() { + return (path == null ? "" : path.toString()) + "[" + index + "]"; + } +} diff --git a/jooby/src/main/java/org/jooby/internal/parser/bean/BeanMethodPath.java b/jooby/src/main/java/org/jooby/internal/parser/bean/BeanMethodPath.java new file mode 100644 index 00000000..adc8f1b9 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/parser/bean/BeanMethodPath.java @@ -0,0 +1,72 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.parser.bean; + +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Method; +import java.lang.reflect.Type; + +class BeanMethodPath implements BeanPath { + private String path; + + private Method method; + + BeanPath setter; + + public BeanMethodPath(final String path, final Method method) { + this.path = path; + this.method = method; + this.method.setAccessible(true); + } + + @Override + public void set(final Object bean, final Object... args) + throws Throwable { + if (setter != null) { + setter.set(bean, args); + } else { + method.invoke(bean, args); + } + } + + @Override + public Object get(final Object bean, final Object... args) throws Throwable { + return method.invoke(bean, args); + } + + @Override + public Type settype() { + if (method.getParameterCount() == 0) { + return method.getGenericReturnType(); + } + return method.getGenericParameterTypes()[0]; + } + + @Override + public Type type() { + return method.getGenericReturnType(); + } + + @Override + public AnnotatedElement setelem() { + return method; + } + + @Override + public String toString() { + return path; + } +} \ No newline at end of file diff --git a/jooby/src/main/java/org/jooby/internal/parser/bean/BeanPath.java b/jooby/src/main/java/org/jooby/internal/parser/bean/BeanPath.java new file mode 100644 index 00000000..988c73c8 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/parser/bean/BeanPath.java @@ -0,0 +1,34 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.parser.bean; + +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Type; + +interface BeanPath { + + Type type(); + + Object get(Object bean, Object... args) throws Throwable; + + void set(Object bean, Object... args) throws Throwable; + + AnnotatedElement setelem(); + + default Type settype() { + return type(); + } +} \ No newline at end of file diff --git a/jooby/src/main/java/org/jooby/internal/parser/bean/BeanPlan.java b/jooby/src/main/java/org/jooby/internal/parser/bean/BeanPlan.java new file mode 100644 index 00000000..9e05d627 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/parser/bean/BeanPlan.java @@ -0,0 +1,247 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.parser.bean; + +import com.google.common.base.CharMatcher; +import com.google.common.base.Splitter; +import com.google.inject.TypeLiteral; +import org.jooby.Request; +import org.jooby.internal.ParameterNameProvider; +import org.jooby.internal.mvc.RequestParam; +import org.jooby.internal.mvc.RequestParamNameProviderImpl; +import org.jooby.internal.mvc.RequestParamProviderImpl; +import org.jooby.funzy.Throwing; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.inject.Inject; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +@SuppressWarnings("rawtypes") +public class BeanPlan { + + /** The logging system. */ + private final Logger log = LoggerFactory.getLogger(Request.class); + + private Constructor constructor; + + private List parameters; + + private TypeLiteral beanType; + + private Map cache = new ConcurrentHashMap<>(); + + @SuppressWarnings("unchecked") + public BeanPlan(final ParameterNameProvider classInfo, final Class beanType) { + this(classInfo, TypeLiteral.get(beanType)); + } + + public BeanPlan(final ParameterNameProvider classInfo, final TypeLiteral beanType) { + Constructor inject = null, def = null; + Class rawType = beanType.getRawType(); + if (rawType == List.class) { + rawType = ArrayList.class; + } + Constructor[] cons = rawType.getDeclaredConstructors(); + if (cons.length == 1) { + def = cons[0]; + } else { + for (Constructor c : cons) { + if (c.isAnnotationPresent(Inject.class)) { + if (inject != null) { + throw new IllegalStateException( + "Ambigous constructor found: " + rawType.getName() + + ". Only one @" + Inject.class.getName() + " allowed"); + } + inject = c; + } else if (c.getParameterCount() == 0) { + def = c; + } + } + } + Constructor constructor = inject == null ? def : inject; + if (constructor == null) { + throw new IllegalStateException("Ambigous constructor found: " + rawType.getName() + + ". Bean/Form type must have a no-args constructor or must be annotated with @" + + Inject.class.getName()); + } + this.beanType = beanType; + this.constructor = constructor; + this.parameters = new RequestParamProviderImpl(new RequestParamNameProviderImpl(classInfo)) + .parameters(constructor); + } + + public Object newBean(final Throwing.Function lookup, + final Set params) throws Throwable { + log.debug("instantiating object {}", constructor); + + Object[] args = new Object[parameters.size()]; + List names = new ArrayList<>(params); + // remove constructor injected params + for (int i = 0; i < args.length; i++) { + RequestParam param = parameters.get(i); + args[i] = lookup.apply(param); + // skip constructor injected param (don't override) + names.remove(param.name); + } + Object bean = constructor.newInstance(args); + + List paths = compile(names.stream().sorted().iterator(), beanType); + for (BeanPath path : paths) { + String rawpath = path.toString(); + log.debug(" setting {}", rawpath); + path.set(bean, lookup.apply(new RequestParam(path.setelem(), rawpath, path.settype()))); + } + return bean; + } + + private List compile(final Iterator it, final TypeLiteral beanType) { + List result = new ArrayList<>(); + while (it.hasNext()) { + String path = it.next(); + List ckey = Arrays.asList(beanType, path); + BeanPath cached = cache.get(ckey); + if (cached == null) { + List segments = segments(path); + List chain = new ArrayList<>(); + // traverse path + TypeLiteral ittype = beanType; + for (int i = 0; i < segments.size() - 1; i++) { + Object[] segment = segments.get(i); + final BeanPath cpath; + if (segment[1] != null) { + if (segment[0] == null) { + cpath = new BeanIndexedPath(null, (Integer) segment[1], ittype); + } else { + BeanPath getter = member("get", (String) segment[0], ittype, 0); + if (getter instanceof BeanMethodPath) { + ((BeanMethodPath) getter).setter = member("set", (String) segment[0], ittype, 1); + } + cpath = new BeanIndexedPath(getter, (Integer) segment[1], ittype); + } + } else { + BeanPath getter = member("get", (String) segment[0], ittype, 0); + if (getter instanceof BeanMethodPath) { + ((BeanMethodPath) getter).setter = member("set", (String) segment[0], ittype, 1); + } + cpath = getter; + } + if (cpath != null) { + chain.add(cpath); + ittype = TypeLiteral.get(cpath.type()); + } + } + + // set path + Object[] last = segments.get(segments.size() - 1); + BeanPath cpath = member("set", (String) last[0], ittype, 1); + if (cpath != null) { + if (last[1] != null) { + BeanPath getter = member("get", (String) last[0], ittype, 0); + if (getter instanceof BeanMethodPath) { + ((BeanMethodPath) getter).setter = cpath; + } + cpath = new BeanIndexedPath(getter, (Integer) last[1], ittype); + } + if (chain.size() == 0) { + cached = cpath; + } else { + cached = new BeanComplexPath(chain, cpath, path); + } + cache.put(ckey, cached); + } + } + if (cached != null) { + result.add(cached); + } + } + return result; + } + + private List segments(final String path) { + List segments = Splitter.on(CharMatcher.anyOf("[].")).trimResults() + .omitEmptyStrings() + .splitToList(path); + List result = new ArrayList<>(segments.size()); + for (int i = 0; i < segments.size(); i++) { + String segment = segments.get(i); + try { + int idx = Integer.parseInt(segment); + if (result.size() > 0) { + result.set(result.size() - 1, new Object[]{result.get(result.size() - 1)[0], idx}); + } else { + result.add(new Object[]{null, idx}); + } + } catch (NumberFormatException x) { + result.add(new Object[]{segment, null}); + } + } + + return result; + } + + private BeanPath member(final String prefix, final String name, final TypeLiteral root, + final int pcount) { + Class rawType = root.getRawType(); + BeanPath fn = method(prefix, name, rawType.getDeclaredMethods(), pcount); + if (fn == null) { + fn = field(name, rawType.getDeclaredFields()); + // superclass lookup? + if (fn == null) { + Class superclass = rawType.getSuperclass(); + if (superclass != Object.class) { + return member(prefix, name, TypeLiteral.get(rawType.getGenericSuperclass()), pcount); + } + } + } + return fn; + } + + private BeanFieldPath field(final String name, final Field[] fields) { + for (Field f : fields) { + if (f.getName().equals(name)) { + return new BeanFieldPath(name, f); + } + } + return null; + } + + private BeanMethodPath method(final String prefix, final String name, final Method[] methods, + final int pcount) { + String bname = javaBeanMethod(new StringBuilder(prefix), name); + for (Method m : methods) { + String mname = m.getName(); + if ((bname.equals(mname) || name.equals(mname)) && m.getParameterCount() == pcount) { + return new BeanMethodPath(name, m); + } + } + return null; + } + + private String javaBeanMethod(final StringBuilder prefix, final String name) { + return prefix.append(Character.toUpperCase(name.charAt(0))).append(name, 1, name.length()) + .toString(); + } +} diff --git a/jooby/src/main/java/org/jooby/internal/ssl/JdkSslContext.java b/jooby/src/main/java/org/jooby/internal/ssl/JdkSslContext.java new file mode 100644 index 00000000..4daac5fa --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/ssl/JdkSslContext.java @@ -0,0 +1,222 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.ssl; + +import java.io.File; +import java.io.IOException; +import java.security.InvalidAlgorithmParameterException; +import java.security.KeyException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.Security; +import java.security.UnrecoverableKeyException; +import java.security.cert.CertificateException; +import java.security.spec.InvalidKeySpecException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/* + * Copyright 2014 The Netty Project + * + * The Netty Project licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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. + */ + +import javax.crypto.NoSuchPaddingException; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLSessionContext; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * An {@link SslContext} which uses JDK's SSL/TLS implementation. + * Kindly Borrowed from Netty + */ +public abstract class JdkSslContext extends SslContext { + + /** The logging system. */ + private final static Logger logger = LoggerFactory.getLogger(JdkSslContext.class); + + static final String PROTOCOL = "TLS"; + static final String[] PROTOCOLS; + static final List DEFAULT_CIPHERS; + static final Set SUPPORTED_CIPHERS; + + private static final char[] EMPTY_CHARS = new char[0]; + + static { + SSLContext context; + int i; + try { + context = SSLContext.getInstance(PROTOCOL); + context.init(null, null, null); + } catch (Exception e) { + throw new Error("failed to initialize the default SSL context", e); + } + + SSLEngine engine = context.createSSLEngine(); + + // Choose the sensible default list of protocols. + final String[] supportedProtocols = engine.getSupportedProtocols(); + Set supportedProtocolsSet = new HashSet(supportedProtocols.length); + for (i = 0; i < supportedProtocols.length; ++i) { + supportedProtocolsSet.add(supportedProtocols[i]); + } + List protocols = new ArrayList(); + addIfSupported( + supportedProtocolsSet, protocols, + "TLSv1.2", "TLSv1.1", "TLSv1"); + + if (!protocols.isEmpty()) { + PROTOCOLS = protocols.toArray(new String[protocols.size()]); + } else { + PROTOCOLS = engine.getEnabledProtocols(); + } + + // Choose the sensible default list of cipher suites. + final String[] supportedCiphers = engine.getSupportedCipherSuites(); + SUPPORTED_CIPHERS = new HashSet(supportedCiphers.length); + for (i = 0; i < supportedCiphers.length; ++i) { + SUPPORTED_CIPHERS.add(supportedCiphers[i]); + } + List ciphers = new ArrayList(); + addIfSupported( + SUPPORTED_CIPHERS, ciphers, + // XXX: Make sure to sync this list with OpenSslEngineFactory. + // GCM (Galois/Counter Mode) requires JDK 8. + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", + // AES256 requires JCE unlimited strength jurisdiction policy files. + "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", + // GCM (Galois/Counter Mode) requires JDK 8. + "TLS_RSA_WITH_AES_128_GCM_SHA256", + "TLS_RSA_WITH_AES_128_CBC_SHA", + // AES256 requires JCE unlimited strength jurisdiction policy files. + "TLS_RSA_WITH_AES_256_CBC_SHA", + "SSL_RSA_WITH_3DES_EDE_CBC_SHA"); + + if (ciphers.isEmpty()) { + // Use the default from JDK as fallback. + for (String cipher : engine.getEnabledCipherSuites()) { + if (cipher.contains("_RC4_")) { + continue; + } + ciphers.add(cipher); + } + } + DEFAULT_CIPHERS = Collections.unmodifiableList(ciphers); + + if (logger.isDebugEnabled()) { + logger.debug("Default protocols (JDK): {} ", Arrays.asList(PROTOCOLS)); + logger.debug("Default cipher suites (JDK): {}", DEFAULT_CIPHERS); + } + } + + private static void addIfSupported(final Set supported, final List enabled, + final String... names) { + for (String n : names) { + if (supported.contains(n)) { + enabled.add(n); + } + } + } + + /** + * Returns the JDK {@link SSLSessionContext} object held by this context. + */ + @Override + public final SSLSessionContext sessionContext() { + return context().getServerSessionContext(); + } + + @Override + public final long sessionCacheSize() { + return sessionContext().getSessionCacheSize(); + } + + @Override + public final long sessionTimeout() { + return sessionContext().getSessionTimeout(); + } + + /** + * Build a {@link KeyManagerFactory} based upon a key file, key file password, and a certificate + * chain. + * + * @param certChainFile a X.509 certificate chain file in PEM format + * @param keyFile a PKCS#8 private key file in PEM format + * @param keyPassword the password of the {@code keyFile}. + * {@code null} if it's not password-protected. + * @param kmf The existing {@link KeyManagerFactory} that will be used if not {@code null} + * @return A {@link KeyManagerFactory} based upon a key file, key file password, and a certificate + * chain. + */ + protected static KeyManagerFactory buildKeyManagerFactory(final File certChainFile, + final File keyFile, final String keyPassword) + throws UnrecoverableKeyException, KeyStoreException, NoSuchAlgorithmException, + NoSuchPaddingException, InvalidKeySpecException, InvalidAlgorithmParameterException, + CertificateException, KeyException, IOException { + String algorithm = Security.getProperty("ssl.KeyManagerFactory.algorithm"); + if (algorithm == null) { + algorithm = "SunX509"; + } + return buildKeyManagerFactory(certChainFile, algorithm, keyFile, keyPassword); + } + + /** + * Build a {@link KeyManagerFactory} based upon a key algorithm, key file, key file password, + * and a certificate chain. + * + * @param certChainFile a X.509 certificate chain file in PEM format + * @param keyAlgorithm the standard name of the requested algorithm. See the Java Secure Socket + * Extension + * Reference Guide for information about standard algorithm names. + * @param keyFile a PKCS#8 private key file in PEM format + * @param keyPassword the password of the {@code keyFile}. + * {@code null} if it's not password-protected. + * @return A {@link KeyManagerFactory} based upon a key algorithm, key file, key file password, + * and a certificate chain. + */ + protected static KeyManagerFactory buildKeyManagerFactory(final File certChainFile, + final String keyAlgorithm, final File keyFile, final String keyPassword) + throws KeyStoreException, NoSuchAlgorithmException, NoSuchPaddingException, + InvalidKeySpecException, InvalidAlgorithmParameterException, IOException, + CertificateException, KeyException, UnrecoverableKeyException { + char[] keyPasswordChars = keyPassword == null ? EMPTY_CHARS : keyPassword.toCharArray(); + KeyStore ks = buildKeyStore(certChainFile, keyFile, keyPasswordChars); + KeyManagerFactory kmf = KeyManagerFactory.getInstance(keyAlgorithm); + kmf.init(ks, keyPasswordChars); + + return kmf; + } +} diff --git a/jooby/src/main/java/org/jooby/internal/ssl/JdkSslServerContext.java b/jooby/src/main/java/org/jooby/internal/ssl/JdkSslServerContext.java new file mode 100644 index 00000000..abd9ab53 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/ssl/JdkSslServerContext.java @@ -0,0 +1,115 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.ssl; + +import java.io.File; + +/* + * Copyright 2014 The Netty Project + * + * The Netty Project licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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. + */ + +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLSessionContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; + +/** + * A server-side {@link SslContext} which uses JDK's SSL/TLS implementation. + * + * Kindly Borrowed from Netty + */ +public final class JdkSslServerContext extends JdkSslContext { + + private final SSLContext ctx; + + /** + * Creates a new instance. + * + * @param trustCertChainFile an X.509 certificate chain file in PEM format. + * This provides the certificate chains used for mutual authentication. + * {@code null} to use the system default + * @param trustManagerFactory the {@link TrustManagerFactory} that provides the + * {@link TrustManager}s + * that verifies the certificates sent from clients. + * {@code null} to use the default or the results of parsing {@code trustCertChainFile} + * @param keyCertChainFile an X.509 certificate chain file in PEM format + * @param keyFile a PKCS#8 private key file in PEM format + * @param keyPassword the password of the {@code keyFile}. + * {@code null} if it's not password-protected. + * @param keyManagerFactory the {@link KeyManagerFactory} that provides the {@link KeyManager}s + * that is used to encrypt data being sent to clients. + * {@code null} to use the default or the results of parsing + * {@code keyCertChainFile} and {@code keyFile}. + * @param ciphers the cipher suites to enable, in the order of preference. + * {@code null} to use the default cipher suites. + * @param cipherFilter a filter to apply over the supplied list of ciphers + * Only required if {@code provider} is {@link SslProvider#JDK} + * @param apn Application Protocol Negotiator object. + * @param sessionCacheSize the size of the cache used for storing SSL session objects. + * {@code 0} to use the default value. + * @param sessionTimeout the timeout for the cached SSL session objects, in seconds. + * {@code 0} to use the default value. + */ + public JdkSslServerContext(final File trustCertChainFile, + final File keyCertChainFile, final File keyFile, final String keyPassword, + final long sessionCacheSize, final long sessionTimeout) throws SSLException { + + try { + TrustManagerFactory trustManagerFactory = null; + if (trustCertChainFile != null) { + trustManagerFactory = buildTrustManagerFactory(trustCertChainFile, trustManagerFactory); + } + KeyManagerFactory keyManagerFactory = buildKeyManagerFactory(keyCertChainFile, keyFile, + keyPassword); + + // Initialize the SSLContext to work with our key managers. + ctx = SSLContext.getInstance(PROTOCOL); + ctx.init(keyManagerFactory.getKeyManagers(), + trustManagerFactory == null ? null : trustManagerFactory.getTrustManagers(), + null); + + SSLSessionContext sessCtx = ctx.getServerSessionContext(); + if (sessionCacheSize > 0) { + sessCtx.setSessionCacheSize((int) Math.min(sessionCacheSize, Integer.MAX_VALUE)); + } + if (sessionTimeout > 0) { + sessCtx.setSessionTimeout((int) Math.min(sessionTimeout, Integer.MAX_VALUE)); + } + } catch (Exception e) { + throw new SSLException("failed to initialize the server-side SSL context", e); + } + } + + @Override + public SSLContext context() { + return ctx; + } +} diff --git a/jooby/src/main/java/org/jooby/internal/ssl/PemReader.java b/jooby/src/main/java/org/jooby/internal/ssl/PemReader.java new file mode 100644 index 00000000..81b942ac --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/ssl/PemReader.java @@ -0,0 +1,92 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.ssl; + +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.KeyException; +import java.security.KeyStore; +import java.security.cert.CertificateException; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.google.common.io.BaseEncoding; +import com.google.common.io.Files; + +/** + * Reads a PEM file and converts it into a list of DERs so that they are imported into a + * {@link KeyStore} easily. + * + * Kindly Borrowed from Netty + */ +final class PemReader { + + private static final Pattern CERT_PATTERN = Pattern.compile( + "-+BEGIN\\s+.*CERTIFICATE[^-]*-+(?:\\s|\\r|\\n)+" + // Header + "([a-z0-9+/=\\r\\n]+)" + // Base64 text + "-+END\\s+.*CERTIFICATE[^-]*-+", // Footer + Pattern.CASE_INSENSITIVE); + private static final Pattern KEY_PATTERN = Pattern.compile( + "-+BEGIN\\s+.*PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+" + // Header + "([a-z0-9+/=\\r\\n]+)" + // Base64 text + "-+END\\s+.*PRIVATE\\s+KEY[^-]*-+", // Footer + Pattern.CASE_INSENSITIVE); + + static List readCertificates(final File file) + throws CertificateException, IOException { + String content = Files.toString(file, StandardCharsets.US_ASCII); + + BaseEncoding base64 = base64(); + List certs = new ArrayList(); + Matcher m = CERT_PATTERN.matcher(content); + int start = 0; + while (m.find(start)) { + ByteBuffer buffer = ByteBuffer.wrap(base64.decode(m.group(1))); + certs.add(buffer); + + start = m.end(); + } + + if (certs.isEmpty()) { + throw new CertificateException("found no certificates: " + file); + } + + return certs; + } + + private static BaseEncoding base64() { + return BaseEncoding.base64().withSeparator("\n", '\n'); + } + + static ByteBuffer readPrivateKey(final File file) throws KeyException, IOException { + String content = Files.toString(file, StandardCharsets.US_ASCII); + + Matcher m = KEY_PATTERN.matcher(content); + if (!m.find()) { + throw new KeyException("found no private key: " + file); + } + + String value = m.group(1); + return ByteBuffer.wrap(base64().decode(value)); + } + + private PemReader() { + } +} diff --git a/jooby/src/main/java/org/jooby/internal/ssl/SslContext.java b/jooby/src/main/java/org/jooby/internal/ssl/SslContext.java new file mode 100644 index 00000000..8b5c10a6 --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/ssl/SslContext.java @@ -0,0 +1,232 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.ssl; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.KeyException; +import java.security.KeyFactory; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.ArrayList; +import java.util.List; + +import javax.crypto.Cipher; +import javax.crypto.EncryptedPrivateKeyInfo; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLSessionContext; +import javax.net.ssl.TrustManagerFactory; +import javax.security.auth.x500.X500Principal; + +/** + * A secure socket protocol implementation which acts as a factory for {@link SSLEngine} and + * {@link SslHandler}. + * Internally, it is implemented via JDK's {@link SSLContext} or OpenSSL's {@code SSL_CTX}. + * + *

Making your server support SSL/TLS

+ *
+ * // In your {@link ChannelInitializer}:
+ * {@link ChannelPipeline} p = channel.pipeline();
+ * {@link SslContext} sslCtx = {@link SslContextBuilder#forServer(File, File) SslContextBuilder.forServer(...)}.build();
+ * p.addLast("ssl", {@link #newEngine(ByteBufAllocator) sslCtx.newEngine(channel.alloc())});
+ * ...
+ * 
+ * + *

Making your client support SSL/TLS

+ *
+ * // In your {@link ChannelInitializer}:
+ * {@link ChannelPipeline} p = channel.pipeline();
+ * {@link SslContext} sslCtx = {@link SslContextBuilder#forClient() SslContextBuilder.forClient()}.build();
+ * p.addLast("ssl", {@link #newEngine(ByteBufAllocator, String, int) sslCtx.newEngine(channel.alloc(), host, port)});
+ * ...
+ * 
+ * + * Kindly Borrowed from Netty + */ +public abstract class SslContext { + static final CertificateFactory X509_CERT_FACTORY; + + static { + try { + X509_CERT_FACTORY = CertificateFactory.getInstance("X.509"); + } catch (CertificateException e) { + throw new IllegalStateException("unable to instance X.509 CertificateFactory", e); + } + } + + public static SslContext newServerContextInternal( + final File trustCertChainFile, + final File keyCertChainFile, final File keyFile, final String keyPassword, + final long sessionCacheSize, final long sessionTimeout) throws SSLException { + return new JdkSslServerContext(trustCertChainFile, keyCertChainFile, + keyFile, keyPassword, sessionCacheSize, sessionTimeout); + } + + /** + * Returns the size of the cache used for storing SSL session objects. + */ + public abstract long sessionCacheSize(); + + public abstract long sessionTimeout(); + + public abstract SSLContext context(); + + /** + * Returns the {@link SSLSessionContext} object held by this context. + */ + public abstract SSLSessionContext sessionContext(); + + /** + * Generates a key specification for an (encrypted) private key. + * + * @param password characters, if {@code null} or empty an unencrypted key is assumed + * @param key bytes of the DER encoded private key + * + * @return a key specification + * + * @throws IOException if parsing {@code key} fails + * @throws NoSuchAlgorithmException if the algorithm used to encrypt {@code key} is unkown + * @throws NoSuchPaddingException if the padding scheme specified in the decryption algorithm is + * unkown + * @throws InvalidKeySpecException if the decryption key based on {@code password} cannot be + * generated + * @throws InvalidKeyException if the decryption key based on {@code password} cannot be used to + * decrypt + * {@code key} + * @throws InvalidAlgorithmParameterException if decryption algorithm parameters are somehow + * faulty + */ + protected static PKCS8EncodedKeySpec generateKeySpec(final char[] password, final byte[] key) + throws IOException, NoSuchAlgorithmException, NoSuchPaddingException, InvalidKeySpecException, + InvalidKeyException, InvalidAlgorithmParameterException { + + if (password == null || password.length == 0) { + return new PKCS8EncodedKeySpec(key); + } + + EncryptedPrivateKeyInfo encryptedPrivateKeyInfo = new EncryptedPrivateKeyInfo(key); + SecretKeyFactory keyFactory = SecretKeyFactory + .getInstance(encryptedPrivateKeyInfo.getAlgName()); + PBEKeySpec pbeKeySpec = new PBEKeySpec(password); + SecretKey pbeKey = keyFactory.generateSecret(pbeKeySpec); + + Cipher cipher = Cipher.getInstance(encryptedPrivateKeyInfo.getAlgName()); + cipher.init(Cipher.DECRYPT_MODE, pbeKey, encryptedPrivateKeyInfo.getAlgParameters()); + + return encryptedPrivateKeyInfo.getKeySpec(cipher); + } + + /** + * Generates a new {@link KeyStore}. + * + * @param certChainFile a X.509 certificate chain file in PEM format, + * @param keyFile a PKCS#8 private key file in PEM format, + * @param keyPasswordChars the password of the {@code keyFile}. + * {@code null} if it's not password-protected. + * @return generated {@link KeyStore}. + */ + static KeyStore buildKeyStore(final File certChainFile, final File keyFile, + final char[] keyPasswordChars) + throws KeyStoreException, NoSuchAlgorithmException, + NoSuchPaddingException, InvalidKeySpecException, InvalidAlgorithmParameterException, + CertificateException, KeyException, IOException { + ByteBuffer encodedKeyBuf = PemReader.readPrivateKey(keyFile); + byte[] encodedKey = encodedKeyBuf.array(); + + PKCS8EncodedKeySpec encodedKeySpec = generateKeySpec(keyPasswordChars, encodedKey); + + PrivateKey key; + try { + key = KeyFactory.getInstance("RSA").generatePrivate(encodedKeySpec); + } catch (InvalidKeySpecException ignore) { + try { + key = KeyFactory.getInstance("DSA").generatePrivate(encodedKeySpec); + } catch (InvalidKeySpecException ignore2) { + try { + key = KeyFactory.getInstance("EC").generatePrivate(encodedKeySpec); + } catch (InvalidKeySpecException e) { + throw new InvalidKeySpecException("Neither RSA, DSA nor EC worked", e); + } + } + } + + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + List certs = PemReader.readCertificates(certChainFile); + List certChain = new ArrayList(certs.size()); + + for (ByteBuffer buf : certs) { + certChain.add(cf.generateCertificate(new ByteArrayInputStream(buf.array()))); + } + + KeyStore ks = KeyStore.getInstance("JKS"); + ks.load(null, null); + ks.setKeyEntry("key", key, keyPasswordChars, + certChain.toArray(new Certificate[certChain.size()])); + return ks; + } + + /** + * Build a {@link TrustManagerFactory} from a certificate chain file. + * + * @param certChainFile The certificate file to build from. + * @param trustManagerFactory The existing {@link TrustManagerFactory} that will be used if not + * {@code null}. + * @return A {@link TrustManagerFactory} which contains the certificates in {@code certChainFile} + */ + protected static TrustManagerFactory buildTrustManagerFactory(final File certChainFile, + TrustManagerFactory trustManagerFactory) + throws NoSuchAlgorithmException, CertificateException, KeyStoreException, IOException { + KeyStore ks = KeyStore.getInstance("JKS"); + ks.load(null, null); + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + + List certs = PemReader.readCertificates(certChainFile); + + for (ByteBuffer buf : certs) { + X509Certificate cert = (X509Certificate) cf + .generateCertificate(new ByteArrayInputStream(buf.array())); + X500Principal principal = cert.getSubjectX500Principal(); + ks.setCertificateEntry(principal.getName("RFC2253"), cert); + } + + // Set up trust manager factory to use our key store. + if (trustManagerFactory == null) { + trustManagerFactory = TrustManagerFactory + .getInstance(TrustManagerFactory.getDefaultAlgorithm()); + } + trustManagerFactory.init(ks); + + return trustManagerFactory; + } +} diff --git a/jooby/src/main/java/org/jooby/internal/ssl/SslContextProvider.java b/jooby/src/main/java/org/jooby/internal/ssl/SslContextProvider.java new file mode 100644 index 00000000..d7dda49f --- /dev/null +++ b/jooby/src/main/java/org/jooby/internal/ssl/SslContextProvider.java @@ -0,0 +1,78 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.ssl; + +import com.typesafe.config.Config; +import static java.util.Objects.requireNonNull; +import org.jooby.funzy.Try; + +import javax.inject.Inject; +import javax.inject.Provider; +import javax.net.ssl.SSLContext; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; + +public class SslContextProvider implements Provider { + + private Config conf; + + @Inject + public SslContextProvider(final Config conf) { + this.conf = requireNonNull(conf, "SSL config is required."); + } + + @Override + public SSLContext get() { + return Try.apply(() -> { + String tmpdir = conf.getString("application.tmpdir"); + File keyStoreCert = toFile(conf.getString("ssl.keystore.cert"), tmpdir); + File keyStoreKey = toFile(conf.getString("ssl.keystore.key"), tmpdir); + String keyStorePass = conf.hasPath("ssl.keystore.password") + ? conf.getString("ssl.keystore.password") : null; + + File trustCert = conf.hasPath("ssl.trust.cert") + ? toFile(conf.getString("ssl.trust.cert"), tmpdir) : null; + + return SslContext + .newServerContextInternal(trustCert, keyStoreCert, keyStoreKey, keyStorePass, + conf.getLong("ssl.session.cacheSize"), conf.getLong("ssl.session.timeout")) + .context(); + }).get(); + } + + private File toFile(final String path, final String tmpdir) throws IOException { + File file = new File(path); + if (file.exists()) { + return file; + } + file = new File(tmpdir, Paths.get(path).getFileName().toString()); + // classpath resource? + try (InputStream in = getClass().getClassLoader().getResourceAsStream(path)) { + if (in == null) { + throw new FileNotFoundException(path); + } + Files.copy(in, file.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + file.deleteOnExit(); + return file; + } + +} diff --git a/jooby/src/main/java/org/jooby/jetty/Jetty.java b/jooby/src/main/java/org/jooby/jetty/Jetty.java new file mode 100644 index 00000000..d8b33174 --- /dev/null +++ b/jooby/src/main/java/org/jooby/jetty/Jetty.java @@ -0,0 +1,34 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.jetty; + +import javax.inject.Singleton; + +import org.jooby.Env; +import org.jooby.Jooby; +import org.jooby.internal.jetty.JettyServer; +import org.jooby.spi.Server; + +import com.google.inject.Binder; +import com.typesafe.config.Config; + +public class Jetty implements Jooby.Module { + + @Override + public void configure(final Env env, final Config config, final Binder binder) { + binder.bind(Server.class).to(JettyServer.class).in(Singleton.class); + } +} diff --git a/jooby/src/main/java/org/jooby/json/Jackson.java b/jooby/src/main/java/org/jooby/json/Jackson.java new file mode 100644 index 00000000..7a1cfc84 --- /dev/null +++ b/jooby/src/main/java/org/jooby/json/Jackson.java @@ -0,0 +1,297 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.json; + +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.module.afterburner.AfterburnerModule; +import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; +import com.google.inject.Binder; +import com.google.inject.Key; +import com.google.inject.multibindings.Multibinder; +import com.google.inject.name.Names; +import com.typesafe.config.Config; +import static java.util.Objects.requireNonNull; +import org.jooby.Env; +import org.jooby.Jooby; +import org.jooby.MediaType; +import org.jooby.Parser; +import org.jooby.Renderer; + +import javax.inject.Inject; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.TimeZone; +import java.util.function.Consumer; + +/** + *

jackson

+ * + * JSON support from the excellent Jackson + * library. + * + * This module provides a JSON {@link Parser} and {@link Renderer}, but also an + * {@link ObjectMapper}. + * + *

usage

+ * + *
+ * {
+ *   use(new Jackson());
+ *
+ *   // sending
+ *   get("/my-api", req {@literal ->} new MyObject());
+ *
+ *   // receiving a json body
+ *   post("/my-api", req {@literal ->} {
+ *     MyObject obj = req.body(MyObject.class);
+ *     return obj;
+ *   });
+ *
+ *   // receiving a json param from a multipart or form url encoded
+ *   post("/my-api", req {@literal ->} {
+ *     MyObject obj = req.param("my-object").to(MyObject.class);
+ *     return obj;
+ *   });
+ * }
+ * 
+ * + *

views

+ *

Dynamic views are supported via {@link JacksonView}:

+ * + *
{@code
+ * {
+ *   use(new Jackson());
+ *
+ *   get("/public", req -> {
+ *     Item item = ...;
+ *     return new JacksonView<>(Views.Public.class, item);
+ *   });
+ *
+ *   get("/public", req -> {
+ *     Item item = ...;
+ *     return new JacksonView<>(Views.Internal.class, item);
+ *   });
+ * }
+ * }
+ * + *

advanced configuration

+ *

+ * If you need a special setting or configuration for your {@link ObjectMapper}: + *

+ * + *
+ * {
+ *   use(new Jackson().configure(mapper {@literal ->} {
+ *     // setup your custom object mapper
+ *   });
+ * }
+ * 
+ * + * or provide an {@link ObjectMapper} instance: + * + *
+ * {
+ *   ObjectMapper mapper = ....;
+ *   use(new Jackson(mapper));
+ * }
+ * 
+ * + * It is possible to wire Jackson modules too: + * + *
+ * {
+ *
+ *   use(new Jackson()
+ *      .module(MyJacksonModuleWiredByGuice.class)
+ *   );
+ * }
+ * 
+ * + * This is useful when your jackson module require some dependencies. + * + * @author edgar + * @since 0.6.0 + */ +public class Jackson implements Jooby.Module { + + private static class PostConfigurer { + + @Inject + public PostConfigurer(final ObjectMapper mapper, final Set jacksonModules) { + mapper.registerModules(jacksonModules); + } + + } + + private final Optional mapper; + + private MediaType type = MediaType.json; + + private Consumer configurer; + + private List>> modules = new ArrayList<>(); + + private boolean raw; + + /** + * Creates a new {@link Jackson} module and use the provided {@link ObjectMapper} instance. + * + * @param mapper {@link ObjectMapper} to apply. + */ + public Jackson(final ObjectMapper mapper) { + this.mapper = Optional.of(requireNonNull(mapper, "The mapper is required.")); + } + + /** + * Creates a new {@link Jackson} module. + */ + public Jackson() { + this.mapper = Optional.empty(); + } + + /** + * Set the json type supported by this module, default is: application/json. + * + * @param type Media type. + * @return This module. + */ + public Jackson type(final MediaType type) { + this.type = type; + return this; + } + + /** + * Set the json type supported by this module, default is: application/json. + * + * @param type Media type. + * @return This module. + */ + public Jackson type(final String type) { + return type(MediaType.valueOf(type)); + } + + /** + * Apply advanced configuration over the provided {@link ObjectMapper}. + * + * @param configurer A configurer callback. + * @return This module. + */ + public Jackson doWith(final Consumer configurer) { + this.configurer = requireNonNull(configurer, "ObjectMapper configurer is required."); + return this; + } + + /** + * Register the provided module. + * + * @param module A module instance. + * @return This module. + */ + public Jackson module(final Module module) { + requireNonNull(module, "Jackson Module is required."); + modules.add(binder -> binder.addBinding().toInstance(module)); + return this; + } + + /** + * Register the provided {@link Module}. The module will be instantiated by Guice. + * + * @param module Module type. + * @return This module. + */ + public Jackson module(final Class module) { + requireNonNull(module, "Jackson Module is required."); + modules.add(binder -> binder.addBinding().to(module)); + return this; + } + + /** + * Add support raw string json responses: + * + *
{@code
+   * {
+   *   get("/raw", () -> {
+   *     return "{\"raw\": \"json\"}";
+   *   });
+   * }
+   * }
+ * + * @return This module. + */ + public Jackson raw() { + raw = true; + return this; + } + + @Override + public void configure(final Env env, final Config config, final Binder binder) { + // provided or default mapper. + ObjectMapper mapper = this.mapper.orElseGet(() -> { + ObjectMapper m = new ObjectMapper(); + Locale locale = env.locale(); + // Jackson clone the date format in order to make dateFormat thread-safe + m.setDateFormat(new SimpleDateFormat(config.getString("application.dateFormat"), locale)); + m.setLocale(locale); + m.setTimeZone(TimeZone.getTimeZone(config.getString("application.tz"))); + // default modules: + m.registerModule(new Jdk8Module()); + m.registerModule(new JavaTimeModule()); + m.registerModule(new ParameterNamesModule()); + m.registerModule(new AfterburnerModule()); + return m; + }); + + if (configurer != null) { + configurer.accept(mapper); + } + + // bind mapper + binder.bind(ObjectMapper.class).toInstance(mapper); + + // Jackson Modules from Guice + Multibinder mbinder = Multibinder.newSetBinder(binder, Module.class); + modules.forEach(m -> m.accept(mbinder)); + + // Jackson Configurer (like a post construct) + binder.bind(PostConfigurer.class).asEagerSingleton(); + + // json parser & renderer + JacksonParser parser = new JacksonParser(mapper, type); + JacksonRenderer renderer = raw + ? new JacksonRawRenderer(mapper, type) + : new JacksonRenderer(mapper, type); + + Multibinder.newSetBinder(binder, Renderer.class) + .addBinding() + .toInstance(renderer); + + Multibinder.newSetBinder(binder, Parser.class) + .addBinding() + .toInstance(parser); + + // direct access? + binder.bind(Key.get(Renderer.class, Names.named(renderer.toString()))).toInstance(renderer); + binder.bind(Key.get(Parser.class, Names.named(parser.toString()))).toInstance(parser); + } + +} diff --git a/jooby/src/main/java/org/jooby/json/JacksonParser.java b/jooby/src/main/java/org/jooby/json/JacksonParser.java new file mode 100644 index 00000000..373773d6 --- /dev/null +++ b/jooby/src/main/java/org/jooby/json/JacksonParser.java @@ -0,0 +1,59 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.json; + +import org.jooby.MediaType; +import org.jooby.MediaType.Matcher; +import org.jooby.Parser; + +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.inject.TypeLiteral; + +class JacksonParser implements Parser { + + private ObjectMapper mapper; + + private Matcher matcher; + + public JacksonParser(final ObjectMapper mapper, final MediaType type) { + this.mapper = mapper; + this.matcher = MediaType.matcher(type); + } + + @Override + public Object parse(final TypeLiteral type, final Context ctx) throws Throwable { + MediaType ctype = ctx.type(); + if (ctype.isAny()) { + // */* + return ctx.next(); + } + + JavaType javaType = mapper.constructType(type.getType()); + if (matcher.matches(ctype) && mapper.canDeserialize(javaType)) { + return ctx + .ifparam(values -> mapper.readValue(values.iterator().next(), javaType)) + .ifbody(body -> mapper.readValue(body.bytes(), javaType)); + } + return ctx.next(); + } + + @Override + public String toString() { + return "json"; + } + +} diff --git a/jooby/src/main/java/org/jooby/json/JacksonRawRenderer.java b/jooby/src/main/java/org/jooby/json/JacksonRawRenderer.java new file mode 100644 index 00000000..1394745c --- /dev/null +++ b/jooby/src/main/java/org/jooby/json/JacksonRawRenderer.java @@ -0,0 +1,37 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.json; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.jooby.MediaType; +import org.jooby.Renderer; + +class JacksonRawRenderer extends JacksonRenderer { + + public JacksonRawRenderer(final ObjectMapper mapper, final MediaType type) { + super(mapper, type); + } + + @Override + protected void renderValue(final Object value, final Renderer.Context ctx) throws Exception { + if (value instanceof CharSequence) { + ctx.type(type).send(value.toString()); + } else { + super.renderValue(value, ctx); + } + } + +} diff --git a/jooby/src/main/java/org/jooby/json/JacksonRenderer.java b/jooby/src/main/java/org/jooby/json/JacksonRenderer.java new file mode 100644 index 00000000..94fc88a5 --- /dev/null +++ b/jooby/src/main/java/org/jooby/json/JacksonRenderer.java @@ -0,0 +1,69 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.json; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.jooby.MediaType; +import org.jooby.Renderer; + +class JacksonRenderer implements Renderer { + + protected final ObjectMapper mapper; + + protected final MediaType type; + + public JacksonRenderer(final ObjectMapper mapper, final MediaType type) { + this.mapper = mapper; + this.type = type; + } + + @Override + public void render(final Object value, final Renderer.Context ctx) throws Exception { + if (ctx.accepts(type) && mapper.canSerialize(value.getClass())) { + ctx.type(type); + renderValue(value, ctx); + } + } + + protected void renderValue(final Object value, final Renderer.Context ctx) throws Exception { + // use UTF-8 and get byte version + final byte[] bytes; + + if (value instanceof JacksonView) { + final JacksonView viewResponse = (JacksonView) value; + + bytes = mapper + .writerWithView(viewResponse.view) + .writeValueAsBytes(viewResponse.data); + } else { + bytes = mapper.writeValueAsBytes(value); + } + + ctx.length(bytes.length) + .send(bytes); + } + + @Override + public String name() { + return "json"; + } + + @Override + public String toString() { + return name(); + } + +} diff --git a/jooby/src/main/java/org/jooby/json/JacksonView.java b/jooby/src/main/java/org/jooby/json/JacksonView.java new file mode 100644 index 00000000..78bf9a13 --- /dev/null +++ b/jooby/src/main/java/org/jooby/json/JacksonView.java @@ -0,0 +1,52 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.json; + +/** + * Dynamic jackson view support. Usage: + * + *
{@code
+ *
+ * {
+ *   use(new Jackson());
+ *
+ *   get("/public", req -> {
+ *     Item item = ...;
+ *     return new JacksonView(Views.Public.class, item);
+ *   });
+ * }
+ *
+ * }
+ */ +public class JacksonView { + + /** View/projection class. */ + public final Class view; + + /** Data/payload. */ + public final T data; + + /** + * Creates a new jackson view. + * + * @param view View/projection class. + * @param data Data/payload. + */ + public JacksonView(final Class view, final T data) { + this.view = view; + this.data = data; + } +} diff --git a/jooby/src/main/java/org/jooby/mvc/Body.java b/jooby/src/main/java/org/jooby/mvc/Body.java new file mode 100644 index 00000000..02fd416b --- /dev/null +++ b/jooby/src/main/java/org/jooby/mvc/Body.java @@ -0,0 +1,42 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.mvc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Bind a Mvc parameter to the HTTP body. + * + *
+ *   @Path("/r")
+ *   class Resources {
+ *
+ *     @Post
+ *     public void method(@Body MyBean) {
+ *     }
+ *   }
+ * 
+ * + * @author edgar + * @since 0.6.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.PARAMETER }) +public @interface Body { +} diff --git a/jooby/src/main/java/org/jooby/mvc/CONNECT.java b/jooby/src/main/java/org/jooby/mvc/CONNECT.java new file mode 100644 index 00000000..7f29b683 --- /dev/null +++ b/jooby/src/main/java/org/jooby/mvc/CONNECT.java @@ -0,0 +1,40 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.mvc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * HTTP CONNECT verb for mvc routes. + *
+ *   class Resources {
+ *
+ *     @CONNECT
+ *     public void method() {
+ *     }
+ *   }
+ * 
+ * + * @author edgar + * @since 0.1.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface CONNECT { +} diff --git a/jooby/src/main/java/org/jooby/mvc/Consumes.java b/jooby/src/main/java/org/jooby/mvc/Consumes.java new file mode 100644 index 00000000..210d0bf0 --- /dev/null +++ b/jooby/src/main/java/org/jooby/mvc/Consumes.java @@ -0,0 +1,53 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.mvc; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Defines what media types a route can consume. By default a route can consume any type {@code *}/ + * {@code *}. + * + * Check the Content-Type header against this value or send a + * "415 Unsupported Media Type" response. + * + *
+ *   class Resources {
+ *
+ *     @Consume("application/json")
+ *     public void method(@Body MyBody body) {
+ *     }
+ *   }
+ * 
+ * + * @author edgar + * @since 0.1.0 + */ +@Inherited +@Target({ElementType.TYPE, ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Consumes { + /** + * @return Media types the route can consume. + */ + String[] value(); +} diff --git a/jooby/src/main/java/org/jooby/mvc/DELETE.java b/jooby/src/main/java/org/jooby/mvc/DELETE.java new file mode 100644 index 00000000..d80fe5ab --- /dev/null +++ b/jooby/src/main/java/org/jooby/mvc/DELETE.java @@ -0,0 +1,40 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.mvc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * HTTP DELETE verb for mvc routes. + *
+ *   class Resources {
+ *
+ *     @DELETE
+ *     public void method() {
+ *     }
+ *   }
+ * 
+ * + * @author edgar + * @since 0.1.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface DELETE { +} diff --git a/jooby/src/main/java/org/jooby/mvc/Flash.java b/jooby/src/main/java/org/jooby/mvc/Flash.java new file mode 100644 index 00000000..3d7ce12f --- /dev/null +++ b/jooby/src/main/java/org/jooby/mvc/Flash.java @@ -0,0 +1,71 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.mvc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.jooby.FlashScope; +import org.jooby.Request; + +/** + *

+ * Bind a Mvc parameter to a {@link Request#flash(String)} flash attribute. + *

+ * + * Accessing to flash scope: + *
+ *   @Path("/r")
+ *   class Resources {
+ *
+ *     @Get
+ *     public void method(@Flash Map<String, String> flash) {
+ *     }
+ *   }
+ * 
+ * + * Accessing to a flash attribute: + *
+ *   @Path("/r")
+ *   class Resources {
+ *
+ *     @Get
+ *     public void method(@Flash String success) {
+ *     }
+ *   }
+ * 
+ * + * Accessing to an optional flash attribute: + *
+ *   @Path("/r")
+ *   class Resources {
+ *
+ *     @Get
+ *     public void method(@Flash Optional<String> success) {
+ *     }
+ *   }
+ * 
+ * + * @author edgar + * @since 1.0.0.CR4 + * @see FlashScope + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.PARAMETER }) +public @interface Flash { +} diff --git a/jooby/src/main/java/org/jooby/mvc/GET.java b/jooby/src/main/java/org/jooby/mvc/GET.java new file mode 100644 index 00000000..660bd684 --- /dev/null +++ b/jooby/src/main/java/org/jooby/mvc/GET.java @@ -0,0 +1,40 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.mvc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * HTTP GET verb for mvc routes. + *
+ *   class Resources {
+ *
+ *     @GET
+ *     public void method() {
+ *     }
+ *   }
+ * 
+ * + * @author edgar + * @since 0.1.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface GET { +} diff --git a/jooby/src/main/java/org/jooby/mvc/HEAD.java b/jooby/src/main/java/org/jooby/mvc/HEAD.java new file mode 100644 index 00000000..434a57c6 --- /dev/null +++ b/jooby/src/main/java/org/jooby/mvc/HEAD.java @@ -0,0 +1,40 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.mvc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * HTTP HEAD verb for mvc routes. + *
+ *   class Resources {
+ *
+ *     @HEAD
+ *     public void method() {
+ *     }
+ *   }
+ * 
+ * + * @author edgar + * @since 0.1.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface HEAD { +} diff --git a/jooby/src/main/java/org/jooby/mvc/Header.java b/jooby/src/main/java/org/jooby/mvc/Header.java new file mode 100644 index 00000000..4b5e8885 --- /dev/null +++ b/jooby/src/main/java/org/jooby/mvc/Header.java @@ -0,0 +1,47 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.mvc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import javax.inject.Qualifier; + +/** + * Mark a MVC method parameter as a request header. + *
+ *   class Resources {
+ *
+ *     @GET
+ *     public void method(@Header String myHeader) {
+ *     }
+ *   }
+ * 
+ * + * @author edgar + * @since 0.1.0 + */ +@Qualifier +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PARAMETER) +public @interface Header { + /** + * @return Header's name. + */ + String value() default ""; +} diff --git a/jooby/src/main/java/org/jooby/mvc/Local.java b/jooby/src/main/java/org/jooby/mvc/Local.java new file mode 100644 index 00000000..340ea308 --- /dev/null +++ b/jooby/src/main/java/org/jooby/mvc/Local.java @@ -0,0 +1,44 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.mvc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.jooby.Request; + +/** + * Bind a Mvc parameter to a {@link Request#get(String)} local variable. + * + *
+ *   @Path("/r")
+ *   class Resources {
+ *
+ *     @Get
+ *     public void method(@Local String value) {
+ *     }
+ *   }
+ * 
+ * + * @author edgar + * @since 0.15.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.PARAMETER }) +public @interface Local { +} diff --git a/jooby/src/main/java/org/jooby/mvc/OPTIONS.java b/jooby/src/main/java/org/jooby/mvc/OPTIONS.java new file mode 100644 index 00000000..b8fb26dd --- /dev/null +++ b/jooby/src/main/java/org/jooby/mvc/OPTIONS.java @@ -0,0 +1,40 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.mvc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * HTTP OPTIONS verb for mvc routes. + *
+ *   class Resources {
+ *
+ *     @OPTIONS
+ *     public void method() {
+ *     }
+ *   }
+ * 
+ * + * @author edgar + * @since 0.1.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface OPTIONS { +} diff --git a/jooby/src/main/java/org/jooby/mvc/PATCH.java b/jooby/src/main/java/org/jooby/mvc/PATCH.java new file mode 100644 index 00000000..6a199014 --- /dev/null +++ b/jooby/src/main/java/org/jooby/mvc/PATCH.java @@ -0,0 +1,40 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.mvc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * HTTP PATCH verb for mvc routes. + *
+ *   class Resources {
+ *
+ *     @PATCH
+ *     public void method() {
+ *     }
+ *   }
+ * 
+ * + * @author edgar + * @since 0.1.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface PATCH { +} diff --git a/jooby/src/main/java/org/jooby/mvc/POST.java b/jooby/src/main/java/org/jooby/mvc/POST.java new file mode 100644 index 00000000..e3c0b40d --- /dev/null +++ b/jooby/src/main/java/org/jooby/mvc/POST.java @@ -0,0 +1,41 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.mvc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * HTTP POST verb for mvc routes. + *
+ *   class Resources {
+ *
+ *     @POST
+ *     public void method() {
+ *     }
+ *   }
+ * 
+ * + * @author edgar + * @since 0.1.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface POST { + +} diff --git a/jooby/src/main/java/org/jooby/mvc/PUT.java b/jooby/src/main/java/org/jooby/mvc/PUT.java new file mode 100644 index 00000000..2b4e04f3 --- /dev/null +++ b/jooby/src/main/java/org/jooby/mvc/PUT.java @@ -0,0 +1,40 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.mvc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * HTTP PUT verb for mvc routes. + *
+ *   class Resources {
+ *
+ *     @PUT
+ *     public void method() {
+ *     }
+ *   }
+ * 
+ * + * @author edgar + * @since 0.1.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface PUT { +} diff --git a/jooby/src/main/java/org/jooby/mvc/Path.java b/jooby/src/main/java/org/jooby/mvc/Path.java new file mode 100644 index 00000000..f1fa721d --- /dev/null +++ b/jooby/src/main/java/org/jooby/mvc/Path.java @@ -0,0 +1,83 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.mvc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Set a path for Mvc routes. + * + *
+ *   @Path("/r")
+ *   class Resources {
+ *
+ *     @Path("/sub")
+ *     @Get
+ *     public void method() {
+ *     }
+ *   }
+ * 
+ * + *

Path Patterns

+ *

+ * Jooby supports Ant-style path patterns: + *

+ *

+ * Some examples: + *

+ *
    + *
  • {@code com/t?st.html} - matches {@code com/test.html} but also {@code com/tast.jsp} or + * {@code com/txst.html}
  • + *
  • {@code com/*.html} - matches all {@code .html} files in the {@code com} directory
  • + *
  • com/{@literal **}/test.html - matches all {@code test.html} files underneath the + * {@code com} path
  • + *
  • {@code **}/{@code *} - matches any path at any level.
  • + *
  • {@code *} - matches any path at any level, shorthand for {@code **}/{@code *}.
  • + *
+ * + *

Variables

+ *

+ * Jooby supports path parameters too: + *

+ *

+ * Some examples: + *

+ *
    + *
  • /user/{id} - /user/* and give you access to the id var.
  • + *
  • /user/:id - /user/* and give you access to the id var.
  • + *
  • /user/{id:\\d+} - /user/[digits] and give you access to the numeric + * id var.
  • + *
+ * + * @author edgar + * @since 0.1.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD }) +public @interface Path { + /** + * @return Route path pattern. + */ + String[] value(); + + /** + * @return Pattern to excludes/ignore. Useful for filters. + */ + String[] excludes() default {}; +} diff --git a/jooby/src/main/java/org/jooby/mvc/Produces.java b/jooby/src/main/java/org/jooby/mvc/Produces.java new file mode 100644 index 00000000..21f10049 --- /dev/null +++ b/jooby/src/main/java/org/jooby/mvc/Produces.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.mvc; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Defines what media types a route can produces. By default a route can produces any type + * {@code *}/{@code *}. + * Check the Accept header against this value or send a "406 Not Acceptable" response. + * + *
+ *   class Resources {
+ *
+ *     @Produces("application/json")
+ *     public Object method() {
+ *      return ...;
+ *     }
+ *   }
+ * 
+ * @author edgar + * @since 0.1.0 + */ +@Inherited +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Produces { + String[] value(); +} diff --git a/jooby/src/main/java/org/jooby/mvc/TRACE.java b/jooby/src/main/java/org/jooby/mvc/TRACE.java new file mode 100644 index 00000000..dd02efa1 --- /dev/null +++ b/jooby/src/main/java/org/jooby/mvc/TRACE.java @@ -0,0 +1,40 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.mvc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * HTTP TRACE verb for mvc routes. + *
+ *   class Resources {
+ *
+ *     @TRACE
+ *     public void method() {
+ *     }
+ *   }
+ * 
+ * + * @author edgar + * @since 0.1.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface TRACE { +} diff --git a/jooby/src/main/java/org/jooby/package-info.java b/jooby/src/main/java/org/jooby/package-info.java new file mode 100644 index 00000000..12f7005c --- /dev/null +++ b/jooby/src/main/java/org/jooby/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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. + */ +/** + *

do more, more easily

+ *

+ * Jooby a scalable, fast and modular micro web framework for Java and Kotlin. + *

+ */ +@javax.annotation.ParametersAreNonnullByDefault +package org.jooby; diff --git a/jooby/src/main/java/org/jooby/scope/Providers.java b/jooby/src/main/java/org/jooby/scope/Providers.java new file mode 100644 index 00000000..c9d97843 --- /dev/null +++ b/jooby/src/main/java/org/jooby/scope/Providers.java @@ -0,0 +1,35 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.scope; + +import javax.inject.Provider; + +import com.google.inject.Key; +import com.google.inject.OutOfScopeException; + +public class Providers { + + public static Provider outOfScope(final Class type) { + return outOfScope(Key.get(type)); + } + + public static Provider outOfScope(final Key key) { + return () -> { + throw new OutOfScopeException(key.toString()); + }; + } + +} diff --git a/jooby/src/main/java/org/jooby/scope/RequestScoped.java b/jooby/src/main/java/org/jooby/scope/RequestScoped.java new file mode 100644 index 00000000..6a4533ea --- /dev/null +++ b/jooby/src/main/java/org/jooby/scope/RequestScoped.java @@ -0,0 +1,55 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.scope; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; + +import javax.inject.Scope; + +/** + * Define a request scoped object. Steps for defining a request scoped object are: + * + *
    + *
  1. + * Bind the object at bootstrap time: + *
    + *    binder.bind(MyRequestObject.class).toProvider(() {@literal ->} {
    + *      throw new IllegalStateException("Out of scope!");
    + *    });
    + *  
    + *
  2. + *
  3. + * Seed the object from a route handler: + *
    + *    use("*", req {@literal ->} {
    + *      MyRequestObject object = ...;
    + *      req.set(MyRequestObject.class, object);
    + *    });
    + *  
    + *
  4. + *
+ * + * @author edgar + * @since 0.5.0 + */ +@Scope +@Documented +@Retention(RUNTIME) +public @interface RequestScoped { +} diff --git a/jooby/src/main/java/org/jooby/servlet/ServerInitializer.java b/jooby/src/main/java/org/jooby/servlet/ServerInitializer.java new file mode 100644 index 00000000..45f38eb6 --- /dev/null +++ b/jooby/src/main/java/org/jooby/servlet/ServerInitializer.java @@ -0,0 +1,63 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.servlet; + +import com.google.inject.Binder; +import com.typesafe.config.Config; +import static java.util.Objects.requireNonNull; +import org.jooby.Env; +import org.jooby.Jooby; +import org.jooby.funzy.Throwing; +import org.jooby.spi.Server; + +import javax.servlet.ServletContext; +import javax.servlet.ServletContextEvent; +import javax.servlet.ServletContextListener; + +public class ServerInitializer implements ServletContextListener { + + public static class ServletModule implements Jooby.Module { + + @Override + public void configure(final Env env, final Config config, final Binder binder) { + binder.bind(Server.class).toInstance(ServletContainer.NOOP); + } + + } + + @Override + public void contextInitialized(final ServletContextEvent sce) { + ServletContext ctx = sce.getServletContext(); + String appClass = ctx.getInitParameter("application.class"); + requireNonNull(appClass, "Context param NOT found: application.class"); + + Jooby.run(Throwing.throwingSupplier(() -> { + Jooby app = (Jooby) ctx.getClassLoader().loadClass(appClass).newInstance(); + ctx.setAttribute(Jooby.class.getName(), app); + return app; + }), "application.path=" + ctx.getContextPath(), "server.module=" + ServletModule.class.getName()); + } + + @Override + public void contextDestroyed(final ServletContextEvent sce) { + ServletContext ctx = sce.getServletContext(); + Jooby app = (Jooby) ctx.getAttribute(Jooby.class.getName()); + if (app != null) { + app.stop(); + } + } + +} diff --git a/jooby/src/main/java/org/jooby/servlet/ServletContainer.java b/jooby/src/main/java/org/jooby/servlet/ServletContainer.java new file mode 100644 index 00000000..67f268f1 --- /dev/null +++ b/jooby/src/main/java/org/jooby/servlet/ServletContainer.java @@ -0,0 +1,52 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.servlet; + +import org.jooby.spi.Server; + +import java.util.Optional; +import java.util.concurrent.Executor; + +/** + * NOOP server for servlets. + * + * @author edgar + */ +public class ServletContainer implements Server { + + public static final Server NOOP = new ServletContainer(); + + ServletContainer() { + } + + @Override + public void start() throws Exception { + } + + @Override + public void stop() throws Exception { + } + + @Override + public void join() throws InterruptedException { + } + + @Override + public Optional executor() { + return Optional.empty(); + } + +} diff --git a/jooby/src/main/java/org/jooby/servlet/ServletHandler.java b/jooby/src/main/java/org/jooby/servlet/ServletHandler.java new file mode 100644 index 00000000..601e1e3f --- /dev/null +++ b/jooby/src/main/java/org/jooby/servlet/ServletHandler.java @@ -0,0 +1,72 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.servlet; + +import java.io.IOException; + +import javax.servlet.ServletConfig; +import javax.servlet.ServletContext; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.jooby.Jooby; +import org.jooby.spi.HttpHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.typesafe.config.Config; + +@SuppressWarnings("serial") +public class ServletHandler extends HttpServlet { + + /** The logging system. */ + private final Logger log = LoggerFactory.getLogger(getClass()); + + private HttpHandler dispatcher; + + private String tmpdir; + + @Override + public void init(final ServletConfig config) throws ServletException { + super.init(config); + + ServletContext ctx = config.getServletContext(); + + Jooby app = (Jooby) ctx.getAttribute(Jooby.class.getName()); + + dispatcher = app.require(HttpHandler.class); + tmpdir = app.require(Config.class).getString("application.tmpdir"); + } + + @Override + protected void service(final HttpServletRequest req, final HttpServletResponse rsp) + throws ServletException, IOException { + try { + dispatcher.handle( + new ServletServletRequest(req, tmpdir), + new ServletServletResponse(req, rsp)); + } catch (IOException | ServletException | RuntimeException ex) { + log.error("execution of: " + req.getRequestURI() + " resulted in error", ex); + throw ex; + } catch (Throwable ex) { + log.error("execution of: " + req.getRequestURI() + " resulted in error", ex); + throw new IllegalStateException(ex); + } + } + +} diff --git a/jooby/src/main/java/org/jooby/servlet/ServletServletRequest.java b/jooby/src/main/java/org/jooby/servlet/ServletServletRequest.java new file mode 100644 index 00000000..351ef0c2 --- /dev/null +++ b/jooby/src/main/java/org/jooby/servlet/ServletServletRequest.java @@ -0,0 +1,243 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.servlet; + +import static java.util.Objects.requireNonNull; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URLDecoder; +import java.util.Arrays; +import java.util.Collections; +import java.util.Enumeration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.Executor; +import java.util.function.Function; +import java.util.stream.Collectors; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; + +import org.jooby.Cookie; +import org.jooby.MediaType; +import org.jooby.Router; +import org.jooby.spi.NativeRequest; +import org.jooby.spi.NativeUpload; + +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableList.Builder; + +public class ServletServletRequest implements NativeRequest { + + private final HttpServletRequest req; + + private final String tmpdir; + + private final boolean multipart; + + private final String path; + + private ServletUpgrade upgrade = noupgrade(); + + public ServletServletRequest(final HttpServletRequest req, final String tmpdir, + final boolean multipart) throws IOException { + this.req = requireNonNull(req, "HTTP req is required."); + this.tmpdir = requireNonNull(tmpdir, "A tmpdir is required."); + this.multipart = multipart; + String pathInfo = req.getPathInfo(); + if (pathInfo == null) { + pathInfo = "/"; + } + this.path = req.getContextPath() + Router.decode(pathInfo); + } + + public HttpServletRequest servletRequest() { + return req; + } + + public ServletServletRequest(final HttpServletRequest req, final String tmpdir) + throws IOException { + this(req, tmpdir, multipart(req)); + } + + public ServletServletRequest with(final ServletUpgrade upgrade) { + this.upgrade = requireNonNull(upgrade, "An upgrade provider is required."); + return this; + } + + @Override + public String method() { + return req.getMethod(); + } + + @Override + public Optional queryString() { + return Optional.ofNullable(Strings.emptyToNull(req.getQueryString())); + } + + @Override + public String path() { + return path; + } + + @Override + public String rawPath() { + return req.getRequestURI(); + } + + @Override + public List paramNames() { + return toList(req.getParameterNames()); + } + + private List toList(final Enumeration enumeration) { + Builder result = ImmutableList.builder(); + while (enumeration.hasMoreElements()) { + result.add(enumeration.nextElement()); + } + return result.build(); + } + + @Override + public List params(final String name) throws Exception { + String[] values = req.getParameterValues(name); + if (values == null) { + return Collections.emptyList(); + } + return Arrays.asList(values); + } + + @Override + public Map attributes() { + final Enumeration attributeNames = req.getAttributeNames(); + if (!attributeNames.hasMoreElements()) { + return Collections.emptyMap(); + } + return Collections.list(attributeNames).stream() + .collect(Collectors.toMap(Function.identity(), req::getAttribute)); + } + + @Override + public List headers(final String name) { + return toList(req.getHeaders(name)); + } + + @Override + public Optional header(final String name) { + return Optional.ofNullable(req.getHeader(name)); + } + + @Override + public List headerNames() { + return toList(req.getHeaderNames()); + } + + @Override + public List cookies() { + javax.servlet.http.Cookie[] cookies = req.getCookies(); + if (cookies == null) { + return ImmutableList.of(); + } + return Arrays.stream(cookies) + .map(c -> { + Cookie.Definition cookie = new Cookie.Definition(c.getName(), c.getValue()); + Optional.ofNullable(c.getComment()).ifPresent(cookie::comment); + Optional.ofNullable(c.getDomain()).ifPresent(cookie::domain); + Optional.ofNullable(c.getPath()).ifPresent(cookie::path); + + return cookie.toCookie(); + }) + .collect(Collectors.toList()); + } + + @Override + public List files(final String name) throws IOException { + try { + if (multipart) { + return req.getParts().stream() + .filter(part -> part.getSubmittedFileName() != null && part.getName().equals(name)) + .map(part -> new ServletUpload(part, tmpdir)) + .collect(Collectors.toList()); + } + return Collections.emptyList(); + } catch (ServletException ex) { + throw new IOException("File not found: " + name, ex); + } + } + + @Override + public List files() throws IOException { + try { + if (multipart) { + return req.getParts().stream() + .map(part -> new ServletUpload(part, tmpdir)) + .collect(Collectors.toList()); + } + return Collections.emptyList(); + } catch (ServletException ex) { + throw new IOException("Unable to get files", ex); + } + } + + @Override + public InputStream in() throws IOException { + return req.getInputStream(); + } + + @Override + public String ip() { + return req.getRemoteAddr(); + } + + @Override + public String protocol() { + return req.getProtocol(); + } + + @Override + public boolean secure() { + return req.isSecure(); + } + + @Override + public T upgrade(final Class type) throws Exception { + return upgrade.upgrade(type); + } + + @Override + public void startAsync(final Executor executor, final Runnable runnable) { + req.startAsync(); + executor.execute(runnable); + } + + private static boolean multipart(final HttpServletRequest req) { + String contentType = req.getContentType(); + return contentType != null && contentType.toLowerCase().startsWith(MediaType.multipart.name()); + } + + private static ServletUpgrade noupgrade() { + return new ServletUpgrade() { + + @Override + public T upgrade(final Class type) throws Exception { + throw new UnsupportedOperationException(""); + } + }; + } +} diff --git a/jooby/src/main/java/org/jooby/servlet/ServletServletResponse.java b/jooby/src/main/java/org/jooby/servlet/ServletServletResponse.java new file mode 100644 index 00000000..74a7fcc6 --- /dev/null +++ b/jooby/src/main/java/org/jooby/servlet/ServletServletResponse.java @@ -0,0 +1,159 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.servlet; + +import com.google.common.collect.ImmutableList; +import com.google.common.io.ByteStreams; +import static java.util.Objects.requireNonNull; + +import org.jooby.funzy.Try; +import org.jooby.spi.NativeResponse; + +import javax.servlet.AsyncContext; +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.nio.channels.WritableByteChannel; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +public class ServletServletResponse implements NativeResponse { + + protected HttpServletRequest req; + + protected HttpServletResponse rsp; + + private boolean committed; + + public ServletServletResponse(final HttpServletRequest req, final HttpServletResponse rsp) { + this.req = requireNonNull(req, "A request is required."); + this.rsp = requireNonNull(rsp, "A response is required."); + } + + @Override + public List headers(final String name) { + Collection headers = rsp.getHeaders(name); + if (headers == null || headers.size() == 0) { + return Collections.emptyList(); + } + return ImmutableList.copyOf(headers); + } + + @Override + public Optional header(final String name) { + String header = rsp.getHeader(name); + return header == null || header.isEmpty() ? Optional.empty() : Optional.of(header); + } + + @Override + public void header(final String name, final String value) { + rsp.setHeader(name, value); + } + + @Override + public void header(final String name, final Iterable values) { + for (String value : values) { + rsp.addHeader(name, value); + } + } + + @Override + public void send(final byte[] bytes) throws Exception { + rsp.setHeader("Transfer-Encoding", null); + ServletOutputStream output = rsp.getOutputStream(); + output.write(bytes); + output.close(); + committed = true; + } + + @Override + public void send(final ByteBuffer buffer) throws Exception { + Try.of(Channels.newChannel(rsp.getOutputStream())) + .run(channel -> channel.write(buffer)) + .onSuccess(() -> committed = true); + } + + @Override + public void send(final FileChannel file) throws Exception { + send(file, 0, file.size()); + } + + @Override + public void send(final FileChannel channel, final long position, final long count) + throws Exception { + try (FileChannel src = channel) { + WritableByteChannel dest = Channels.newChannel(rsp.getOutputStream()); + src.transferTo(position, count, dest); + dest.close(); + committed = true; + } + } + + @Override + public void send(final InputStream stream) throws Exception { + ServletOutputStream output = rsp.getOutputStream(); + ByteStreams.copy(stream, output); + output.close(); + stream.close(); + committed = true; + } + + @Override + public int statusCode() { + return rsp.getStatus(); + } + + @Override + public void statusCode(final int statusCode) { + rsp.setStatus(statusCode); + } + + @Override + public boolean committed() { + if (committed) { + return true; + } + return rsp.isCommitted(); + } + + @Override + public void end() { + if (!committed) { + if (req.isAsyncStarted()) { + AsyncContext ctx = req.getAsyncContext(); + ctx.complete(); + } else { + close(); + } + committed = true; + } + } + + protected void close() { + } + + @Override + public void reset() { + rsp.reset(); + } + +} diff --git a/jooby/src/main/java/org/jooby/servlet/ServletUpgrade.java b/jooby/src/main/java/org/jooby/servlet/ServletUpgrade.java new file mode 100644 index 00000000..16484b3b --- /dev/null +++ b/jooby/src/main/java/org/jooby/servlet/ServletUpgrade.java @@ -0,0 +1,22 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.servlet; + +public interface ServletUpgrade { + + T upgrade(Class type) throws Exception; + +} diff --git a/jooby/src/main/java/org/jooby/servlet/ServletUpload.java b/jooby/src/main/java/org/jooby/servlet/ServletUpload.java new file mode 100644 index 00000000..8102674b --- /dev/null +++ b/jooby/src/main/java/org/jooby/servlet/ServletUpload.java @@ -0,0 +1,77 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.servlet; + +import static java.util.Objects.requireNonNull; + +import java.io.File; +import java.io.IOException; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import javax.servlet.http.Part; + +import org.jooby.spi.NativeUpload; + +import com.google.common.collect.ImmutableList; + +public class ServletUpload implements NativeUpload { + + private final Part upload; + + private final String tmpdir; + + private File file; + + public ServletUpload(final Part upload, final String tmpdir) { + this.upload = requireNonNull(upload, "A part upload is required."); + this.tmpdir = requireNonNull(tmpdir, "A tmpdir is required."); + } + + @Override + public void close() throws IOException { + if (file != null) { + file.delete(); + } + upload.delete(); + } + + @Override + public String name() { + return upload.getSubmittedFileName(); + } + + @Override + public List headers(final String name) { + Collection headers = upload.getHeaders(name.toLowerCase()); + if (headers == null) { + return Collections.emptyList(); + } + return ImmutableList.copyOf(headers); + } + + @Override + public File file() throws IOException { + if (file == null) { + String name = "tmp-" + Long.toHexString(System.currentTimeMillis()) + "." + name(); + upload.write(name); + file = new File(tmpdir, name); + } + return file; + } + +} diff --git a/jooby/src/main/java/org/jooby/spi/HttpHandler.java b/jooby/src/main/java/org/jooby/spi/HttpHandler.java new file mode 100644 index 00000000..c5bd7378 --- /dev/null +++ b/jooby/src/main/java/org/jooby/spi/HttpHandler.java @@ -0,0 +1,37 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.spi; + +/** + * Bridge between Jooby app and a {@link Server} implementation. Server implementors are not + * required to implement this contract, instead they should use or inject the provided + * implementation. + * + * @author edgar + * @since 0.5.0 + */ +public interface HttpHandler { + + /** + * Handle an incoming HTTP request. + * + * @param request HTTP request. + * @param response HTTP response. + * @throws Exception If execution resulted in exception. + */ + void handle(final NativeRequest request, final NativeResponse response) throws Exception; + +} diff --git a/jooby/src/main/java/org/jooby/spi/NativePushPromise.java b/jooby/src/main/java/org/jooby/spi/NativePushPromise.java new file mode 100644 index 00000000..ef57ca58 --- /dev/null +++ b/jooby/src/main/java/org/jooby/spi/NativePushPromise.java @@ -0,0 +1,36 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.spi; + +import java.util.Map; + +/** + * HTTP/2 push promise. + * + * @author edgar + */ +public interface NativePushPromise { + + /** + * Send a push promise to client and start/enqueue a the response. + * + * @param method HTTP method. + * @param path Resource path. + * @param headers Resource headers. + */ + void push(String method, String path, Map headers); + +} diff --git a/jooby/src/main/java/org/jooby/spi/NativeRequest.java b/jooby/src/main/java/org/jooby/spi/NativeRequest.java new file mode 100644 index 00000000..22c45b10 --- /dev/null +++ b/jooby/src/main/java/org/jooby/spi/NativeRequest.java @@ -0,0 +1,183 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.spi; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.Executor; + +import org.jooby.Cookie; + +/** + * Minimal/basic implementation of HTTP request. A server implementor must provide an implementation + * of {@link NativeRequest}. + * + * @author edgar + * @since 0.5.0 + */ +public interface NativeRequest { + /** + * @return The name of the HTTP method with which this request was made, for example, GET, POST, + * or PUT. + */ + String method(); + + /** + * @return The part of this request's URL from the protocol name up to the query string in the + * first line of the HTTP request. + */ + String path(); + + /** + * Returns the query string that is contained in the request URL after the path. + * + * @return Query string or empty + */ + Optional queryString(); + + /** + * @return List with all the parameter names from query string plus any other form/multipart param + * names (excluding file uploads). This method should NOT returns null, absence of params + * is represented by an empty list. + * @throws Exception If param extraction fails. + */ + List paramNames() throws Exception; + + /** + * Get all the params for the provided name or a empty list. + * + * @param name Parameter name. + * @return Get all the params for the provided name or a empty list. + * @throws Exception If param parsing fails. + */ + List params(String name) throws Exception; + + /** + * @return Map containing all request attributes + */ + @SuppressWarnings("unchecked") + default Map attributes() { + return Collections.EMPTY_MAP; + } + + /** + * Get all the headers for the provided name or a empty list. + * + * @param name Header name. + * @return Get all the headers for the provided name or a empty list. + */ + List headers(String name); + + /** + * Get the first header for the provided name or a empty list. + * + * @param name Header name. + * @return The first header for the provided name or a empty list. + */ + Optional header(final String name); + + /** + * @return All the header names or an empty list. + */ + List headerNames(); + + /** + * @return All the cookies or an empty list. + */ + List cookies(); + + /** + * Get all the files for the provided name or an empty list. + * + * @param name File name. + * @return All the files or an empty list. + * @throws IOException If file parsing fails. + */ + List files(String name) throws IOException; + + /** + * Get all the files or an empty list. + * + * @return All the files or an empty list. + * @throws IOException If file parsing fails. + */ + List files() throws IOException; + + /** + * Input stream that represent the body. + * + * @return Body as an input stream. + * @throws IOException If body read fails. + */ + InputStream in() throws IOException; + + /** + * @return The IP address of the client or last proxy that sent the request. + */ + String ip(); + + /** + * @return The name and version of the protocol the request uses in the form + * protocol/majorVersion.minorVersion, for example, HTTP/1.1 + */ + String protocol(); + + /** + * @return True if this request was made using a secure channel, such as HTTPS. + */ + boolean secure(); + + /** + * Upgrade the request to something else...like a web socket. + * + * @param type Upgrade type. + * @param Upgrade type. + * @return A instance of the upgrade. + * @throws Exception If the upgrade fails or it is un-supported. + * @see NativeWebSocket + */ + T upgrade(Class type) throws Exception; + + /** + * Put the request in async mode. + * + * @param executor Executor to use. + * @param runnable Task to run. + */ + void startAsync(Executor executor, Runnable runnable); + + /** + * Send push promise to the client. + * + * @param method HTTP method. + * @param path HTTP path. + * @param headers HTTP headers. + * @throws Exception If something goes wrong. + */ + default void push(final String method, final String path, final Map headers) + throws Exception { + upgrade(NativePushPromise.class).push(method, path, headers); + } + + /** + * @return Request path without decoding. + */ + String rawPath(); +} diff --git a/jooby/src/main/java/org/jooby/spi/NativeResponse.java b/jooby/src/main/java/org/jooby/spi/NativeResponse.java new file mode 100644 index 00000000..cfb9f236 --- /dev/null +++ b/jooby/src/main/java/org/jooby/spi/NativeResponse.java @@ -0,0 +1,102 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.spi; + +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.util.List; +import java.util.Optional; + +/** + * Minimal/basic implementation of HTTP request. A server implementor must provide an implementation + * of {@link NativeResponse}. + * + * @author edgar + * @since 0.5.0 + */ +public interface NativeResponse { + + /** + * Get a response header (previously set). + * + * @param name Header's name. + * @return Header. + */ + Optional header(final String name); + + /** + * Get all the response headers for the provided name. + * + * @param name A header's name + * @return All the response headers. + */ + List headers(String name); + + /** + * Set a response header. + * + * @param name Header's name. + * @param values Header's values. + */ + void header(String name, Iterable values); + + /** + * Set a response header. + * + * @param name Header's name. + * @param value Header's value. + */ + void header(String name, String value); + + void send(byte[] bytes) throws Exception; + + void send(ByteBuffer buffer) throws Exception; + + void send(InputStream stream) throws Exception; + + void send(FileChannel channel) throws Exception; + + void send(FileChannel channel, long possition, long count) throws Exception; + + /** + * @return HTTP response status. + */ + int statusCode(); + + /** + * Set the HTTP response status. + * + * @param code A HTTP response status. + */ + void statusCode(int code); + + /** + * @return True if response was committed to the client. + */ + boolean committed(); + + /** + * End a response and clean up any resources used it. + */ + void end(); + + /** + * Reset the HTTP status, headers and response buffer is need it. + */ + void reset(); + +} diff --git a/jooby/src/main/java/org/jooby/spi/NativeUpload.java b/jooby/src/main/java/org/jooby/spi/NativeUpload.java new file mode 100644 index 00000000..fc4f8751 --- /dev/null +++ b/jooby/src/main/java/org/jooby/spi/NativeUpload.java @@ -0,0 +1,52 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.spi; + +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.util.List; + +/** + * File upload from multipart/form-data post. + * + * @author edgar + * @since 0.5.0 + */ +public interface NativeUpload extends Closeable { + + /** + * @return File name. + */ + String name(); + + /** + * Get all the file headers for the given name. + * + * @param name A header's name. + * @return All available values or and empty list. + */ + List headers(String name); + + /** + * Get the actual file link/reference and do something with it. + * + * @return A file from local file system. + * @throws IOException If file failed to read/write from local file system. + */ + File file() throws IOException; + +} diff --git a/jooby/src/main/java/org/jooby/spi/NativeWebSocket.java b/jooby/src/main/java/org/jooby/spi/NativeWebSocket.java new file mode 100644 index 00000000..458b330d --- /dev/null +++ b/jooby/src/main/java/org/jooby/spi/NativeWebSocket.java @@ -0,0 +1,145 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.spi; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Optional; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import org.jooby.WebSocket; + +/** + * A web socket upgrade created from {@link NativeRequest#upgrade(Class)}. + * + * @author edgar + * @since 0.5.0 + */ +public interface NativeWebSocket { + + /** + * Close the web socket. + * + * @param status A web socket close status. + * @param reason A close reason. + */ + void close(int status, String reason); + + /** + * Resume reads. + */ + void resume(); + + /** + * Set the onconnect callback. It will be execute once per each client. + * + * @param callback A callback. + */ + void onConnect(Runnable callback); + + /** + * Set the ontext message callback. On message arrival the callback will be executed. + * + * @param callback A callback. + */ + void onTextMessage(Consumer callback); + + /** + * Set the onbinary message callback. On message arrival the callback will be executed. + * + * @param callback A callback. + */ + void onBinaryMessage(Consumer callback); + + /** + * Set the onclose message callback. It will be executed when clients close a connection and/or + * connection idle timeout. + * + * @param callback A callback. + */ + void onCloseMessage(BiConsumer> callback); + + /** + * Set the onerror message callback. It will be executed on errors. + * + * @param callback A callback. + */ + void onErrorMessage(Consumer callback); + + /** + * Pause reads. + */ + void pause(); + + /** + * Terminate immediately a connection. + * + * @throws IOException If termination fails. + */ + void terminate() throws IOException; + + /** + * Send a binary message to the client. + * + * @param data Message to send. + * @param success Success callback. + * @param err Error callback. + */ + void sendBytes(ByteBuffer data, WebSocket.SuccessCallback success, WebSocket.OnError err); + + /** + * Send a binary message to the client. + * + * @param data Message to send. + * @param success Success callback. + * @param err Error callback. + */ + void sendBytes(byte[] data, WebSocket.SuccessCallback success, WebSocket.OnError err); + + /** + * Send a text message to the client. + * + * @param data Message to send. + * @param success Success callback. + * @param err Error callback. + */ + void sendText(String data, WebSocket.SuccessCallback success, WebSocket.OnError err); + + /** + * Send a text message to the client. + * + * @param data Message to send. + * @param success Success callback. + * @param err Error callback. + */ + void sendText(ByteBuffer data, WebSocket.SuccessCallback success, WebSocket.OnError err); + + /** + * Send a text message to the client. + * + * @param data Message to send. + * @param success Success callback. + * @param err Error callback. + */ + void sendText(byte[] data, WebSocket.SuccessCallback success, WebSocket.OnError err); + + /** + * @return True if the websocket connection is open. + */ + boolean isOpen(); + +} diff --git a/jooby/src/main/java/org/jooby/spi/Server.java b/jooby/src/main/java/org/jooby/spi/Server.java new file mode 100644 index 00000000..1c7e8c1d --- /dev/null +++ b/jooby/src/main/java/org/jooby/spi/Server.java @@ -0,0 +1,57 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.spi; + +import java.util.Optional; +import java.util.concurrent.Executor; + +/** + * A HTTP web server. + * + * @author edgar + * @since 0.1.0 + */ +public interface Server { + + /** + * Start the web server. + * + * @throws Exception If server fail to start. + */ + void start() throws Exception; + + /** + * Stop the web server. + * + * @throws Exception If server fail to stop. + */ + void stop() throws Exception; + + /** + * Waits for this thread to die. + * + * @throws InterruptedException If wait didn't success. + */ + void join() throws InterruptedException; + + /** + * Obtain the executor for worker threads. + * + * @return The executor for worker threads. + */ + Optional executor(); + +} diff --git a/jooby/src/main/java/org/jooby/spi/WatchEventModifier.java b/jooby/src/main/java/org/jooby/spi/WatchEventModifier.java new file mode 100644 index 00000000..68dd04d6 --- /dev/null +++ b/jooby/src/main/java/org/jooby/spi/WatchEventModifier.java @@ -0,0 +1,34 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.spi; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.file.WatchEvent; + +public class WatchEventModifier { + + public static WatchEvent.Modifier modifier(String name) { + try { + Class e = WatchEventModifier.class.getClassLoader() + .loadClass("com.sun.nio.file.SensitivityWatchEventModifier"); + Method m = e.getDeclaredMethod("valueOf", String.class); + return (WatchEvent.Modifier) m.invoke(null, name); + } catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException | InvocationTargetException x) { + return () -> name; + } + } +} diff --git a/jooby/src/main/java/org/jooby/test/JoobyRule.java b/jooby/src/main/java/org/jooby/test/JoobyRule.java new file mode 100644 index 00000000..daf3e0af --- /dev/null +++ b/jooby/src/main/java/org/jooby/test/JoobyRule.java @@ -0,0 +1,119 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.test; + +import static java.util.Objects.requireNonNull; + +import org.jooby.Jooby; +import org.junit.rules.ExternalResource; + +/** + *

+ * Junit rule to run integration tests. You can choose between @ClassRule or @Rule. The next example + * uses ClassRule: + * + *

+ * import org.jooby.test.JoobyRule;
+ *
+ * public class MyIntegrationTest {
+ *
+ *   @ClassRule
+ *   private static JoobyRule bootstrap = new JoobyRule(new MyApp());
+ *
+ * }
+ * 
+ * + *

+ * Here one and only one instance will be created, which means the application start before the + * first test and stop after the last test. Application state is shared between tests. + *

+ *

+ * While with Rule a new application is created per test. If you have N test, then the application + * will start/stop N times: + *

+ * + *
+ * import org.jooby.test.JoobyRule;
+ *
+ * public class MyIntegrationTest {
+ *
+ *   @Rule
+ *   private static JoobyRule bootstrap = new JoobyRule(new MyApp());
+ *
+ * }
+ * 
+ * + *

+ * You are free to choice the HTTP client of your choice, like Fluent Apache HTTP client, REST + * Assured, etc.. + *

+ *

+ * Here is a full example with REST Assured: + *

+ * + *
{@code
+ * import org.jooby.Jooby;
+ *
+ * public class MyApp extends Jooby {
+ *
+ *   {
+ *     get("/", () -> "I'm real");
+ *   }
+ *
+ * }
+ *
+ * import org.jooby.test.JoobyRyle;
+ *
+ * public class MyIntegrationTest {
+ *
+ *   @ClassRule
+ *   static JoobyRule bootstrap = new JoobyRule(new MyApp());
+ *
+ *   @Test
+ *   public void integrationTestJustWorks() {
+ *     get("/")
+ *       .then()
+ *       .assertThat()
+ *       .body(equalTo("I'm real"));
+ *   }
+ * }
+ * }
+ * + * @author edgar + */ +public class JoobyRule extends ExternalResource { + + private Jooby app; + + /** + * Creates a new {@link JoobyRule} to run integration tests. + * + * @param app Application to test. + */ + public JoobyRule(final Jooby app) { + this.app = requireNonNull(app, "App required."); + } + + @Override + protected void before() throws Throwable { + app.start("server.join=false"); + } + + @Override + protected void after() { + app.stop(); + } +} diff --git a/jooby/src/main/java/org/jooby/test/MockRouter.java b/jooby/src/main/java/org/jooby/test/MockRouter.java new file mode 100644 index 00000000..0d16bd2e --- /dev/null +++ b/jooby/src/main/java/org/jooby/test/MockRouter.java @@ -0,0 +1,474 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.test; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Lists; +import com.google.common.reflect.Reflection; +import com.google.inject.Injector; +import com.google.inject.Key; +import com.google.inject.name.Names; +import org.jooby.Deferred; +import org.jooby.Err; +import org.jooby.Jooby; +import org.jooby.MediaType; +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Result; +import org.jooby.Results; +import org.jooby.Route; +import org.jooby.Route.After; +import org.jooby.Route.Definition; +import org.jooby.Route.Filter; +import org.jooby.Status; +import org.jooby.funzy.Try; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +/** + *

tests

+ *

+ * In this section we are going to see how to run unit and integration tests in Jooby. + *

+ * + *

unit tests

+ *

+ * We do offer two programming models: + *

+ *
    + *
  • script programming model; and
  • + *
  • mvc programming model
  • + *
+ * + *

+ * You don't need much for MVC routes, because a route got binded to a method of some + * class. So it is usually very easy and simple to mock and run unit tests against a + * MVC route. + *

+ * + *

+ * We can't say the same for script routes, because a route is represented by a + * lambda and there is no easy or simple way to get access to the lambda object. + *

+ * + *

+ * We do provide a {@link MockRouter} which simplify unit tests for script routes: + *

+ * + *

usage

+ * + *
{@code
+ * public class MyApp extends Jooby {
+ *   {
+ *     get("/test", () -> "Hello unit tests!");
+ *   }
+ * }
+ * }
+ * + *

+ * A unit test for this route, looks like: + *

+ * + *
+ * @Test
+ * public void simpleTest() {
+ *   String result = new MockRouter(new MyApp())
+ *      .get("/test");
+ *   assertEquals("Hello unit tests!", result);
+ * }
+ * + *

+ * Just create a new instance of {@link MockRouter} with your application and call one of the HTTP + * method, like get, post, etc... + *

+ * + *

mocks

+ *

+ * You're free to choose the mock library of your choice. Here is an example using + * EasyMock: + *

+ * + *
{@code
+ * {
+ *   get("/mock", req -> {
+ *     return req.path();
+ *   });
+ * }
+ * }
+ * + *

+ * A test with EasyMock looks like: + *

+ *
+ *
+ * @Test
+ * public void shouldGetRequestPath() {
+ *   Request req = EasyMock.createMock(Request.class);
+ *   expect(req.path()).andReturn("/mypath");
+ *
+ *   EasyMock.replay(req);
+ *
+ *   String result = new MockRouter(new MyApp(), req)
+ *      .get("/mock");
+ *
+ *   assertEquals("/mypath", result);
+ *
+ *   EasyMock.verify(req);
+ * }
+ * 
+ * + *

+ * You can mock a {@link Response} object in the same way: + *

+ * + *
{@code
+ * {
+ *   get("/mock", (req, rsp) -> {
+ *     rsp.send("OK");
+ *   });
+ * }
+ * }
+ * + *

+ * A test with EasyMock looks like: + *

+ *
+ *
+ * @Test
+ * public void shouldUseResponseSend() {
+ *   Request req = EasyMock.createMock(Request.class);
+ *   Response rsp = EasyMock.createMock(Response.class);
+ *   rsp.send("OK");
+ *
+ *   EasyMock.replay(req, rsp);
+ *
+ *   String result = new MockRouter(new MyApp(), req, rsp)
+ *      .get("/mock");
+ *
+ *   assertEquals("OK", result);
+ *
+ *   EasyMock.verify(req, rsp);
+ * }
+ * 
+ * + *

+ * What about external dependencies? It works in a similar way: + *

+ * + *
{@code
+ * {
+ *   get("/", () -> {
+ *     HelloService service = require(HelloService.class);
+ *     return service.salute();
+ *   });
+ * }
+ * }
+ * + *
+ * @Test
+ * public void shouldMockExternalDependencies() {
+ *   HelloService service = EasyMock.createMock(HelloService.class);
+ *   expect(service.salute()).andReturn("Hola!");
+ *
+ *   EasyMock.replay(service);
+ *
+ *   String result = new MockRouter(new MyApp())
+ *      .set(service)
+ *      .get("/");
+ *
+ *   assertEquals("Hola!", result);
+ *
+ *   EasyMock.verify(service);
+ * }
+ * 
+ * + *

+ * The {@link #set(Object)} call push and register an external dependency (usually a mock). This + * make it possible to resolve services from require calls. + *

+ * + *

deferred

+ *

+ * Mock of promises are possible too: + *

+ * + *
{@code
+ * {
+ *   get("/", promise(deferred -> {
+ *     deferred.resolve("OK");
+ *   }));
+ * }
+ * }
+ * + *
+ * @Test
+ * public void shouldMockPromises() {
+ *
+ *   String result = new MockRouter(new MyApp())
+ *      .get("/");
+ *
+ *   assertEquals("OK", result);
+ * }
+ * 
+ * + * Previous test works for deferred routes: + * + *
{@code
+ * {
+ *   get("/", deferred(() -> {
+ *     return "OK";
+ *   }));
+ * }
+ * }
+ * + * @author edgar + */ +public class MockRouter { + + private static final Route.Chain NOOP_CHAIN = new Route.Chain() { + + @Override + public List routes() { + return ImmutableList.of(); + } + + @Override + public void next(final String prefix, final Request req, final Response rsp) throws Throwable { + } + }; + + private static class MockResponse extends Response.Forwarding { + + List afterList = new ArrayList<>(); + private AtomicReference ref; + + public MockResponse(final Response response, final AtomicReference ref) { + super(response); + this.ref = ref; + } + + @Override + public void after(final After handler) { + afterList.add(handler); + } + + @Override + public void send(final Object result) throws Throwable { + rsp.send(result); + ref.set(result); + } + + @Override + public void send(final Result result) throws Throwable { + rsp.send(result); + ref.set(result); + } + + } + + private static final int CLEAN_STACK = 4; + + @SuppressWarnings("rawtypes") + private Map registry = new HashMap<>(); + + private List routes; + + private Request req; + + private Response rsp; + + public MockRouter(final Jooby app) { + this(app, empty(Request.class), empty(Response.class)); + } + + public MockRouter(final Jooby app, final Request req) { + this(app, req, empty(Response.class)); + } + + public MockRouter(final Jooby app, final Request req, final Response rsp) { + this.routes = Jooby.exportRoutes(hackInjector(app)); + this.req = req; + this.rsp = rsp; + } + + public MockRouter set(final Object dependency) { + return set(null, dependency); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + public MockRouter set(final String name, final Object object) { + traverse(object.getClass(), type -> { + Object key = Optional.ofNullable(name) + .map(it -> Key.get(type, Names.named(name))) + .orElseGet(() -> Key.get(type)); + registry.putIfAbsent((Key) key, object); + }); + return this; + } + + public T get(final String path) throws Throwable { + return execute(Route.GET, path); + } + + public T post(final String path) throws Throwable { + return execute(Route.POST, path); + } + + public T put(final String path) throws Throwable { + return execute(Route.PUT, path); + } + + public T patch(final String path) throws Throwable { + return execute(Route.PATCH, path); + } + + public T delete(final String path) throws Throwable { + return execute(Route.DELETE, path); + } + + public T execute(final String method, final String path) throws Throwable { + return execute(method, path, MediaType.all, MediaType.all); + } + + @SuppressWarnings("unchecked") + private T execute(final String method, final String path, final MediaType contentType, + final MediaType... accept) throws Throwable { + List filters = pipeline(method, path, contentType, Arrays.asList(accept)); + if (filters.isEmpty()) { + throw new Err(Status.NOT_FOUND, path); + } + Iterator pipeline = filters.iterator(); + AtomicReference ref = new AtomicReference<>(); + MockResponse rsp = new MockResponse(this.rsp, ref); + while (ref.get() == null && pipeline.hasNext()) { + Filter next = pipeline.next(); + if (next instanceof Route.ZeroArgHandler) { + ref.set(((Route.ZeroArgHandler) next).handle()); + } else if (next instanceof Route.OneArgHandler) { + ref.set(((Route.OneArgHandler) next).handle(req)); + } else if (next instanceof Route.Handler) { + ((Route.Handler) next).handle(req, rsp); + } else { + next.handle(req, rsp, NOOP_CHAIN); + } + } + Object lastResult = ref.get(); + // after callbacks: + if (rsp.afterList.size() > 0) { + Result result = wrap(lastResult); + for (int i = rsp.afterList.size() - 1; i >= 0; i--) { + result = rsp.afterList.get(i).handle(req, rsp, result); + } + if (Result.class.isInstance(lastResult)) { + return (T) result; + } + return result.get(); + } + + // deferred results: + if (lastResult instanceof Deferred) { + Deferred deferred = ((Deferred) lastResult); + // execute deferred code: + deferred.handler(req, (v, x) -> { + }); + // get result + lastResult = deferred.get(); + if (Throwable.class.isInstance(lastResult)) { + throw (Throwable) lastResult; + } + } + return (T) lastResult; + } + + private Result wrap(final Object value) { + if (value instanceof Result) { + return (Result) value; + } + return Results.with(value); + } + + private List pipeline(final String method, final String path, + final MediaType contentType, + final List accept) { + List routes = new ArrayList<>(); + for (Route.Definition routeDef : this.routes) { + Optional route = routeDef.matches(method, path, contentType, accept); + if (route.isPresent()) { + routes.add(routeDef.filter()); + } + } + return routes; + } + + private Jooby hackInjector(final Jooby app) { + Try.run(() -> { + Field field = Jooby.class.getDeclaredField("injector"); + field.setAccessible(true); + Injector injector = proxyInjector(getClass().getClassLoader(), registry); + field.set(app, injector); + registry.put(Key.get(Injector.class), injector); + }).throwException(); + return app; + } + + @SuppressWarnings("rawtypes") + private static Injector proxyInjector(final ClassLoader loader, final Map registry) { + return Reflection.newProxy(Injector.class, (proxy, method, args) -> { + if (method.getName().equals("getInstance")) { + Key key = (Key) args[0]; + Object value = registry.get(key); + if (value == null) { + Object type = key.getAnnotation() != null ? key : key.getTypeLiteral(); + IllegalStateException iex = new IllegalStateException("Not found: " + type); + // Skip proxy and some useless lines: + Try.apply(() -> { + StackTraceElement[] stacktrace = iex.getStackTrace(); + return Lists.newArrayList(stacktrace).subList(CLEAN_STACK, stacktrace.length); + }).onSuccess(stacktrace -> iex + .setStackTrace(stacktrace.toArray(new StackTraceElement[stacktrace.size()]))); + throw iex; + } + return value; + } + throw new UnsupportedOperationException(method.toString()); + }); + } + + @SuppressWarnings("rawtypes") + private void traverse(final Class type, final Consumer set) { + if (type != Object.class) { + set.accept(type); + Optional.ofNullable(type.getSuperclass()).ifPresent(it -> traverse(it, set)); + Arrays.asList(type.getInterfaces()).forEach(it -> traverse(it, set)); + } + } + + private static T empty(final Class type) { + return Reflection.newProxy(type, (proxy, method, args) -> { + throw new UnsupportedOperationException(method.toString()); + }); + } + +} diff --git a/jooby/src/main/resources/WEB-INF/web.xml b/jooby/src/main/resources/WEB-INF/web.xml new file mode 100644 index 00000000..548371b4 --- /dev/null +++ b/jooby/src/main/resources/WEB-INF/web.xml @@ -0,0 +1,29 @@ + + + + application.class + ${application.class} + + + + org.jooby.servlet.ServerInitializer + + + + jooby + org.jooby.servlet.ServletHandler + 0 + + + 0 + ${war.maxRequestSize} + + + + + jooby + /* + + diff --git a/jooby/src/main/resources/org/jooby/jooby.conf b/jooby/src/main/resources/org/jooby/jooby.conf new file mode 100644 index 00000000..c9af91db --- /dev/null +++ b/jooby/src/main/resources/org/jooby/jooby.conf @@ -0,0 +1,243 @@ +################################################################################################### +#! application +################################################################################################### +application { + + # environment default is: dev + env = dev + + # contains the simple name of package of your application bootstrap class. + # For example: com.foo.App -> foo + # name = App.class.getPackage().getName().lastSegment() + + # application namespace, default to app package. set it at runtime + # ns = App.class.getPackage().getName() + + # class = App.class.getName() + + # tmpdir + tmpdir = ${java.io.tmpdir}${file.separator}${application.name} + + # path (a.k.a. as contextPath) + path = / + + # localhost + host = localhost + + # HTTP ports + port = 8080 + + # uncomment to enabled HTTPS + # securePort = 8443 + + # we do UTF-8 + charset = UTF-8 + + # date/time format + dateFormat = dd-MMM-yyyy + + zonedDateTimeFormat = "yyyy-MM-dd'T'HH:mm:ss[.S]XXX" + + # number format, system default. set it at runtime + # numberFormat = DecimalFormat.getInstance(${application.lang})).toPattern() + + # comma separated list of locale using the language tag format. Default to: Locale.getDefault() + # lang = Locale.getDefault() + + # timezone, system default. set it at runtime + # tz = ZoneId.systemDefault().getId() + + # redirect to/force https + # example: https://my.domain.com/{0} + redirect_https = "" +} + +ssl { + + # An X.509 certificate chain file in PEM format, provided certificate should NOT be used in prod. + keystore.cert = org/jooby/unsecure.crt + + # A PKCS#8 private key file in PEM format, provided key should NOT be used in prod. + keystore.key = org/jooby/unsecure.key + + # password of the keystore.key (if any) + # keystore.password = + + # Trusted certificates for verifying the remote endpoint's certificate. The file should + # contain an X.509 certificate chain in PEM format. Default uses the system default. + # trust.cert = + + # Set the size of the cache used for storing SSL session objects. 0 to use the + # default value. + session.cacheSize = 0 + + # Timeout for the cached SSL session objects, in seconds. 0 to use the default value. + session.timeout = 0 +} + +################################################################################################### +#! session defaults +################################################################################################### +session { + # we suggest a timeout, but usage and an implementation is specific to a Session.Store implementation + timeout = 30m + + # save interval, how frequently we must save a none-dirty session (in millis). + saveInterval = 60s + + cookie { + # name of the cookie + name = jooby.sid + + # cookie path + path = / + + # expires when the user closes the web browser + maxAge = -1 + + httpOnly = true + + secure = false + } +} + +################################################################################################### +#! flash scope defaults +################################################################################################### +flash { + cookie { + name = jooby.flash + + path = ${application.path} + + httpOnly = true + + secure = false + } +} + +################################################################################################### +#! server defaults +################################################################################################### +server { + http { + + HeaderSize = 8k + + # Max response buffer size + ResponseBufferSize = 16k + + # Max request body size to keep in memory + RequestBufferSize = 1m + + # Max request size total (body + header) + MaxRequestSize = 200k + + IdleTimeout = 0 + + Method = "" + } + + threads { + # Min = Math.max(4, ${runtime.processors}) + # Max = Math.max(32, ${runtime.processors-x8}) + IdleTimeout = 60s + } + + routes { + # Guava Cache Spec + Cache = "concurrencyLevel="${runtime.concurrencyLevel}",maximumSize="${server.threads.Max} + } + + ws { + # The maximum size of a text message. + MaxTextMessageSize = 131072 + + # The maximum size of a binary message. + MaxBinaryMessageSize = 131072 + + # The time in ms (milliseconds) that a websocket may be idle before closing. + IdleTimeout = 5minutes + } + + http2 { + cleartext = true + enabled = false + } +} + +################################################################################################### +#! assets +################################################################################################### +assets { + #! If asset CDN is present, the asset router will do a redirect to CDN and wont serve the file locally + #! /assets/js/index.js -> redirectTo(cdn + assets/js/index.js) + cdn = "" + + etag = true + + lastModified = true + + env = ${application.env} + + charset = ${application.charset} + + # -1 to disable or HOCON duration value + cache.maxAge = -1 + +} + +################################################################################################### +#! Cross origin resource sharing +################################################################################################### +cors { + # Configures the Access-Control-Allow-Origin CORS header. Possibly values: *, domain, regex or a list of previous values. + # Example: + # "*" + # ["http://foo.com"] + # ["http://*.com"] + # ["http://foo.com", "http://bar.com"] + origin: "*" + + # If true, set the Access-Control-Allow-Credentials header + credentials: true + + # Allowed methods: Set the Access-Control-Allow-Methods header + allowedMethods: [GET, POST] + + # Allowed headers: set the Access-Control-Allow-Headers header. Possibly values: *, header name or a list of previous values. + # Examples + # "*" + # Custom-Header + # [Header-1, Header-2] + allowedHeaders: [X-Requested-With, Content-Type, Accept, Origin] + + # Preflight max age: number of seconds that preflight requests can be cached by the client + maxAge: 30m + + # Set the Access-Control-Expose-Headers header + # exposedHeaders: [] +} + +################################################################################################### +#! runtime +################################################################################################### + +#! number of available processors, set it at runtime +#! runtime.processors = Runtime.getRuntime().availableProcessors() +#! runtime.processors-plus1 = ${runtime.processors} + 1 +#! runtime.processors-plus2 = ${runtime.processors} + 2 +#! runtime.processors-x2 = ${runtime.processors} * 2 + +################################################################################################### +#! status codes +################################################################################################### +err.java.lang.IllegalArgumentException = 400 +err.java.util.NoSuchElementException = 400 +err.java.io.FileNotFoundException = 404 + +################################################################################################### +#! alias +################################################################################################### + +contextPath = ${application.path} diff --git a/jooby/src/main/resources/org/jooby/mime.properties b/jooby/src/main/resources/org/jooby/mime.properties new file mode 100644 index 00000000..859f1c36 --- /dev/null +++ b/jooby/src/main/resources/org/jooby/mime.properties @@ -0,0 +1,199 @@ +mime.ai=application/postscript +mime.aif=audio/x-aiff +mime.aifc=audio/x-aiff +mime.aiff=audio/x-aiff +mime.apk=application/vnd.android.package-archive +mime.asc=text/plain +mime.asf=video/x.ms.asf +mime.asx=video/x.ms.asx +mime.au=audio/basic +mime.avi=video/x-msvideo +mime.bcpio=application/x-bcpio +mime.bin=application/octet-stream +mime.bmp=image/bmp +mime.cab=application/x-cabinet +mime.cdf=application/x-netcdf +mime.class=application/java-vm +mime.cpio=application/x-cpio +mime.cpt=application/mac-compactpro +mime.crt=application/x-x509-ca-cert +mime.csh=application/x-csh +mime.css=text/css +mime.scss=text/css +mime.less=text/css +mime.csv=text/comma-separated-values +mime.dcr=application/x-director +mime.dir=application/x-director +mime.dll=application/x-msdownload +mime.dms=application/octet-stream +mime.doc=application/msword +mime.dtd=application/xml-dtd +mime.dvi=application/x-dvi +mime.dxr=application/x-director +mime.eps=application/postscript +mime.etx=text/x-setext +mime.exe=application/octet-stream +mime.ez=application/andrew-inset +mime.gif=image/gif +mime.gtar=application/x-gtar +mime.gz=application/gzip +mime.gzip=application/gzip +mime.hdf=application/x-hdf +mime.hqx=application/mac-binhex40 +mime.htc=text/x-component +mime.htm=text/html +mime.html=text/html +mime.ice=x-conference/x-cooltalk +mime.ico=image/x-icon +mime.ief=image/ief +mime.iges=model/iges +mime.igs=model/iges +mime.jad=text/vnd.sun.j2me.app-descriptor +mime.jar=application/java-archive +mime.java=text/plain +mime.jnlp=application/x-java-jnlp-file +mime.jpe=image/jpeg +mime.jpeg=image/jpeg +mime.jpg=image/jpeg +mime.js=application/javascript +mime.ts=application/javascript +mime.coffee=application/javascript +mime.json=application/json +mime.yaml=application/yaml +mime.jsp=text/html +mime.kar=audio/midi +mime.latex=application/x-latex +mime.lha=application/octet-stream +mime.lzh=application/octet-stream +mime.man=application/x-troff-man +mime.mathml=application/mathml+xml +mime.me=application/x-troff-me +mime.mesh=model/mesh +mime.mid=audio/midi +mime.midi=audio/midi +mime.mif=application/vnd.mif +mime.mol=chemical/x-mdl-molfile +mime.mov=video/quicktime +mime.movie=video/x-sgi-movie +mime.mp2=audio/mpeg +mime.mp3=audio/mpeg +mime.mpe=video/mpeg +mime.mpeg=video/mpeg +mime.mpg=video/mpeg +mime.mpga=audio/mpeg +mime.ms=application/x-troff-ms +mime.msh=model/mesh +mime.msi=application/octet-stream +mime.nc=application/x-netcdf +mime.oda=application/oda +mime.odb=application/vnd.oasis.opendocument.database +mime.odc=application/vnd.oasis.opendocument.chart +mime.odf=application/vnd.oasis.opendocument.formula +mime.odg=application/vnd.oasis.opendocument.graphics +mime.odi=application/vnd.oasis.opendocument.image +mime.odm=application/vnd.oasis.opendocument.text-master +mime.odp=application/vnd.oasis.opendocument.presentation +mime.ods=application/vnd.oasis.opendocument.spreadsheet +mime.odt=application/vnd.oasis.opendocument.text +mime.ogg=application/ogg +mime.otc=application/vnd.oasis.opendocument.chart-template +mime.otf=application/vnd.oasis.opendocument.formula-template +mime.otg=application/vnd.oasis.opendocument.graphics-template +mime.oth=application/vnd.oasis.opendocument.text-web +mime.oti=application/vnd.oasis.opendocument.image-template +mime.otp=application/vnd.oasis.opendocument.presentation-template +mime.ots=application/vnd.oasis.opendocument.spreadsheet-template +mime.ott=application/vnd.oasis.opendocument.text-template +mime.pbm=image/x-portable-bitmap +mime.pdb=chemical/x-pdb +mime.pdf=application/pdf +mime.pgm=image/x-portable-graymap +mime.pgn=application/x-chess-pgn +mime.png=image/png +mime.pnm=image/x-portable-anymap +mime.ppm=image/x-portable-pixmap +mime.pps=application/vnd.ms-powerpoint +mime.ppt=application/vnd.ms-powerpoint +mime.ps=application/postscript +mime.qml=text/x-qml +mime.qt=video/quicktime +mime.ra=audio/x-pn-realaudio +mime.ram=audio/x-pn-realaudio +mime.ras=image/x-cmu-raster +mime.rdf=application/rdf+xml +mime.rgb=image/x-rgb +mime.rm=audio/x-pn-realaudio +mime.roff=application/x-troff +mime.rpm=application/x-rpm +mime.rtf=application/rtf +mime.rtx=text/richtext +mime.rv=video/vnd.rn-realvideo +mime.ser=application/java-serialized-object +mime.sgm=text/sgml +mime.sgml=text/sgml +mime.sh=application/x-sh +mime.shar=application/x-shar +mime.silo=model/mesh +mime.sit=application/x-stuffit +mime.skd=application/x-koan +mime.skm=application/x-koan +mime.skp=application/x-koan +mime.skt=application/x-koan +mime.smi=application/smil +mime.smil=application/smil +mime.snd=audio/basic +mime.spl=application/x-futuresplash +mime.src=application/x-wais-source +mime.sv4cpio=application/x-sv4cpio +mime.sv4crc=application/x-sv4crc +mime.svg=image/svg+xml +mime.swf=application/x-shockwave-flash +mime.t=application/x-troff +mime.tar=application/x-tar +mime.tar.gz=application/x-gtar +mime.tcl=application/x-tcl +mime.tex=application/x-tex +mime.texi=application/x-texinfo +mime.texinfo=application/x-texinfo +mime.tgz=application/x-gtar +mime.tif=image/tiff +mime.tiff=image/tiff +mime.tr=application/x-troff +mime.tsv=text/tab-separated-values +mime.txt=text/plain +mime.ustar=application/x-ustar +mime.vcd=application/x-cdlink +mime.vrml=model/vrml +mime.vxml=application/voicexml+xml +mime.wav=audio/x-wav +mime.wbmp=image/vnd.wap.wbmp +mime.wml=text/vnd.wap.wml +mime.wmlc=application/vnd.wap.wmlc +mime.wmls=text/vnd.wap.wmlscript +mime.wmlsc=application/vnd.wap.wmlscriptc +mime.wrl=model/vrml +mime.wtls-ca-certificate=application/vnd.wap.wtls-ca-certificate +mime.xbm=image/x-xbitmap +mime.xht=application/xhtml+xml +mime.xhtml=application/xhtml+xml +mime.xls=application/vnd.ms-excel +mime.xml=application/xml +mime.xpm=image/x-xpixmap +mime.xsd=application/xml +mime.xsl=application/xml +mime.xslt=application/xslt+xml +mime.xul=application/vnd.mozilla.xul+xml +mime.xwd=image/x-xwindowdump +mime.xyz=chemical/x-xyz +mime.z=application/compress +mime.zip=application/zip +mime.conf=application/hocon +#! fonts +mime.ttf=font/truetype +mime.otf=font/opentype +mime.eot=application/vnd.ms-fontobject +mime.woff=application/x-font-woff +mime.woff2=application/font-woff2 +#! source map +mime.map=text/plain +mime.mp4=video/mp4 diff --git a/jooby/src/main/resources/org/jooby/spi/server.conf b/jooby/src/main/resources/org/jooby/spi/server.conf new file mode 100644 index 00000000..d3d6968e --- /dev/null +++ b/jooby/src/main/resources/org/jooby/spi/server.conf @@ -0,0 +1,82 @@ +# jetty defaults +server.module = org.jooby.jetty.Jetty + +server.http2.cleartext = true + +jetty { + + threads { + MinThreads = ${server.threads.Min} + + MaxThreads = ${server.threads.Max} + + IdleTimeout = ${server.threads.IdleTimeout} + + Name = jetty task + } + + FileSizeThreshold = 16k + + http { + HeaderCacheSize = ${server.http.HeaderSize} + + RequestHeaderSize = ${server.http.HeaderSize} + + ResponseHeaderSize = ${server.http.HeaderSize} + + OutputBufferSize = ${server.http.ResponseBufferSize} + + SendServerVersion = false + + SendXPoweredBy = false + + SendDateHeader = false + + connector { + # The accept queue size (also known as accept backlog) + AcceptQueueSize = 0 + + StopTimeout = 30000 + + # Sets the maximum Idle time for a connection, which roughly translates to the Socket#setSoTimeout(int) + # call, although with NIO implementations other mechanisms may be used to implement the timeout. + # The max idle time is applied: + # When waiting for a new message to be received on a connection + # When waiting for a new message to be sent on a connection + + # This value is interpreted as the maximum time between some progress being made on the connection. + # So if a single byte is read or written, then the timeout is reset. + IdleTimeout = ${server.http.IdleTimeout} + } + } + + ws { + # The maximum size of a text message during parsing/generating. + # Text messages over this maximum will result in a close code 1009 {@link StatusCode#MESSAGE_TOO_LARGE} + MaxTextMessageSize = ${server.ws.MaxTextMessageSize} + + # The maximum size of a text message buffer. + # Used ONLY for stream based message writing. + MaxTextMessageBufferSize = 32k + + # The maximum size of a binary message during parsing/generating. + # Binary messages over this maximum will result in a close code 1009 {@link StatusCode#MESSAGE_TOO_LARGE} + MaxBinaryMessageSize = ${server.ws.MaxBinaryMessageSize} + + # The maximum size of a binary message buffer + # Used ONLY for for stream based message writing + MaxBinaryMessageBufferSize = 32k + + # The timeout in ms (milliseconds) for async write operations. + # Negative values indicate a disabled timeout. + AsyncWriteTimeout = 60000 + + # The time in ms (milliseconds) that a websocket may be idle before closing. + IdleTimeout = ${server.ws.IdleTimeout} + + # The size of the input (read from network layer) buffer size. + InputBufferSize = 4k + } + + url.charset = ${application.charset} +} diff --git a/jooby/src/main/resources/org/jooby/unsecure.crt b/jooby/src/main/resources/org/jooby/unsecure.crt new file mode 100644 index 00000000..bdc7af44 --- /dev/null +++ b/jooby/src/main/resources/org/jooby/unsecure.crt @@ -0,0 +1,10 @@ +-----BEGIN CERTIFICATE----- +MIIBqjCCAROgAwIBAgIJAI+mXoZ/oBONMA0GCSqGSIb3DQEBBQUAMBYxFDASBgNVBAMTC2V4YW1w +bGUuY29tMCAXDTE0MDkwNDEzNDg1OVoYDzk5OTkxMjMxMjM1OTU5WjAWMRQwEgYDVQQDEwtleGFt +cGxlLmNvbTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEApEOrgZXAECQ4KbFQhIPAP31bMe/x +oh7sKwVdqVkwTTADdZBPLKnQ1Omdkvj+VXWwvMaZcGIppYb3UuZDOTOcTG14He5A0GzSvTPBNBsM +BOKtCgOYJ7qkxXeJ6UVkyaj40qDWqE7uvs0qFIXC4hSmIhlBdVIqcYkDdt26/F3MSwMCAwEAATAN +BgkqhkiG9w0BAQUFAAOBgQBdsQ3NR0xJgjsj5fk4x++EuvR+mhxz68GJMidV5ztJ2t6H21yF7JJD +e3t4WP32E7io9rphsD+Izi1SY18+ldx0giz3ilLhZ3KUErCWZsnv66WjdANpzuTO9ilKjqEGwTVS +7M4iRfPBmv/xRNy9JGClvCT0WG/xJ+fWmgDy5Bomiw== +-----END CERTIFICATE----- diff --git a/jooby/src/main/resources/org/jooby/unsecure.key b/jooby/src/main/resources/org/jooby/unsecure.key new file mode 100644 index 00000000..063473db --- /dev/null +++ b/jooby/src/main/resources/org/jooby/unsecure.key @@ -0,0 +1,14 @@ +-----BEGIN PRIVATE KEY----- +MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAKRDq4GVwBAkOCmxUISDwD99WzHv +8aIe7CsFXalZME0wA3WQTyyp0NTpnZL4/lV1sLzGmXBiKaWG91LmQzkznExteB3uQNBs0r0zwTQb +DATirQoDmCe6pMV3ielFZMmo+NKg1qhO7r7NKhSFwuIUpiIZQXVSKnGJA3bduvxdzEsDAgMBAAEC +gYBQ0UpsczUPvAI14RtwVzIbCp33r8n+raAceoNecpclIt5Q1TNfEh3A4z+3s/HOMh1Gg5+yf1lm +K0U78DZayl23IntYSSsftB4vypdUQVPxSL6GM1cRCV5wTlcgwNwlBgMrIBbXo9oqmlzHKpMGg+o1 +RQVaheJIiVAzhIXZNW5WIQJBANjTWwoOSUsZDeHKNdoK/L9NC8zb0Ww7bqUqL10t3yOuoN5LH1Na +2t8W5VpnG99ecaUFu9+k5Z+5Lz0E8wImNE0CQQDB8T51BJE8m/XRANDFfwa0IPZKkwRmO+mBTPbp +/F8WKudjks9OEfs0S+RB/AQEbkQB0hn3KvQ1Dvs2cEA1o2SPAkAtoHRU7mKwAeqw69tfMdaz7uOf +zVYJf4wuB22GHyQInzPM82P5J3JNZcUHvBDadUZW4pkBW/LSJKbzITp95ko1AkEAoeoAVL19a3Zh +YR4nLdsBA71JIbVfxOJb7eENeweBcwZaq5zTicAlUuHRLO1zhSdxi3uWxe2MeAeL30UTtjQ1LQJA +WieUa6leAQYZdfiSOzKs0YEGyLi4xegpUgKangc3262/fZkbAmJpnWefp2pYgyplCLuTFCwjtHdY +48OYqx3PPw== +-----END PRIVATE KEY----- diff --git a/jooby/src/test/java-excluded/Client.java b/jooby/src/test/java-excluded/Client.java new file mode 100644 index 00000000..7c6e75f7 --- /dev/null +++ b/jooby/src/test/java-excluded/Client.java @@ -0,0 +1,494 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.test; + +import org.apache.http.Header; +import org.apache.http.HttpEntity; +import org.apache.http.HttpRequest; +import org.apache.http.HttpResponse; +import org.apache.http.ProtocolException; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.client.RedirectStrategy; +import org.apache.http.client.fluent.Executor; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.conn.ssl.NoopHostnameVerifier; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.InputStreamEntity; +import org.apache.http.entity.mime.MultipartEntityBuilder; +import org.apache.http.impl.client.BasicCookieStore; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.impl.client.StandardHttpRequestRetryHandler; +import org.apache.http.message.BasicNameValuePair; +import org.apache.http.protocol.HttpContext; +import org.apache.http.ssl.SSLContexts; +import org.apache.http.util.EntityUtils; +import org.jooby.funzy.Try; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import org.junit.rules.ExternalResource; + +import javax.net.ssl.SSLContext; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.BiConsumer; + +/** + * Utility test class integration test. Internal use only. + * + * @author edgar + */ +public class Client extends ExternalResource { + + public interface Callback { + + void execute(String value) throws Exception; + } + + public interface ArrayCallback { + + void execute(String[] values) throws Exception; + } + + public interface ServerCallback { + + void execute(Client request) throws Exception; + } + + public static class Request { + private Executor executor; + + private org.apache.http.client.fluent.Request req; + + private org.apache.http.HttpResponse rsp; + + private Client server; + + public Request(final Client server, final Executor executor, + final org.apache.http.client.fluent.Request req) { + this.server = server; + this.executor = executor; + this.req = req; + } + + public Response execute() throws Exception { + this.rsp = executor.execute(req).returnResponse(); + return new Response(server, rsp); + } + + public Response expect(final String content) throws Exception { + return execute().expect(content); + } + + public Response expect(final Callback callback) throws Exception { + return execute().expect(callback); + } + + public Response expect(final int status) throws Exception { + return execute().expect(status); + } + + public Response expect(final byte[] content) throws Exception { + return execute().expect(content); + } + + public Request header(final String name, final Object value) { + req.addHeader(name, value.toString()); + return this; + } + + public Body multipart() { + return new Body(MultipartEntityBuilder.create(), this); + } + + public Body form() { + return new Body(this); + } + + public void close() { + EntityUtils.consumeQuietly(rsp.getEntity()); + } + + public Request body(final String body, final String type) { + if (type == null) { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + HttpEntity entity = new InputStreamEntity(new ByteArrayInputStream(bytes), bytes.length); + req.body(entity); + } else { + req.bodyString(body, ContentType.parse(type)); + } + return this; + } + + } + + public static class Body { + + private Request req; + + private MultipartEntityBuilder parts; + + private List fields; + + public Body(final MultipartEntityBuilder parts, final Request req) { + this.parts = parts; + this.req = req; + } + + public Body(final Request req) { + this.fields = new ArrayList<>(); + this.req = req; + } + + public Response expect(final String content) throws Exception { + if (parts != null) { + req.req.body(parts.build()); + } else { + req.req.bodyForm(fields); + } + return req.expect(content); + } + + public Response expect(final int status) throws Exception { + if (parts != null) { + req.req.body(parts.build()); + } else { + req.req.bodyForm(fields); + } + return req.expect(status); + } + + public Body add(final String name, final Object value, final String type) { + if (parts != null) { + parts.addTextBody(name, value.toString(), ContentType.parse(type)); + } else { + fields.add(new BasicNameValuePair(name, value.toString())); + } + return this; + } + + public Body add(final String name, final Object value) { + return add(name, value, "text/plain"); + } + + public Body file(final String name, final byte[] bytes, final String type, + final String filename) { + if (parts != null) { + parts.addBinaryBody(name, bytes, ContentType.parse(type), filename); + } else { + throw new IllegalStateException("Not a multipart"); + } + return this; + } + + } + + public static class Response { + + private Client server; + + private HttpResponse rsp; + + public Response(final Client server, final org.apache.http.HttpResponse rsp) { + this.server = server; + this.rsp = rsp; + } + + public Response expect(final String content) throws Exception { + assertEquals(content, EntityUtils.toString(this.rsp.getEntity())); + return this; + } + + public Response expect(final int status) throws Exception { + assertEquals(status, rsp.getStatusLine().getStatusCode()); + return this; + } + + public Response expect(final byte[] content) throws Exception { + assertArrayEquals(content, EntityUtils.toByteArray(this.rsp.getEntity())); + return this; + } + + public Response expect(final Callback callback) throws Exception { + callback.execute(EntityUtils.toString(this.rsp.getEntity())); + return this; + } + + public Response header(final String headerName, final String headerValue) + throws Exception { + if (headerValue == null) { + assertNull(rsp.getFirstHeader(headerName)); + } else { + Header header = rsp.getFirstHeader(headerName); + if (header == null) { + // friendly junit err + assertEquals(headerValue, header); + } else { + assertEquals(headerValue.toLowerCase(), header.getValue().toLowerCase()); + } + } + return this; + } + + public Response headers(final BiConsumer headers) + throws Exception { + for (Header header : rsp.getAllHeaders()) { + headers.accept(header.getName(), header.getValue()); + } + return this; + } + + public Response header(final String headerName, final Object headerValue) + throws Exception { + if (headerValue == null) { + return header(headerName, (String) null); + } else { + return header(headerName, headerValue.toString()); + } + } + + public Response header(final String headerName, final Optional headerValue) + throws Exception { + Header header = rsp.getFirstHeader(headerName); + if (header != null) { + assertEquals(headerValue.get(), header.getValue()); + } + return this; + } + + public Response header(final String headerName, final Callback callback) throws Exception { + callback.execute( + Optional.ofNullable(rsp.getFirstHeader(headerName)) + .map(Header::getValue) + .orElse(null)); + return this; + } + + public Response headers(final String headerName, final ArrayCallback callback) + throws Exception { + Header[] headers = rsp.getHeaders(headerName); + String[] values = new String[headers.length]; + for (int i = 0; i < values.length; i++) { + values[i] = headers[i].getValue(); + } + callback.execute(values); + return this; + } + + public Response empty() throws Exception { + HttpEntity entity = this.rsp.getEntity(); + if (entity != null) { + assertEquals("", EntityUtils.toString(entity)); + } + return this; + } + + public void request(final ServerCallback request) throws Exception { + request.execute(server); + } + + public void startsWith(final String value) throws IOException { + String rsp = EntityUtils.toString(this.rsp.getEntity()); + if (!rsp.startsWith(value)) { + assertEquals(value, rsp); + } + } + + } + + private Executor executor; + + private CloseableHttpClient client; + + private BasicCookieStore cookieStore; + + private String host; + + private Request req; + + private HttpClientBuilder builder; + + private UsernamePasswordCredentials creds; + + public Client(final String host) { + this.host = host; + } + + public Client() { + this("http://localhost:8080"); + } + + public void start() { + this.cookieStore = new BasicCookieStore(); + this.builder = HttpClientBuilder.create() + .setMaxConnTotal(1) + .setRetryHandler(new StandardHttpRequestRetryHandler(0, false)) + .setMaxConnPerRoute(1) + .setDefaultCookieStore(cookieStore); + } + + public Client resetCookies() { + cookieStore.clear(); + return this; + } + + public Client dontFollowRedirect() { + builder.setRedirectStrategy(new RedirectStrategy() { + + @Override + public boolean isRedirected(final HttpRequest request, final HttpResponse response, + final HttpContext context) throws ProtocolException { + return false; + } + + @Override + public HttpUriRequest getRedirect(final HttpRequest request, final HttpResponse response, + final HttpContext context) throws ProtocolException { + return null; + } + }); + return this; + } + + public Request get(final String path) { + this.req = new Request(this, executor(), org.apache.http.client.fluent.Request.Get(host + + path)); + return req; + } + + public Request trace(final String path) { + this.req = new Request(this, executor(), org.apache.http.client.fluent.Request.Trace(host + + path)); + return req; + } + + public Request options(final String path) { + this.req = new Request(this, executor(), org.apache.http.client.fluent.Request.Options(host + + path)); + return req; + } + + public Request head(final String path) { + this.req = new Request(this, executor(), org.apache.http.client.fluent.Request.Head(host + + path)); + return req; + } + + private Executor executor() { + if (executor == null) { + if (this.host.startsWith("https://")) { + Try.run(() -> { + SSLContext sslContext = SSLContexts.custom() + .loadTrustMaterial(null, (chain, authType) -> true) + .build(); + builder.setSSLContext(sslContext); + builder.setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE); + }).throwException(); + } + client = builder.build(); + executor = Executor.newInstance(client); + if (creds != null) { + executor.auth(creds); + } + } + return executor; + } + + public Request post(final String path) { + this.req = new Request(this, executor(), org.apache.http.client.fluent.Request.Post(host + + path)); + return req; + } + + public Request put(final String path) { + this.req = new Request(this, executor(), org.apache.http.client.fluent.Request.Put(host + + path)); + return req; + } + + public Request delete(final String path) { + this.req = new Request(this, executor(), org.apache.http.client.fluent.Request.Delete(host + + path)); + return req; + } + + public Request patch(final String path) { + this.req = new Request(this, executor(), pathHack(host + path)); + return req; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private org.apache.http.client.fluent.Request pathHack(final String string) { + try { + // Patch is available since 4.4, but we are in 4.3 because of AWS-SDK + Class ireqclass = getClass().getClassLoader().loadClass( + "org.apache.http.client.fluent.InternalHttpRequest"); + Constructor constructor = org.apache.http.client.fluent.Request.class + .getDeclaredConstructor(ireqclass); + constructor.setAccessible(true); + + Constructor ireqcons = ireqclass.getDeclaredConstructor(String.class, URI.class); + ireqcons.setAccessible(true); + Object ireq = ireqcons.newInstance("PATCH", URI.create(string)); + return constructor.newInstance(ireq); + } catch (NoSuchMethodException | SecurityException | ClassNotFoundException + | InstantiationException | IllegalAccessException | IllegalArgumentException + | InvocationTargetException ex) { + throw new UnsupportedOperationException(ex); + } + } + + public void stop() throws IOException { + if (this.req != null) { + try { + this.req.close(); + } catch (NullPointerException ex) { + } + } + if (client != null) { + client.close(); + } + this.builder = null; + this.executor = null; + } + + public Client basic(final String username, final String password) { + creds = new UsernamePasswordCredentials(username, password); + return this; + } + + @Override + protected void before() throws Throwable { + start(); + } + + @Override + protected void after() { + try { + stop(); + } catch (IOException ex) { + throw new IllegalStateException("Unable to stop client", ex); + } + } +} diff --git a/jooby/src/test/java-excluded/JoobyRuleTest.java b/jooby/src/test/java-excluded/JoobyRuleTest.java new file mode 100644 index 00000000..8c0431e4 --- /dev/null +++ b/jooby/src/test/java-excluded/JoobyRuleTest.java @@ -0,0 +1,51 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.test; + +import org.jooby.Jooby; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({JoobyRule.class, Jooby.class }) +public class JoobyRuleTest { + + @Test + public void before() throws Exception { + new MockUnit(Jooby.class) + .expect(unit -> { + Jooby app = unit.get(Jooby.class); + app.start("server.join=false"); + }) + .run(unit -> { + new JoobyRule(unit.get(Jooby.class)).before(); + }); + } + + @Test + public void after() throws Exception { + new MockUnit(Jooby.class) + .expect(unit -> { + Jooby app = unit.get(Jooby.class); + app.stop(); + }) + .run(unit -> { + new JoobyRule(unit.get(Jooby.class)).after(); + }); + } +} diff --git a/jooby/src/test/java-excluded/JoobyRunner.java b/jooby/src/test/java-excluded/JoobyRunner.java new file mode 100644 index 00000000..ec8f502f --- /dev/null +++ b/jooby/src/test/java-excluded/JoobyRunner.java @@ -0,0 +1,217 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.test; + +import com.google.inject.Binder; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; +import com.typesafe.config.ConfigValueFactory; +import org.jooby.Env; +import org.jooby.Jooby; +import org.junit.runners.BlockJUnit4ClassRunner; +import org.junit.runners.model.FrameworkMethod; +import org.junit.runners.model.InitializationError; +import org.junit.runners.model.MultipleFailureException; +import org.junit.runners.model.Statement; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.net.ServerSocket; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * JUnit4 block runner for Jooby. Internal use only. + * + * @author edgar + */ +public class JoobyRunner extends BlockJUnit4ClassRunner { + + private Jooby app; + + private int port; + private int securePort; + + private Class server; + + public JoobyRunner(final Class klass) throws InitializationError { + super(klass); + prepare(klass, null); + } + + public JoobyRunner(final Class klass, final Class server) throws InitializationError { + super(klass); + prepare(klass, server); + } + + @Override + protected String getName() { + if (server != null) { + return "[" + server.getSimpleName().toLowerCase() + "]"; + } + return super.getName(); + } + + @Override + protected String testName(final FrameworkMethod method) { + if (server != null) { + return method.getName() + getName(); + } + return super.testName(method); + } + + private void prepare(final Class klass, final Class server) throws InitializationError { + try { + this.port = port("coverage.port", 9999); + this.securePort = port("coverage.securePort", 9943); + this.server = server; + Class appClass = klass; + if (!Jooby.class.isAssignableFrom(appClass)) { + throw new InitializationError("Invalid jooby app: " + appClass); + } + int processors = Math.max(1, Runtime.getRuntime().availableProcessors()); + // required by Jetty (processors * 2, 1(http), 1(https), 1(request) + int maxThreads = processors * 2 + 3; + Config config = ConfigFactory.empty("test-config") + .withValue("server.join", ConfigValueFactory.fromAnyRef(false)) + .withValue("server.http.IdleTimeout", ConfigValueFactory.fromAnyRef("5m")) + .withValue("server.threads.Min", ConfigValueFactory.fromAnyRef(1)) + .withValue("server.threads.Max", ConfigValueFactory.fromAnyRef(maxThreads)) + .withValue("application.port", ConfigValueFactory.fromAnyRef(port)) + .withValue("undertow.ioThreads", ConfigValueFactory.fromAnyRef(2)) + .withValue("undertow.workerThreads", ConfigValueFactory.fromAnyRef(4)) + .withValue("netty.threads.Parent", ConfigValueFactory.fromAnyRef(2)); + + if (server != null) { + config = config.withFallback(ConfigFactory.empty() + .withValue("server.module", ConfigValueFactory.fromAnyRef(server.getName()))); + } + + app = (Jooby) appClass.newInstance(); + app.throwBootstrapException(); + if (app instanceof ServerFeature) { + int appport = ((ServerFeature) app).port; + if (appport > 0) { + config = config.withValue("application.port", ConfigValueFactory.fromAnyRef(appport)); + this.port = appport; + } + + int sappport = ((ServerFeature) app).securePort; + if (sappport > 0) { + config = config.withValue("application.securePort", + ConfigValueFactory.fromAnyRef(sappport)); + this.securePort = sappport; + } + } + Config testConfig = config; + app.use(new Jooby.Module() { + @Override + public void configure(final Env mode, final Config config, final Binder binder) { + } + + @Override + public Config config() { + return testConfig; + } + }); + } catch (Exception ex) { + throw new InitializationError(Arrays.asList(ex)); + } + } + + @Override + protected Statement withBeforeClasses(final Statement statement) { + Statement next = super.withBeforeClasses(statement); + return new Statement() { + + @Override + public void evaluate() throws Throwable { + app.start(); + + next.evaluate(); + } + }; + } + + @Override + protected Object createTest() throws Exception { + Object test = super.createTest(); + Class c = test.getClass(); + set(test, c, "port", port); + set(test, c, "securePort", securePort); + + return test; + } + + @SuppressWarnings("rawtypes") + private void set(final Object test, final Class clazz, final String field, final Object value) + throws Exception { + try { + Field f = clazz.getDeclaredField(field); + f.setAccessible(true); + f.set(test, value); + } catch (NoSuchFieldException ex) { + Class superclass = clazz.getSuperclass(); + if (superclass != Object.class) { + set(test, superclass, field, value); + } + } + + } + + @Override + protected Statement withAfterClasses(final Statement statement) { + Statement next = super.withAfterClasses(statement); + return new Statement() { + + @Override + public void evaluate() throws Throwable { + List errors = new ArrayList(); + try { + next.evaluate(); + } catch (Throwable e) { + errors.add(e); + } + + try { + app.stop(); + } catch (Exception ex) { + errors.add(ex); + } + if (errors.isEmpty()) { + return; + } + if (errors.size() == 1) { + throw errors.get(0); + } + throw new MultipleFailureException(errors); + } + }; + } + + private int port(String property, Integer defaultPort) throws IOException { + String port = System.getProperty(property, defaultPort.toString()); + if (port.equalsIgnoreCase("random")) { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } else { + return Integer.parseInt(port); + } + } + +} diff --git a/jooby/src/test/java-excluded/MockRouterTest.java b/jooby/src/test/java-excluded/MockRouterTest.java new file mode 100644 index 00000000..e3079e5d --- /dev/null +++ b/jooby/src/test/java-excluded/MockRouterTest.java @@ -0,0 +1,514 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.test; + +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.isA; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicInteger; + +import org.jooby.Err; +import org.jooby.Jooby; +import org.jooby.Mutant; +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Result; +import org.jooby.Results; +import org.jooby.Status; +import org.jooby.View; +import org.jooby.test.MockRouter; +import org.junit.Test; + +import com.google.inject.Injector; + +public class MockRouterTest { + + public static class HelloService { + + public String hello() { + return "Hi"; + } + + } + + public static class MyForm { + + public String hello() { + return "Hi"; + } + + } + + public static class HelloWorld extends Jooby { + { + + before("/before", (req, rsp) -> { + req.charset(); + }); + + after("/before", (req, rsp, result) -> { + req.charset(); + return result; + }); + + after("/afterResult", (req, rsp, result) -> { + return Results.with(result.get() + ":unit"); + }); + + get("/afterResult", () -> { + return Results.with("hello"); + }); + + get("/injector", () -> { + return require(Injector.class).getParent(); + }); + + get("/async", promise(deferred -> { + deferred.resolve("async"); + })); + + get("/deferred", deferred(() -> { + return "deferred"; + })); + + get("/deferred-err", deferred(() -> { + throw new IllegalStateException("intentional err"); + })); + + get("/deferred-executor", deferred("executor", () -> { + return "deferred"; + })); + + get("/before", req -> req.charset()); + + post("/form", req -> req.form(MyForm.class).hello()); + + put("/form", req -> req.form(MyForm.class).hello()); + + patch("/form", req -> req.form(MyForm.class).hello()); + + delete("/item/:id", req -> req.param("id").intValue()); + + get("/result", req -> Results.html("index")); + + get("/hello", () -> "Hello world!"); + + get("/request", req -> req.path()); + + get("/rsp.send", (req, rsp) -> { + rsp.send("Response"); + }); + + get("/rsp.send.result", (req, rsp) -> { + rsp.send(Results.with("Response")); + }); + + AtomicInteger inc = new AtomicInteger(0); + get("/chain", (req, rsp) -> inc.incrementAndGet()); + get("/chain", (req, rsp) -> inc.incrementAndGet()); + get("/chain", () -> inc.incrementAndGet()); + + get("/require", () -> { + return require(HelloService.class).hello(); + }); + + get("/requirenamed", () -> { + return require("foo", HelloService.class).hello(); + }); + + get("/params", req -> { + return req.param("foo").value("bar"); + }); + + get("/rsp.committed", (req, rsp) -> { + rsp.send("committed"); + }); + + get("/rsp.committed", (req, rsp) -> { + rsp.send("ignored"); + }); + + get("/before", req -> req.charset()); + } + } + + @Test + public void basicCall() throws Exception { + new MockUnit() + .run(unit -> { + String result = new MockRouter(new HelloWorld()) + .get("/hello"); + assertEquals("Hello world!", result); + }); + } + + @Test(expected = UnsupportedOperationException.class) + public void fakedInjector() throws Exception { + new MockUnit(Request.class, Response.class) + .run(unit -> { + new MockRouter(new HelloWorld(), + unit.get(Request.class), + unit.get(Response.class)) + .get("/injector"); + }); + } + + @Test + public void post() throws Exception { + new MockUnit(Request.class, Response.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.form(MyForm.class)).andReturn(new MyForm()); + }) + .run(unit -> { + String result = new MockRouter(new HelloWorld(), + unit.get(Request.class), + unit.get(Response.class)) + .post("/form"); + assertEquals("Hi", result); + }); + } + + @Test + public void put() throws Exception { + new MockUnit(Request.class, Response.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.form(MyForm.class)).andReturn(new MyForm()); + }) + .run(unit -> { + String result = new MockRouter(new HelloWorld(), + unit.get(Request.class), + unit.get(Response.class)) + .put("/form"); + assertEquals("Hi", result); + }); + } + + @Test + public void patch() throws Exception { + new MockUnit(Request.class, Response.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.form(MyForm.class)).andReturn(new MyForm()); + }) + .run(unit -> { + String result = new MockRouter(new HelloWorld(), + unit.get(Request.class), + unit.get(Response.class)) + .patch("/form"); + assertEquals("Hi", result); + }); + } + + @Test + public void delete() throws Exception { + new MockUnit(Request.class, Response.class) + .expect(unit -> { + Mutant id = unit.mock(Mutant.class); + expect(id.intValue()).andReturn(123); + + Request req = unit.get(Request.class); + expect(req.param("id")).andReturn(id); + }) + .run(unit -> { + Integer result = new MockRouter(new HelloWorld(), + unit.get(Request.class), + unit.get(Response.class)) + .delete("/item/123"); + assertEquals(123, result.intValue()); + }); + } + + @Test(expected = UnsupportedOperationException.class) + public void requestAccessEmptyRequest() throws Exception { + new MockUnit() + .run(unit -> { + new MockRouter(new HelloWorld()) + .get("/request"); + }); + } + + @Test + public void requestAccess() throws Exception { + new MockUnit(Request.class, Response.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.path()).andReturn("/mock-path"); + }) + .run(unit -> { + String result = new MockRouter(new HelloWorld(), + unit.get(Request.class), + unit.get(Response.class)) + .get("/request"); + assertEquals("/mock-path", result); + }); + } + + @Test + public void routeChain() throws Exception { + new MockUnit(Request.class) + .run(unit -> { + Integer result = new MockRouter(new HelloWorld(), + unit.get(Request.class)) + .get("/chain"); + assertEquals(3, result.intValue()); + }); + } + + @Test + public void responseSend() throws Exception { + new MockUnit(Request.class, Response.class) + .expect(unit -> { + Response rsp = unit.get(Response.class); + rsp.send("Response"); + }) + .run(unit -> { + Object result = new MockRouter(new HelloWorld(), + unit.get(Request.class), + unit.get(Response.class)) + .get("/rsp.send"); + + assertEquals("Response", result); + }); + } + + @Test + public void responseSendResult() throws Exception { + new MockUnit(Request.class, Response.class) + .expect(unit -> { + Response rsp = unit.get(Response.class); + rsp.send(isA(Result.class)); + }) + .run(unit -> { + Result result = new MockRouter(new HelloWorld(), + unit.get(Request.class), + unit.get(Response.class)) + .get("/rsp.send.result"); + + assertEquals("Response", result.get()); + }); + } + + @Test + public void requireService() throws Exception { + new MockUnit(Request.class, Response.class, HelloService.class) + .expect(unit -> { + HelloService rsp = unit.get(HelloService.class); + expect(rsp.hello()).andReturn("Hola"); + }) + .run(unit -> { + String result = new MockRouter(new HelloWorld(), + unit.get(Request.class), + unit.get(Response.class)) + .set(unit.get(HelloService.class)) + .get("/require"); + + assertEquals("Hola", result); + }); + } + + @Test(expected = IllegalStateException.class) + public void serviceNotFound() throws Exception { + new MockUnit(Request.class, Response.class, HelloService.class) + .run(unit -> { + String result = new MockRouter(new HelloWorld(), + unit.get(Request.class), + unit.get(Response.class)) + .get("/require"); + + assertEquals("Hola", result); + }); + } + + @Test + public void requireNamedService() throws Exception { + new MockUnit(Request.class, Response.class, HelloService.class) + .expect(unit -> { + HelloService rsp = unit.get(HelloService.class); + expect(rsp.hello()).andReturn("Named"); + }) + .run(unit -> { + String result = new MockRouter(new HelloWorld(), + unit.get(Request.class), + unit.get(Response.class)) + .set("foo", unit.get(HelloService.class)) + .get("/requirenamed"); + + assertEquals("Named", result); + }); + } + + @Test(expected = IllegalStateException.class) + public void requireNamedServiceNotFound() throws Exception { + new MockUnit(Request.class, Response.class, HelloService.class) + .run(unit -> { + String result = new MockRouter(new HelloWorld(), + unit.get(Request.class), + unit.get(Response.class)) + .get("/requirenamed"); + + assertEquals("Named", result); + }); + } + + @Test + public void requestMockParam() throws Exception { + new MockUnit(Request.class, Response.class) + .expect(unit -> { + Mutant foo = unit.mock(Mutant.class); + expect(foo.value("bar")).andReturn("mock"); + Request req = unit.get(Request.class); + expect(req.param("foo")).andReturn(foo); + }) + .run(unit -> { + String result = new MockRouter(new HelloWorld(), + unit.get(Request.class), + unit.get(Response.class)) + .get("/params"); + + assertEquals("mock", result); + }); + } + + @Test + public void beforeAfterRequest() throws Exception { + new MockUnit(Request.class, Response.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.charset()).andReturn(StandardCharsets.US_ASCII).times(3); + }) + .run(unit -> { + Charset result = new MockRouter(new HelloWorld(), + unit.get(Request.class), + unit.get(Response.class)) + .get("/before"); + + assertEquals(StandardCharsets.US_ASCII, result); + }); + } + + @Test + public void afterResult() throws Exception { + new MockUnit(Request.class, Response.class) + .run(unit -> { + Result result = new MockRouter(new HelloWorld(), + unit.get(Request.class), + unit.get(Response.class)) + .get("/afterResult"); + + assertEquals("hello:unit", result.get()); + }); + } + + @Test + public void resultResponse() throws Exception { + new MockUnit(Request.class, Response.class) + .run(unit -> { + View result = new MockRouter(new HelloWorld(), + unit.get(Request.class), + unit.get(Response.class)) + .get("/result"); + + assertEquals("index", result.name()); + }); + } + + @Test + public void responseCommitted() throws Exception { + new MockUnit(Request.class, Response.class) + .expect(unit -> { + Response rsp = unit.get(Response.class); + rsp.send("committed"); + }) + .run(unit -> { + String result = new MockRouter(new HelloWorld(), + unit.get(Request.class), + unit.get(Response.class)) + .get("/rsp.committed"); + + assertEquals("committed", result); + }); + } + + @Test + public void async() throws Exception { + new MockUnit(Request.class, Response.class) + .run(unit -> { + String result = new MockRouter(new HelloWorld(), + unit.get(Request.class), + unit.get(Response.class)) + .get("/async"); + + assertEquals("async", result); + }); + } + + @Test + public void deferred() throws Exception { + new MockUnit(Request.class, Response.class) + .run(unit -> { + String result = new MockRouter(new HelloWorld(), + unit.get(Request.class), + unit.get(Response.class)) + .get("/deferred"); + + assertEquals("deferred", result); + }); + + new MockUnit(Request.class, Response.class) + .run(unit -> { + String result = new MockRouter(new HelloWorld(), + unit.get(Request.class), + unit.get(Response.class)) + .get("/deferred-executor"); + + assertEquals("deferred", result); + }); + } + + @Test(expected = IllegalStateException.class) + public void deferredReject() throws Exception { + new MockUnit(Request.class, Response.class) + .run(unit -> { + new MockRouter(new HelloWorld(), + unit.get(Request.class), + unit.get(Response.class)) + .get("/deferred-err"); + }); + } + + @Test + public void notFound() throws Exception { + new MockUnit(Request.class, Response.class) + .run(unit -> { + try { + new MockRouter(new HelloWorld(), + unit.get(Request.class), + unit.get(Response.class)) + .get("/notFound"); + fail(); + } catch (Err x) { + assertEquals(Status.NOT_FOUND.value(), x.statusCode()); + } + }); + } + +} diff --git a/jooby/src/test/java-excluded/MockUnit.java b/jooby/src/test/java-excluded/MockUnit.java new file mode 100644 index 00000000..476301a3 --- /dev/null +++ b/jooby/src/test/java-excluded/MockUnit.java @@ -0,0 +1,273 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.test; + +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.Multimap; +import com.google.common.primitives.Primitives; +import static java.util.Objects.requireNonNull; +import org.easymock.Capture; +import org.easymock.EasyMock; +import static org.easymock.EasyMock.createMock; +import static org.easymock.EasyMock.createStrictMock; +import org.jooby.funzy.Try; +import org.powermock.api.easymock.PowerMock; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Utility test class for mocks. Internal use only. + * + * @author edgar + */ +@SuppressWarnings({"rawtypes", "unchecked" }) +public class MockUnit { + + public class ConstructorBuilder { + + private Class[] types; + + private Class type; + + public ConstructorBuilder(final Class type) { + this.type = type; + } + + public T build(final Object... args) throws Exception { + mockClasses.add(type); + if (types == null) { + types = Arrays.asList(type.getDeclaredConstructors()) + .stream() + .filter(c -> { + Class[] types = c.getParameterTypes(); + if (types.length == args.length) { + for (int i = 0; i < types.length; i++) { + if (!types[i].isInstance(args[i]) + && !Primitives.wrap(types[i]).isInstance(args[i])) { + return false; + } + } + return true; + } + return false; + }).map(Constructor::getParameterTypes) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unable to find parameter types")); + } + T mock = PowerMock.createMockAndExpectNew(type, types, args); + partialMocks.add(mock); + return mock; + } + + public ConstructorBuilder args(final Class... types) { + this.types = types; + return this; + } + + } + + public interface Block { + + public void run(MockUnit unit) throws Throwable; + + } + + private List mocks = new LinkedList<>(); + + private List partialMocks = new LinkedList<>(); + + private Multimap globalMock = ArrayListMultimap.create(); + + private Map>> captures = new LinkedHashMap<>(); + + private Set mockClasses = new LinkedHashSet<>(); + + private List blocks = new LinkedList<>(); + + public MockUnit(final Class... types) { + this(false, types); + } + + public MockUnit(final boolean strict, final Class... types) { + Arrays.stream(types).forEach(type -> { + registerMock(type); + }); + } + + public T capture(final Class type) { + Capture capture = new Capture<>(); + List> captures = this.captures.get(type); + if (captures == null) { + captures = new ArrayList<>(); + this.captures.put(type, captures); + } + captures.add(capture); + return (T) EasyMock.capture(capture); + } + + public List captured(final Class type) { + List> captureList = this.captures.get(type); + List result = new LinkedList<>(); + captureList.stream().filter(Capture::hasCaptured).forEach(it -> result.add((T) it.getValue())); + return result; + } + + public Class mockStatic(final Class type) { + if (mockClasses.add(type)) { + PowerMock.mockStatic(type); + mockClasses.add(type); + } + return type; + } + + public Class mockStaticPartial(final Class type, final String... names) { + if (mockClasses.add(type)) { + PowerMock.mockStaticPartial(type, names); + mockClasses.add(type); + } + return type; + } + + public T partialMock(final Class type, final String... methods) { + T mock = PowerMock.createPartialMock(type, methods); + partialMocks.add(mock); + return mock; + } + + public T partialMock(final Class type, final String method, final Class firstArg) { + T mock = PowerMock.createPartialMock(type, method, firstArg); + partialMocks.add(mock); + return mock; + } + + public T partialMock(final Class type, final String method, final Class t1, + final Class t2) { + T mock = PowerMock.createPartialMock(type, method, t1, t2); + partialMocks.add(mock); + return mock; + } + + public T mock(final Class type) { + return mock(type, false); + } + + public T powerMock(final Class type) { + T mock = PowerMock.createMock(type); + partialMocks.add(mock); + return mock; + } + + public T mock(final Class type, final boolean strict) { + if (Modifier.isFinal(type.getModifiers())) { + T mock = PowerMock.createMock(type); + partialMocks.add(mock); + return mock; + } else { + + T mock = strict ? createStrictMock(type) : createMock(type); + mocks.add(mock); + return mock; + } + } + + public T registerMock(final Class type) { + T mock = mock(type); + globalMock.put(type, mock); + return mock; + } + + public T registerMock(final Class type, final T mock) { + globalMock.put(type, mock); + return mock; + } + + public T get(final Class type) { + try { + List collection = (List) requireNonNull(globalMock.get(type)); + T m = (T) collection.get(collection.size() - 1); + return m; + } catch (ArrayIndexOutOfBoundsException ex) { + throw new IllegalStateException("Not found: " + type); + } + } + + public T first(final Class type) { + List collection = (List) requireNonNull(globalMock.get(type), + "Mock not found: " + type); + return (T) collection.get(0); + } + + public MockUnit expect(final Block block) { + blocks.add(requireNonNull(block, "A block is required.")); + return this; + } + + public MockUnit run(final Block block) throws Exception { + return run(new Block[] {block}); + } + + public MockUnit run(final Block... blocks) throws Exception { + + for (Block block : this.blocks) { + Try.run(() -> block.run(this)) + .throwException(); + } + + mockClasses.forEach(PowerMock::replay); + partialMocks.forEach(PowerMock::replay); + mocks.forEach(EasyMock::replay); + + for (Block main : blocks) { + Try.run(() -> main.run(this)).throwException(); + } + + mocks.forEach(EasyMock::verify); + partialMocks.forEach(PowerMock::verify); + mockClasses.forEach(PowerMock::verify); + + return this; + } + + public T mockConstructor(final Class type, final Class[] paramTypes, + final Object... args) throws Exception { + mockClasses.add(type); + T mock = PowerMock.createMockAndExpectNew(type, paramTypes, args); + partialMocks.add(mock); + return mock; + } + + public T mockConstructor(final Class type, final Object... args) throws Exception { + Class[] types = new Class[args.length]; + for (int i = 0; i < types.length; i++) { + types[i] = args[i].getClass(); + } + return mockConstructor(type, types, args); + } + + public ConstructorBuilder constructor(final Class type) { + return new ConstructorBuilder(type); + } + +} diff --git a/jooby/src/test/java-excluded/ServerFeature.java b/jooby/src/test/java-excluded/ServerFeature.java new file mode 100644 index 00000000..295973fa --- /dev/null +++ b/jooby/src/test/java-excluded/ServerFeature.java @@ -0,0 +1,111 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.test; + +import static com.google.common.base.Preconditions.checkState; + +import java.io.IOException; + +import org.apache.http.client.utils.URIBuilder; +import org.jooby.Jooby; +import org.junit.Before; +import org.junit.Rule; +import org.junit.runner.RunWith; + +import com.google.common.base.Joiner; + +/** + * Internal use only. + * + * @author edgar + */ +@RunWith(JoobySuite.class) +public abstract class ServerFeature extends Jooby { + + public static boolean DEBUG = false; + + protected int port; + + protected int securePort; + + public static String protocol = "http"; + + private Client server = null; + + public ServerFeature(final String prefix) { + super(prefix); + } + + public ServerFeature() { + } + + @Before + public void debug() { + if (DEBUG) { + java.util.logging.Logger.getLogger("httpclient.wire.header").setLevel( + java.util.logging.Level.FINEST); + java.util.logging.Logger.getLogger("httpclient.wire.content").setLevel( + java.util.logging.Level.FINEST); + + System.setProperty("org.apache.commons.logging.Log", + "org.apache.commons.logging.impl.SimpleLog"); + System.setProperty("org.apache.commons.logging.simplelog.showdatetime", "true"); + System.setProperty("org.apache.commons.logging.simplelog.log.httpclient.wire", "debug"); + System.setProperty("org.apache.commons.logging.simplelog.log.org.apache.http", "debug"); + System.setProperty("org.apache.commons.logging.simplelog.log.org.apache.http.headers", + "debug"); + } + } + + @Rule + public Client createServer() { + checkState(server == null, "Server was created already"); + server = new Client( + protocol + "://localhost:" + (protocol.equals("https") ? securePort : port)); + return server; + } + + public Client request() { + checkState(server != null, "Server wasn't started"); + return server; + } + + public Client https() throws IOException { + server.stop(); + server = new Client("https://localhost:" + securePort); + server.start(); + return server; + } + + protected URIBuilder ws(final String... parts) throws Exception { + URIBuilder builder = new URIBuilder("ws://localhost:" + port + "/" + + Joiner.on("/").join(parts)); + return builder; + } + + @Override + public Jooby securePort(final int port) { + this.securePort = port; + return super.securePort(port); + } + + @Override + public Jooby port(final int port) { + this.port = port; + return super.port(port); + } + +} diff --git a/jooby/src/test/java-excluded/SseFeature.java b/jooby/src/test/java-excluded/SseFeature.java new file mode 100644 index 00000000..b5a5ae36 --- /dev/null +++ b/jooby/src/test/java-excluded/SseFeature.java @@ -0,0 +1,108 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.test; + +import static org.junit.Assert.assertEquals; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CountDownLatch; + +import org.jooby.Jooby; +import org.jooby.MediaType; +import org.junit.After; +import org.junit.Before; +import org.junit.runner.RunWith; + +import com.ning.http.client.AsyncHandler; +import com.ning.http.client.AsyncHttpClient; +import com.ning.http.client.AsyncHttpClientConfig; +import com.ning.http.client.FluentCaseInsensitiveStringsMap; +import com.ning.http.client.HttpResponseBodyPart; +import com.ning.http.client.HttpResponseHeaders; +import com.ning.http.client.HttpResponseStatus; + +/** + * Internal use only. + * + * @author edgar + */ +@RunWith(JoobySuite.class) +public abstract class SseFeature extends Jooby { + + private int port; + + private AsyncHttpClient client; + + @Before + public void before() { + client = new AsyncHttpClient(new AsyncHttpClientConfig.Builder().build()); + } + + @After + public void after() { + client.close(); + } + + public String sse(final String path, final int count) throws Exception { + CountDownLatch latch = new CountDownLatch(count); + String result = client.prepareGet("http://localhost:" + port + path) + .addHeader("Content-Type", MediaType.sse.name()) + .addHeader("last-event-id", count + "") + .execute(new AsyncHandler() { + + StringBuilder sb = new StringBuilder(); + + @Override + public void onThrowable(final Throwable t) { + t.printStackTrace(); + } + + @Override + public AsyncHandler.STATE onBodyPartReceived(final HttpResponseBodyPart bodyPart) + throws Exception { + sb.append(new String(bodyPart.getBodyPartBytes(), StandardCharsets.UTF_8)); + latch.countDown(); + return AsyncHandler.STATE.CONTINUE; + } + + @Override + public AsyncHandler.STATE onStatusReceived(final HttpResponseStatus responseStatus) + throws Exception { + assertEquals(200, responseStatus.getStatusCode()); + return AsyncHandler.STATE.CONTINUE; + } + + @Override + public AsyncHandler.STATE onHeadersReceived(final HttpResponseHeaders headers) + throws Exception { + FluentCaseInsensitiveStringsMap h = headers.getHeaders(); + assertEquals("close", h.get("Connection").get(0).toLowerCase()); + assertEquals("text/event-stream; charset=utf-8", + h.get("Content-Type").get(0).toLowerCase()); + return AsyncHandler.STATE.CONTINUE; + } + + @Override + public String onCompleted() throws Exception { + return sb.toString(); + } + }).get(); + + latch.await(); + return result; + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/AssetForwardingTest.java b/jooby/src/test/java-excluded/org/jooby/AssetForwardingTest.java new file mode 100644 index 00000000..6de44570 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/AssetForwardingTest.java @@ -0,0 +1,127 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static org.easymock.EasyMock.expect; +import org.jooby.test.MockUnit; +import static org.junit.Assert.assertEquals; +import org.junit.Test; + +import java.io.File; +import java.io.InputStream; +import java.net.URL; + +public class AssetForwardingTest { + + @Test + public void etag() throws Exception { + new MockUnit(Asset.class) + .expect(unit -> { + Asset asset = unit.get(Asset.class); + expect(asset.etag()).andReturn("tag"); + }) + .run(unit -> { + assertEquals("tag", new Asset.Forwarding(unit.get(Asset.class)).etag()); + }); + } + + @Test + public void lastModified() throws Exception { + new MockUnit(Asset.class) + .expect(unit -> { + Asset asset = unit.get(Asset.class); + expect(asset.lastModified()).andReturn(1L); + }) + .run(unit -> { + assertEquals(1L, new Asset.Forwarding(unit.get(Asset.class)).lastModified()); + }); + } + + @Test + public void len() throws Exception { + new MockUnit(Asset.class) + .expect(unit -> { + Asset asset = unit.get(Asset.class); + expect(asset.length()).andReturn(1L); + }) + .run(unit -> { + assertEquals(1L, new Asset.Forwarding(unit.get(Asset.class)).length()); + }); + } + + @Test + public void name() throws Exception { + new MockUnit(Asset.class) + .expect(unit -> { + Asset asset = unit.get(Asset.class); + expect(asset.name()).andReturn("n"); + }) + .run(unit -> { + assertEquals("n", new Asset.Forwarding(unit.get(Asset.class)).name()); + }); + } + + @Test + public void path() throws Exception { + new MockUnit(Asset.class) + .expect(unit -> { + Asset asset = unit.get(Asset.class); + expect(asset.path()).andReturn("p"); + }) + .run(unit -> { + assertEquals("p", new Asset.Forwarding(unit.get(Asset.class)).path()); + }); + } + + @Test + public void url() throws Exception { + URL url = new File("pom.xml").toURI().toURL(); + new MockUnit(Asset.class) + .expect(unit -> { + Asset asset = unit.get(Asset.class); + expect(asset.resource()).andReturn(url); + }) + .run(unit -> { + assertEquals(url, new Asset.Forwarding(unit.get(Asset.class)).resource()); + }); + } + + @Test + public void stream() throws Exception { + new MockUnit(Asset.class, InputStream.class) + .expect(unit -> { + Asset asset = unit.get(Asset.class); + expect(asset.stream()).andReturn(unit.get(InputStream.class)); + }) + .run(unit -> { + assertEquals(unit.get(InputStream.class), + new Asset.Forwarding(unit.get(Asset.class)).stream()); + }); + } + + @Test + public void type() throws Exception { + new MockUnit(Asset.class) + .expect(unit -> { + Asset asset = unit.get(Asset.class); + expect(asset.type()).andReturn(MediaType.css); + }) + .run(unit -> { + assertEquals(MediaType.css, new Asset.Forwarding(unit.get(Asset.class)).type()); + }); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/CookieSignatureTest.java b/jooby/src/test/java-excluded/org/jooby/CookieSignatureTest.java new file mode 100644 index 00000000..c90f5083 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/CookieSignatureTest.java @@ -0,0 +1,85 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static org.easymock.EasyMock.expect; +import static org.junit.Assert.assertEquals; + +import java.security.NoSuchAlgorithmException; + +import javax.crypto.Mac; + +import org.jooby.Cookie.Signature; +import org.jooby.test.MockUnit; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PowerMockIgnore; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +@PowerMockIgnore("javax.crypto.*") +@RunWith(PowerMockRunner.class) +public class CookieSignatureTest { + + @Test + public void sillyJacoco() throws Exception { + new Cookie.Signature(); + } + + @Test + public void sign() throws Exception { + assertEquals("qAlLNkSRVE4aZb+tz6avvkVIEmmR30BH8cpr3x9ZdFA|jooby", + Signature.sign("jooby", "124Qwerty")); + } + + @Test(expected = IllegalArgumentException.class) + @PrepareForTest({Cookie.class, Cookie.Signature.class, Mac.class }) + public void noSuchAlgorithmException() throws Exception { + new MockUnit() + .expect(unit -> { + unit.mockStatic(Mac.class); + expect(Mac.getInstance("HmacSHA256")).andThrow(new NoSuchAlgorithmException("HmacSHA256")); + }) + .run(unit -> { + Signature.sign("jooby", "a11"); + }); + } + + @Test + public void unsign() throws Exception { + assertEquals("jooby", + Signature.unsign("qAlLNkSRVE4aZb+tz6avvkVIEmmR30BH8cpr3x9ZdFA|jooby", "124Qwerty")); + } + + @Test + public void valid() throws Exception { + assertEquals(true, + Signature.valid("qAlLNkSRVE4aZb+tz6avvkVIEmmR30BH8cpr3x9ZdFA|jooby", "124Qwerty")); + } + + @Test + public void invalid() throws Exception { + assertEquals(false, + Signature.valid("QAlLNkSRVE4aZb+tz6avvkVIEmmR30BH8cpr3x9ZdFA|jooby", "124Qwerty")); + + assertEquals(false, + Signature.valid("qAlLNkSRVE4aZb+tz6avvkVIEmmR30BH8cpr3x9ZdFA|joobi", "124Qwerty")); + + assertEquals(false, + Signature.valid("#qAlLNkSRVE4aZb+tz6avvkVIEmmR30BH8cpr3x9ZdFA#joobi", "124Qwerty")); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/DefaultErrHandlerTest.java b/jooby/src/test/java-excluded/org/jooby/DefaultErrHandlerTest.java new file mode 100644 index 00000000..a4864590 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/DefaultErrHandlerTest.java @@ -0,0 +1,175 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import com.google.common.collect.ImmutableList; +import com.google.common.escape.Escapers; +import com.google.common.html.HtmlEscapers; +import com.typesafe.config.Config; +import static org.easymock.EasyMock.expect; +import org.jooby.test.MockUnit; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.LinkedHashMap; +import java.util.Map; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({Err.DefHandler.class, LoggerFactory.class}) +public class DefaultErrHandlerTest { + + @SuppressWarnings({"unchecked"}) + @Test + public void handleNoErrMessage() throws Exception { + Err ex = new Err(500); + + StringWriter writer = new StringWriter(); + ex.printStackTrace(new PrintWriter(writer)); + String[] stacktrace = writer.toString().replace("\r", "").split("\\n"); + + new MockUnit(Request.class, Response.class, Route.class, Config.class, Env.class) + .expect(handleErr(ex,true)) + .run(unit -> { + + Request req = unit.get(Request.class); + Response rsp = unit.get(Response.class); + + new Err.DefHandler().handle(req, rsp, ex); + }, unit -> { + Result result = unit.captured(Result.class).iterator().next(); + View view = (View) result.ifGet(ImmutableList.of(MediaType.html)).get(); + assertEquals("err", view.name()); + checkErr(stacktrace, "Server Error(500)", (Map) view.model() + .get("err")); + + Object hash = result.ifGet(MediaType.ALL).get(); + assertEquals(4, ((Map) hash).size()); + }); + } + + private MockUnit.Block handleErr(Throwable ex, boolean stacktrace) { + return unit -> { + Logger log = unit.mock(Logger.class); + log.error("execution of: {}{} resulted in exception\nRoute:\n{}\n\nStacktrace:", "GET", + "/path", "route", ex); + + unit.mockStatic(LoggerFactory.class); + expect(LoggerFactory.getLogger(Err.class)).andReturn(log); + + Route route = unit.get(Route.class); + expect(route.print(6)).andReturn("route"); + + Config conf = unit.get(Config.class); + expect(conf.getBoolean("err.stacktrace")).andReturn(stacktrace); + Env env = unit.get(Env.class); + expect(env.name()).andReturn("dev"); + expect(env.xss("html")).andReturn(HtmlEscapers.htmlEscaper()::escape); + + Request req = unit.get(Request.class); + + expect(req.require(Config.class)).andReturn(conf); + expect(req.require(Env.class)).andReturn(env); + expect(req.path()).andReturn("/path"); + expect(req.method()).andReturn("GET"); + expect(req.route()).andReturn(route); + + Response rsp = unit.get(Response.class); + + rsp.send(unit.capture(Result.class)); + }; + } + + @SuppressWarnings({"unchecked"}) + @Test + public void handleWithErrMessage() throws Exception { + Err ex = new Err(500, "Something something dark"); + + StringWriter writer = new StringWriter(); + ex.printStackTrace(new PrintWriter(writer)); + String[] stacktrace = writer.toString().replace("\r", "").split("\\n"); + + new MockUnit(Request.class, Response.class, Route.class, Env.class, Config.class) + .expect(handleErr(ex, true)) + .run(unit -> { + + Request req = unit.get(Request.class); + Response rsp = unit.get(Response.class); + + new Err.DefHandler().handle(req, rsp, ex); + }, + unit -> { + Result result = unit.captured(Result.class).iterator().next(); + View view = (View) result.ifGet(ImmutableList.of(MediaType.html)).get(); + assertEquals("err", view.name()); + checkErr(stacktrace, "Server Error(500): Something something dark", + (Map) view.model() + .get("err")); + + Object hash = result.ifGet(MediaType.ALL).get(); + assertEquals(4, ((Map) hash).size()); + }); + } + + @SuppressWarnings({"unchecked"}) + @Test + public void handleWithHtmlErrMessage() throws Exception { + Err ex = new Err(500, "Something something dark"); + + StringWriter writer = new StringWriter(); + ex.printStackTrace(new PrintWriter(writer)); + String[] stacktrace = writer.toString().replace("\r", "").split("\\n"); + + new MockUnit(Request.class, Response.class, Route.class, Env.class, Config.class) + .expect(handleErr(ex, true)) + .run(unit -> { + + Request req = unit.get(Request.class); + Response rsp = unit.get(Response.class); + + new Err.DefHandler().handle(req, rsp, ex); + }, + unit -> { + Result result = unit.captured(Result.class).iterator().next(); + View view = (View) result.ifGet(ImmutableList.of(MediaType.html)).get(); + assertEquals("err", view.name()); + checkErr(stacktrace, "Server Error(500): Something something <em>dark</em>", + (Map) view.model() + .get("err")); + + Object hash = result.ifGet(MediaType.ALL).get(); + assertEquals(4, ((Map) hash).size()); + }); + } + + private void checkErr(final String[] stacktrace, final String message, + final Map err) { + final Map copy = new LinkedHashMap<>(err); + assertEquals(message, copy.remove("message")); + assertEquals("Server Error", copy.remove("reason")); + assertEquals(500, copy.remove("status")); + assertArrayEquals(stacktrace, (String[]) copy.remove("stacktrace")); + assertEquals(copy.toString(), 0, copy.size()); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/DeferredTest.java b/jooby/src/test/java-excluded/org/jooby/DeferredTest.java new file mode 100644 index 00000000..cabb3390 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/DeferredTest.java @@ -0,0 +1,119 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import java.util.concurrent.CountDownLatch; + +import org.jooby.test.MockUnit; +import org.junit.Test; + +public class DeferredTest { + + @Test + public void newWithNoInit() throws Exception { + new Deferred().handler(null, (r, ex) -> { + }); + } + + @Test + public void newWithInit0() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + new Deferred(deferred -> { + assertNotNull(deferred); + latch.countDown(); + }).handler(null, (r, ex) -> { + }); + latch.await(); + } + + @Test + public void newWithInit() throws Exception { + new MockUnit(Request.class) + .run(unit -> { + CountDownLatch latch = new CountDownLatch(1); + new Deferred((req, deferred) -> { + assertNotNull(deferred); + assertEquals(unit.get(Request.class), req); + latch.countDown(); + }).handler(unit.get(Request.class), (r, ex) -> { + }); + latch.await(); + }); + } + + @Test + public void resolve() throws Exception { + Object value = new Object(); + CountDownLatch latch = new CountDownLatch(1); + Deferred deferred = new Deferred(); + deferred.handler(null, (result, ex) -> { + assertFalse(result instanceof Deferred); + assertEquals(value, result.ifGet().get()); + assertNull(ex); + latch.countDown(); + }); + deferred.resolve(value); + latch.await(); + } + + @Test + public void setResolve() throws Exception { + Object value = new Object(); + CountDownLatch latch = new CountDownLatch(1); + Deferred deferred = new Deferred(); + deferred.handler(null, (result, ex) -> { + assertFalse(result instanceof Deferred); + assertEquals(value, result.ifGet().get()); + latch.countDown(); + }); + deferred.set(value); + latch.await(); + } + + @Test + public void reject() throws Exception { + Exception cause = new Exception(); + CountDownLatch latch = new CountDownLatch(1); + Deferred deferred = new Deferred(); + deferred.handler(null, (result, ex) -> { + assertEquals(cause, ex); + assertNull(result); + latch.countDown(); + }); + deferred.reject(cause); + latch.await(); + } + + @Test + public void setReject() throws Exception { + Exception cause = new Exception(); + CountDownLatch latch = new CountDownLatch(1); + Deferred deferred = new Deferred(); + deferred.handler(null, (result, ex) -> { + assertEquals(cause, ex); + assertNull(result); + latch.countDown(); + }); + deferred.set(cause); + latch.await(); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/FileConfTest.java b/jooby/src/test/java-excluded/org/jooby/FileConfTest.java new file mode 100644 index 00000000..2782c9e2 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/FileConfTest.java @@ -0,0 +1,126 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static org.easymock.EasyMock.expect; +import static org.junit.Assert.assertEquals; + +import java.io.File; + +import org.jooby.test.MockUnit; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({Jooby.class, File.class, ConfigFactory.class }) +public class FileConfTest { + + @Test + public void rootFile() throws Exception { + Config conf = ConfigFactory.empty(); + new MockUnit() + .expect(unit -> { + unit.mockStatic(ConfigFactory.class); + }) + .expect(unit -> { + File dir = unit.constructor(File.class) + .args(String.class) + .build(System.getProperty("user.dir")); + + File root = unit.constructor(File.class) + .args(File.class, String.class) + .build(dir, "app.conf"); + expect(root.exists()).andReturn(true); + + expect(ConfigFactory.parseFile(root)).andReturn(conf); + }) + .run(unit -> { + assertEquals(conf, Jooby.fileConfig("app.conf")); + }); + } + + @Test + public void confFile() throws Exception { + Config conf = ConfigFactory.empty(); + new MockUnit() + .expect(unit -> { + unit.mockStatic(ConfigFactory.class); + }) + .expect(unit -> { + File dir = unit.constructor(File.class) + .args(String.class) + .build(System.getProperty("user.dir")); + + File root = unit.constructor(File.class) + .args(File.class, String.class) + .build(dir, "app.conf"); + expect(root.exists()).andReturn(false); + + File cdir = unit.constructor(File.class) + .args(File.class, String.class) + .build(dir, "conf"); + + File cfile = unit.constructor(File.class) + .args(File.class, String.class) + .build(cdir, "app.conf"); + expect(cfile.exists()).andReturn(true); + + expect(ConfigFactory.parseFile(cfile)).andReturn(conf); + }) + .run(unit -> { + assertEquals(conf, Jooby.fileConfig("app.conf")); + }); + } + + @Test + public void empty() throws Exception { + Config conf = ConfigFactory.empty(); + new MockUnit() + .expect(unit -> { + unit.mockStatic(ConfigFactory.class); + }) + .expect(unit -> { + File dir = unit.constructor(File.class) + .args(String.class) + .build(System.getProperty("user.dir")); + + File root = unit.constructor(File.class) + .args(File.class, String.class) + .build(dir, "app.conf"); + expect(root.exists()).andReturn(false); + + File cdir = unit.constructor(File.class) + .args(File.class, String.class) + .build(dir, "conf"); + + File cfile = unit.constructor(File.class) + .args(File.class, String.class) + .build(cdir, "app.conf"); + expect(cfile.exists()).andReturn(false); + + expect(ConfigFactory.empty()).andReturn(conf); + }) + .run(unit -> { + assertEquals(conf, Jooby.fileConfig("app.conf")); + }); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/JoobyRunTest.java b/jooby/src/test/java-excluded/org/jooby/JoobyRunTest.java new file mode 100644 index 00000000..2ade4ec9 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/JoobyRunTest.java @@ -0,0 +1,96 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static org.easymock.EasyMock.expect; + +import java.util.Arrays; +import java.util.function.Supplier; + +import org.jooby.test.MockUnit; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({Jooby.class, System.class }) +public class JoobyRunTest { + + @SuppressWarnings("serial") + public static class ArgEx extends RuntimeException { + + public ArgEx(final String[] args) { + super(Arrays.toString(args)); + } + + } + + public static class NoopApp extends Jooby { + @Override + public void start(final String... args) { + } + } + + public static class NoopAppEx extends Jooby { + @Override + public void start(final String... args) { + throw new ArgEx(args); + } + } + + @SuppressWarnings({"unchecked", "rawtypes" }) + @Test + public void runSupplier() throws Exception { + String[] args = {}; + new MockUnit(Supplier.class, Jooby.class) + .expect(unit -> { + Supplier supplier = unit.get(Supplier.class); + expect(supplier.get()).andReturn(unit.get(Jooby.class)); + }) + .expect(unit -> { + Jooby jooby = unit.get(Jooby.class); + jooby.start(args); + }) + .run(unit -> { + Jooby.run(unit.get(Supplier.class), args); + }); + } + + @SuppressWarnings({"unchecked", "rawtypes" }) + @Test + public void runSupplierArg() throws Exception { + String[] args = {"foo" }; + new MockUnit(Supplier.class, Jooby.class) + .expect(unit -> { + Supplier supplier = unit.get(Supplier.class); + expect(supplier.get()).andReturn(unit.get(Jooby.class)); + }) + .expect(unit -> { + Jooby jooby = unit.get(Jooby.class); + jooby.start(args); + }) + .run(unit -> { + Jooby.run(unit.get(Supplier.class), args); + }); + } + + @Test + public void runClass() throws Throwable { + Jooby.run(NoopApp.class); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/JoobyTest.java b/jooby/src/test/java-excluded/org/jooby/JoobyTest.java new file mode 100644 index 00000000..25f0104b --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/JoobyTest.java @@ -0,0 +1,3082 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import com.google.common.escape.Escaper; +import com.google.common.html.HtmlEscapers; +import com.google.common.net.UrlEscapers; +import com.google.inject.Binder; +import com.google.inject.Guice; +import com.google.inject.Injector; +import com.google.inject.Key; +import com.google.inject.Module; +import com.google.inject.ProvisionException; +import com.google.inject.Stage; +import com.google.inject.TypeLiteral; +import com.google.inject.binder.AnnotatedBindingBuilder; +import com.google.inject.binder.AnnotatedConstantBindingBuilder; +import com.google.inject.binder.ConstantBindingBuilder; +import com.google.inject.binder.LinkedBindingBuilder; +import com.google.inject.binder.ScopedBindingBuilder; +import com.google.inject.internal.ProviderMethodsModule; +import com.google.inject.multibindings.Multibinder; +import com.google.inject.multibindings.OptionalBinder; +import com.google.inject.name.Named; +import com.google.inject.name.Names; +import com.google.inject.util.Types; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; +import com.typesafe.config.ConfigValueFactory; +import org.easymock.EasyMock; +import org.jooby.Session.Definition; +import org.jooby.Session.Store; +import org.jooby.internal.AppPrinter; +import org.jooby.internal.BuiltinParser; +import org.jooby.internal.BuiltinRenderer; +import org.jooby.internal.CookieSessionManager; +import org.jooby.internal.DefaulErrRenderer; +import org.jooby.internal.HttpHandlerImpl; +import org.jooby.internal.ParameterNameProvider; +import org.jooby.internal.RequestScope; +import org.jooby.internal.RouteImpl; +import org.jooby.internal.RouteMetadata; +import org.jooby.internal.ServerSessionManager; +import org.jooby.internal.SessionManager; +import org.jooby.internal.TypeConverters; +import org.jooby.internal.parser.BeanParser; +import org.jooby.internal.parser.DateParser; +import org.jooby.internal.parser.LocalDateParser; +import org.jooby.internal.parser.LocaleParser; +import org.jooby.internal.parser.ParserExecutor; +import org.jooby.internal.parser.StaticMethodParser; +import org.jooby.internal.parser.StringConstructorParser; +import org.jooby.internal.parser.ZonedDateTimeParser; +import org.jooby.internal.ssl.SslContextProvider; +import org.jooby.mvc.GET; +import org.jooby.mvc.POST; +import org.jooby.mvc.Path; +import org.jooby.scope.RequestScoped; +import org.jooby.spi.HttpHandler; +import org.jooby.spi.Server; +import org.jooby.test.MockUnit; +import org.jooby.test.MockUnit.Block; +import org.jooby.funzy.Throwing; + +import static org.easymock.EasyMock.*; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.inject.Provider; +import javax.inject.Singleton; +import javax.net.ssl.SSLContext; +import java.io.File; +import java.nio.charset.Charset; +import java.text.DecimalFormat; +import java.text.NumberFormat; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.TimeZone; +import java.util.function.Function; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({Jooby.class, Guice.class, TypeConverters.class, Multibinder.class, + OptionalBinder.class, Runtime.class, Thread.class, UrlEscapers.class, HtmlEscapers.class, + LoggerFactory.class}) +@SuppressWarnings("unchecked") +public class JoobyTest { + + public static class InternalOnStart implements Throwing.Consumer { + + @Override + public void tryAccept(final Registry value) throws Throwable { + + } + } + + @Path("/singleton") + @Singleton + public static class SingletonTestRoute { + + @GET + @POST + public Object m1() { + return ""; + } + + } + + @Path("/singleton") + @com.google.inject.Singleton + public static class GuiceSingletonTestRoute { + + @GET + @POST + public Object m1() { + return ""; + } + + } + + @Path("/proto") + public static class ProtoTestRoute { + + @GET + public Object m1() { + return ""; + } + + } + + @SuppressWarnings("rawtypes") + private MockUnit.Block config = unit -> { + ConstantBindingBuilder strCBB = unit.mock(ConstantBindingBuilder.class); + strCBB.to(isA(String.class)); + expectLastCall().anyTimes(); + + AnnotatedConstantBindingBuilder strACBB = unit.mock(AnnotatedConstantBindingBuilder.class); + expect(strACBB.annotatedWith(isA(Named.class))).andReturn(strCBB).anyTimes(); + + LinkedBindingBuilder> listOfString = unit.mock(LinkedBindingBuilder.class); + listOfString.toInstance(isA(List.class)); + expectLastCall().anyTimes(); + + LinkedBindingBuilder configBinding = unit.mock(LinkedBindingBuilder.class); + configBinding.toInstance(isA(Config.class)); + expectLastCall().anyTimes(); + AnnotatedBindingBuilder configAnnotatedBinding = unit + .mock(AnnotatedBindingBuilder.class); + + expect(configAnnotatedBinding.annotatedWith(isA(Named.class))).andReturn(configBinding) + .anyTimes(); + // root config + configAnnotatedBinding.toInstance(isA(Config.class)); + + Binder binder = unit.get(Binder.class); + expect(binder.bindConstant()).andReturn(strACBB).anyTimes(); + expect(binder.bind(Config.class)).andReturn(configAnnotatedBinding).anyTimes(); + expect(binder.bind(Key.get(Types.listOf(String.class), Names.named("cors.allowedHeaders")))) + .andReturn((LinkedBindingBuilder) listOfString); + expect(binder.bind(Key.get(Types.listOf(String.class), Names.named("cors.allowedMethods")))) + .andReturn((LinkedBindingBuilder) listOfString); + }; + + private MockUnit.Block env = unit -> { + Binder binder = unit.get(Binder.class); + + AnnotatedBindingBuilder binding = unit.mock(AnnotatedBindingBuilder.class); + binding.toInstance(isA(Env.class)); + + expect(binder.bind(Env.class)).andReturn(binding); + }; + + private MockUnit.Block ssl = unit -> { + Binder binder = unit.get(Binder.class); + + ScopedBindingBuilder sbbSsl = unit.mock(ScopedBindingBuilder.class); + + AnnotatedBindingBuilder binding = unit.mock(AnnotatedBindingBuilder.class); + expect(binding.toProvider(SslContextProvider.class)).andReturn(sbbSsl); + + expect(binder.bind(SSLContext.class)).andReturn(binding); + }; + + private MockUnit.Block classInfo = unit -> { + Binder binder = unit.get(Binder.class); + + AnnotatedBindingBuilder binding = unit + .mock(AnnotatedBindingBuilder.class); + binding.toInstance(isA(RouteMetadata.class)); + + expect(binder.bind(ParameterNameProvider.class)).andReturn(binding); + }; + + private MockUnit.Block charset = unit -> { + Binder binder = unit.get(Binder.class); + + AnnotatedBindingBuilder binding = unit.mock(AnnotatedBindingBuilder.class); + binding.toInstance(isA(Charset.class)); + + expect(binder.bind(Charset.class)).andReturn(binding); + }; + + private MockUnit.Block locale = unit -> { + Binder binder = unit.get(Binder.class); + + AnnotatedBindingBuilder binding = unit.mock(AnnotatedBindingBuilder.class); + binding.toInstance(isA(Locale.class)); + + AnnotatedBindingBuilder> bindings = unit.mock(AnnotatedBindingBuilder.class); + bindings.toInstance(isA(List.class)); + + expect(binder.bind(Locale.class)).andReturn(binding); + + TypeLiteral> localeType = (TypeLiteral>) TypeLiteral + .get(Types.listOf(Locale.class)); + expect(binder.bind(localeType)).andReturn(bindings); + }; + + private MockUnit.Block zoneId = unit -> { + Binder binder = unit.get(Binder.class); + + AnnotatedBindingBuilder binding = unit.mock(AnnotatedBindingBuilder.class); + binding.toInstance(isA(ZoneId.class)); + + expect(binder.bind(ZoneId.class)).andReturn(binding); + }; + + private MockUnit.Block timeZone = unit -> { + Binder binder = unit.get(Binder.class); + + AnnotatedBindingBuilder binding = unit.mock(AnnotatedBindingBuilder.class); + binding.toInstance(isA(TimeZone.class)); + + expect(binder.bind(TimeZone.class)).andReturn(binding); + }; + + private MockUnit.Block dateTimeFormatter = unit -> { + Binder binder = unit.get(Binder.class); + + AnnotatedBindingBuilder binding = unit.mock(AnnotatedBindingBuilder.class); + binding.toInstance(isA(DateTimeFormatter.class)); + + expect(binder.bind(DateTimeFormatter.class)).andReturn(binding); + }; + + private MockUnit.Block numberFormat = unit -> { + Binder binder = unit.get(Binder.class); + + AnnotatedBindingBuilder binding = unit.mock(AnnotatedBindingBuilder.class); + binding.toInstance(isA(NumberFormat.class)); + + expect(binder.bind(NumberFormat.class)).andReturn(binding); + }; + + private MockUnit.Block decimalFormat = unit -> { + Binder binder = unit.get(Binder.class); + + AnnotatedBindingBuilder binding = unit.mock(AnnotatedBindingBuilder.class); + binding.toInstance(isA(DecimalFormat.class)); + + expect(binder.bind(DecimalFormat.class)).andReturn(binding); + }; + + private MockUnit.Block renderers = unit -> { + Multibinder multibinder = unit.mock(Multibinder.class); + + Binder binder = unit.get(Binder.class); + unit.mockStatic(Multibinder.class); + + expect(Multibinder.newSetBinder(binder, Renderer.class)).andReturn(multibinder); + + LinkedBindingBuilder formatAsset = unit.mock(LinkedBindingBuilder.class); + formatAsset.toInstance(BuiltinRenderer.asset); + + LinkedBindingBuilder formatByteArray = unit.mock(LinkedBindingBuilder.class); + formatByteArray.toInstance(BuiltinRenderer.bytes); + + LinkedBindingBuilder formatByteBuffer = unit.mock(LinkedBindingBuilder.class); + formatByteBuffer.toInstance(BuiltinRenderer.byteBuffer); + + LinkedBindingBuilder file = unit.mock(LinkedBindingBuilder.class); + file.toInstance(BuiltinRenderer.file); + + LinkedBindingBuilder formatStream = unit.mock(LinkedBindingBuilder.class); + formatStream.toInstance(BuiltinRenderer.stream); + + LinkedBindingBuilder reader = unit.mock(LinkedBindingBuilder.class); + reader.toInstance(BuiltinRenderer.reader); + + LinkedBindingBuilder charBuffer = unit.mock(LinkedBindingBuilder.class); + charBuffer.toInstance(BuiltinRenderer.charBuffer); + + LinkedBindingBuilder fchannel = unit.mock(LinkedBindingBuilder.class); + fchannel.toInstance(BuiltinRenderer.fileChannel); + + LinkedBindingBuilder err = unit.mock(LinkedBindingBuilder.class); + err.toInstance(isA(DefaulErrRenderer.class)); + + LinkedBindingBuilder formatAny = unit.mock(LinkedBindingBuilder.class); + formatAny.toInstance(BuiltinRenderer.text); + + expect(multibinder.addBinding()).andReturn(formatAsset); + expect(multibinder.addBinding()).andReturn(formatByteArray); + expect(multibinder.addBinding()).andReturn(formatByteBuffer); + expect(multibinder.addBinding()).andReturn(file); + expect(multibinder.addBinding()).andReturn(charBuffer); + expect(multibinder.addBinding()).andReturn(formatStream); + expect(multibinder.addBinding()).andReturn(reader); + expect(multibinder.addBinding()).andReturn(fchannel); + expect(multibinder.addBinding()).andReturn(err); + expect(multibinder.addBinding()).andReturn(formatAny); + + }; + + private MockUnit.Block routes = unit -> { + Multibinder multibinder = unit.mock(Multibinder.class); + + Binder binder = unit.get(Binder.class); + + expect(Multibinder.newSetBinder(binder, Route.Definition.class)).andReturn(multibinder); + }; + + private MockUnit.Block routeHandler = unit -> { + ScopedBindingBuilder routehandlerscope = unit.mock(ScopedBindingBuilder.class); + routehandlerscope.in(Singleton.class); + + AnnotatedBindingBuilder routehandlerbinding = unit + .mock(AnnotatedBindingBuilder.class); + expect(routehandlerbinding.to(HttpHandlerImpl.class)).andReturn(routehandlerscope); + + expect(unit.get(Binder.class).bind(HttpHandler.class)).andReturn(routehandlerbinding); + }; + + private MockUnit.Block webSockets = unit -> { + Multibinder multibinder = unit.mock(Multibinder.class); + + Binder binder = unit.get(Binder.class); + + expect(Multibinder.newSetBinder(binder, WebSocket.Definition.class)).andReturn(multibinder); + }; + + private MockUnit.Block tmpdir = unit -> { + Binder binder = unit.get(Binder.class); + + LinkedBindingBuilder instance = unit.mock(LinkedBindingBuilder.class); + instance.toInstance(isA(File.class)); + + AnnotatedBindingBuilder named = unit.mock(AnnotatedBindingBuilder.class); + expect(named.annotatedWith(Names.named("application.tmpdir"))).andReturn(instance); + + expect(binder.bind(java.io.File.class)).andReturn(named); + }; + + private MockUnit.Block err = unit -> { + Binder binder = unit.get(Binder.class); + + LinkedBindingBuilder ehlbb = unit.mock(LinkedBindingBuilder.class); + ehlbb.toInstance(isA(Err.DefHandler.class)); + + Multibinder multibinder = unit.mock(Multibinder.class); + expect(Multibinder.newSetBinder(binder, Err.Handler.class)).andReturn(multibinder); + + expect(multibinder.addBinding()).andReturn(ehlbb); + }; + + private MockUnit.Block session = unit -> { + Binder binder = unit.get(Binder.class); + + AnnotatedBindingBuilder smABB = unit.mock(AnnotatedBindingBuilder.class); + expect(smABB.to(ServerSessionManager.class)).andReturn(smABB); + smABB.asEagerSingleton(); + + ScopedBindingBuilder ssSBB = unit.mock(ScopedBindingBuilder.class); + ssSBB.asEagerSingleton(); + + AnnotatedBindingBuilder ssABB = unit.mock(AnnotatedBindingBuilder.class); + expect(ssABB.to(Session.Mem.class)).andReturn(ssSBB); + + expect(binder.bind(SessionManager.class)).andReturn(smABB); + expect(binder.bind(Session.Store.class)).andReturn(ssABB); + + AnnotatedBindingBuilder sdABB = unit.mock(AnnotatedBindingBuilder.class); + expect(sdABB.toProvider(isA(com.google.inject.Provider.class))).andReturn(sdABB); + sdABB.asEagerSingleton(); + + expect(binder.bind(Session.Definition.class)).andReturn(sdABB); + }; + + private MockUnit.Block boot = unit -> { + Module module = unit.captured(Module.class).iterator().next(); + + module.configure(unit.get(Binder.class)); + + unit.captured(Runnable.class).get(0).run(); + }; + + private MockUnit.Block requestScope = unit -> { + Binder binder = unit.get(Binder.class); + + AnnotatedBindingBuilder reqscopebinding = unit + .mock(AnnotatedBindingBuilder.class); + reqscopebinding.toInstance(isA(RequestScope.class)); + + expect(binder.bind(RequestScope.class)).andReturn(reqscopebinding); + binder.bindScope(eq(RequestScoped.class), isA(RequestScope.class)); + + ScopedBindingBuilder reqscope = unit.mock(ScopedBindingBuilder.class); + reqscope.in(RequestScoped.class); + reqscope.in(RequestScoped.class); + reqscope.in(RequestScoped.class); + + + AnnotatedBindingBuilder reqbinding = unit.mock(AnnotatedBindingBuilder.class); + expect(reqbinding.toProvider(isA(Provider.class))).andReturn(reqscope); + + expect(binder.bind(Request.class)).andReturn(reqbinding); + + AnnotatedBindingBuilder chainbinding = unit.mock(AnnotatedBindingBuilder.class); + expect(chainbinding.toProvider(isA(Provider.class))).andReturn(reqscope); + + expect(binder.bind(Route.Chain.class)).andReturn(chainbinding); + + ScopedBindingBuilder rspscope = unit.mock(ScopedBindingBuilder.class); + rspscope.in(RequestScoped.class); + AnnotatedBindingBuilder rspbinding = unit.mock(AnnotatedBindingBuilder.class); + expect(rspbinding.toProvider(isA(Provider.class))).andReturn(rspscope); + + expect(binder.bind(Response.class)).andReturn(rspbinding); + + ScopedBindingBuilder sessionscope = unit.mock(ScopedBindingBuilder.class); + sessionscope.in(RequestScoped.class); + + AnnotatedBindingBuilder sessionbinding = unit.mock(AnnotatedBindingBuilder.class); + expect(sessionbinding.toProvider(isA(Provider.class))) + .andReturn(sessionscope); + + expect(binder.bind(Session.class)).andReturn(sessionbinding); + + AnnotatedBindingBuilder sseb = unit.mock(AnnotatedBindingBuilder.class); + expect(sseb.toProvider(isA(Provider.class))) + .andReturn(reqscope); + expect(binder.bind(Sse.class)).andReturn(sseb); + }; + + private MockUnit.Block params = unit -> { + Binder binder = unit.get(Binder.class); + + AnnotatedBindingBuilder parambinding = unit + .mock(AnnotatedBindingBuilder.class); + parambinding.in(Singleton.class); + + expect(binder.bind(ParserExecutor.class)).andReturn(parambinding); + + Multibinder multibinder = unit.mock(Multibinder.class, true); + + for (Parser parser : BuiltinParser.values()) { + LinkedBindingBuilder converterBinding = unit.mock(LinkedBindingBuilder.class); + converterBinding.toInstance(parser); + expect(multibinder.addBinding()).andReturn(converterBinding); + } + + @SuppressWarnings("rawtypes") + Class[] parserClasses = { + DateParser.class, + LocalDateParser.class, + ZonedDateTimeParser.class, + LocaleParser.class, + StaticMethodParser.class, + StaticMethodParser.class, + StaticMethodParser.class, + StringConstructorParser.class, + BeanParser.class + }; + + for (Class converter : parserClasses) { + LinkedBindingBuilder converterBinding = unit.mock(LinkedBindingBuilder.class); + converterBinding.toInstance(isA(converter)); + expect(multibinder.addBinding()).andReturn(converterBinding); + } + + expect(Multibinder.newSetBinder(binder, Parser.class)).andReturn(multibinder); + + }; + + private MockUnit.Block shutdown = unit -> { + unit.mockStatic(Runtime.class); + + Thread thread = unit.mockConstructor(Thread.class, new Class[]{Runnable.class}, + unit.capture(Runnable.class)); + + Runtime runtime = unit.mock(Runtime.class); + expect(Runtime.getRuntime()).andReturn(runtime).times(2); + runtime.addShutdownHook(thread); + expect(runtime.availableProcessors()).andReturn(1); + }; + + private MockUnit.Block guice = unit -> { + Server server = unit.mock(Server.class); + + server.start(); + server.join(); + server.stop(); + + ScopedBindingBuilder serverScope = unit.mock(ScopedBindingBuilder.class); + serverScope.in(Singleton.class); + expectLastCall().times(0, 1); + + AnnotatedBindingBuilder serverBinding = unit.mock(AnnotatedBindingBuilder.class); + expect(serverBinding.to(isA(Class.class))).andReturn(serverScope).times(0, 1); + + Binder binder = unit.get(Binder.class); + binder.install(anyObject(ProviderMethodsModule.class)); + EasyMock.expectLastCall().atLeastOnce(); + expect(binder.bind(Server.class)).andReturn(serverBinding).times(0, 1); + + // ConfigOrigin configOrigin = unit.mock(ConfigOrigin.class); + // expect(configOrigin.description()).andReturn("test.conf, mock.conf").times(0, 1); + + Config config = unit.mock(Config.class); + expect(config.getString("application.env")).andReturn("dev"); + expect(config.hasPath("server.join")).andReturn(true); + expect(config.getBoolean("server.join")).andReturn(true); + unit.registerMock(Config.class, config); + // expect(config.origin()).andReturn(configOrigin).times(0, 1); + + Injector injector = unit.mock(Injector.class); + expect(injector.getInstance(Server.class)).andReturn(server).times(1, 2); + expect(injector.getInstance(Config.class)).andReturn(config); + expect(injector.getInstance(Route.KEY)).andReturn(Collections.emptySet()); + expect(injector.getInstance(WebSocket.KEY)).andReturn(Collections.emptySet()); + injector.injectMembers(isA(Jooby.class)); + unit.registerMock(Injector.class, injector); + + AppPrinter printer = unit.constructor(AppPrinter.class) + .args(Set.class, Set.class, Config.class) + .build(isA(Set.class), isA(Set.class), isA(Config.class)); + printer.printConf(isA(Logger.class), eq(config)); + + unit.mockStatic(Guice.class); + expect(Guice.createInjector(eq(Stage.DEVELOPMENT), unit.capture(Module.class))).andReturn( + injector); + + unit.mockStatic(OptionalBinder.class); + + TypeConverters tc = unit.mockConstructor(TypeConverters.class); + tc.configure(binder); + }; + + @Test + public void applicationSecret() throws Exception { + + new MockUnit(Binder.class) + .expect( + unit -> { + Server server = unit.mock(Server.class); + server.start(); + server.join(); + server.stop(); + + ScopedBindingBuilder serverScope = unit.mock(ScopedBindingBuilder.class); + serverScope.in(Singleton.class); + expectLastCall().times(0, 1); + + AnnotatedBindingBuilder serverBinding = unit + .mock(AnnotatedBindingBuilder.class); + expect(serverBinding.to(isA(Class.class))).andReturn(serverScope).times(0, 1); + + Binder binder = unit.get(Binder.class); + binder.install(anyObject(ProviderMethodsModule.class)); + expect(binder.bind(Server.class)).andReturn(serverBinding).times(0, 1); + + // ConfigOrigin configOrigin = unit.mock(ConfigOrigin.class); + // expect(configOrigin.description()).andReturn("test.conf, mock.conf").times(0, 1); + + Config config = unit.mock(Config.class); + expect(config.getString("application.env")).andReturn("dev"); + expect(config.hasPath("server.join")).andReturn(true); + expect(config.getBoolean("server.join")).andReturn(true); + unit.registerMock(Config.class, config); + // expect(config.origin()).andReturn(configOrigin).times(0, 1); + + AppPrinter printer = unit.constructor(AppPrinter.class) + .args(Set.class, Set.class, Config.class) + .build(isA(Set.class), isA(Set.class), isA(Config.class)); + printer.printConf(isA(Logger.class), eq(config)); + + Injector injector = unit.mock(Injector.class); + expect(injector.getInstance(Server.class)).andReturn(server).times(1, 2); + expect(injector.getInstance(Config.class)).andReturn(config); + expect(injector.getInstance(Route.KEY)).andReturn(Collections.emptySet()); + expect(injector.getInstance(WebSocket.KEY)).andReturn(Collections.emptySet()); + injector.injectMembers(isA(Jooby.class)); + + unit.mockStatic(Guice.class); + expect(Guice.createInjector(eq(Stage.PRODUCTION), unit.capture(Module.class))) + .andReturn( + injector); + + unit.mockStatic(OptionalBinder.class); + + TypeConverters tc = unit.mockConstructor(TypeConverters.class); + tc.configure(binder); + }) + .expect(shutdown) + .expect(config) + .expect(internalOnStart(false)) + .expect(ssl) + .expect(env) + .expect(classInfo) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(routes) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(err) + .run(unit -> { + + Jooby jooby = new Jooby(); + + jooby.use(ConfigFactory.empty() + .withValue("application.env", ConfigValueFactory.fromAnyRef("prod")) + .withValue("application.secret", ConfigValueFactory.fromAnyRef("234"))); + + jooby.start(); + + }, boot); + } + + @Test + public void defaults() throws Exception { + + new MockUnit(Binder.class) + .expect(guice) + .expect(internalOnStart(false)) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(routes) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(err) + .run(unit -> { + + Jooby jooby = new Jooby(); + + assertEquals(false, jooby.isStarted()); + + jooby.start(); + + assertEquals(true, jooby.isStarted()); + + }, boot); + } + + @Test + public void requireShouldHideProvisionExceptionWhenCauseIsErr() throws Exception { + + new MockUnit(Binder.class) + .expect(guice) + .expect(internalOnStart(false)) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(routes) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(err) + .expect(unit -> { + Injector injector = unit.get(Injector.class); + ProvisionException x = new ProvisionException("intentional error", new Err(Status.BAD_REQUEST)); + expect(injector.getInstance(Key.get(Object.class))).andThrow(x); + }) + .run(unit -> { + + Jooby jooby = new Jooby(); + + jooby.start(); + + try { + jooby.require(Object.class); + fail("Should throw Err"); + } catch (Err x) { + assertEquals(400, x.statusCode()); + } + + }, boot); + } + + @Test + public void requireShouldNotHideProvisionExceptionWhenCauseIsNotErr() throws Exception { + + new MockUnit(Binder.class) + .expect(guice) + .expect(internalOnStart(false)) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(routes) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(err) + .expect(unit -> { + Injector injector = unit.get(Injector.class); + ProvisionException x = new ProvisionException("intentional error"); + expect(injector.getInstance(Key.get(Object.class))).andThrow(x); + }) + .run(unit -> { + + Jooby jooby = new Jooby(); + + jooby.start(); + + try { + jooby.require(Object.class); + fail("Should throw Err"); + } catch (ProvisionException x) { + } + + }, boot); + } + + @Test + public void withInternalOnStart() throws Exception { + + new MockUnit(Binder.class) + .expect(guice) + .expect(internalOnStart(true)) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(routes) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(err) + .run(unit -> { + + Jooby jooby = new Jooby(); + + assertEquals(false, jooby.isStarted()); + + jooby.start(); + + assertEquals(true, jooby.isStarted()); + + }, boot); + } + + @Test + public void requireByNameAndTypeLiteralShouldWork() throws Exception { + + Object someVerySpecificObject = new Object(); + + new MockUnit(Binder.class) + .expect(guice) + .expect(internalOnStart(false)) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(routes) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(err) + .expect(unit -> { + Injector injector = unit.get(Injector.class); + expect(injector.getInstance(Key.get(Object.class, Names.named("foo")))).andReturn(someVerySpecificObject); + }) + .run(unit -> { + + Jooby jooby = new Jooby(); + + jooby.start(); + Object actual = jooby.require("foo", TypeLiteral.get(Object.class)); + assertEquals(actual, someVerySpecificObject); + + }, boot); + } + + private Block internalOnStart(final boolean b) { + return unit -> { + Config conf = unit.get(Config.class); + expect(conf.hasPath("jooby.internal.onStart")).andReturn(b); + if (b) { + expect(conf.getString("jooby.internal.onStart")) + .andReturn(InternalOnStart.class.getName()); + } + }; + } + + @Test + public void cookieSession() throws Exception { + + new MockUnit(Binder.class) + .expect(guice) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(unit -> { + Binder binder = unit.get(Binder.class); + + AnnotatedBindingBuilder smABB = unit.mock(AnnotatedBindingBuilder.class); + expect(smABB.to(CookieSessionManager.class)).andReturn(smABB); + smABB.asEagerSingleton(); + + expect(binder.bind(SessionManager.class)).andReturn(smABB); + + AnnotatedBindingBuilder sdABB = unit + .mock(AnnotatedBindingBuilder.class); + expect(sdABB.toProvider(isA(com.google.inject.Provider.class))).andReturn(sdABB); + sdABB.asEagerSingleton(); + + expect(binder.bind(Session.Definition.class)).andReturn(sdABB); + }) + .expect(routes) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(err) + .expect(internalOnStart(false)) + .run(unit -> { + + Jooby jooby = new Jooby(); + + jooby.use(ConfigFactory.empty() + .withValue("application.secret", ConfigValueFactory.fromAnyRef("234"))); + + jooby.cookieSession(); + + jooby.start(); + + }, boot); + } + + @Test + public void cookieSessionShouldFailWhenApplicationSecretIsnotPresent() throws Throwable { + + Jooby jooby = new Jooby(); + + jooby.cookieSession(); + + jooby.start(); + } + + @Test + public void onStartStopCallback() throws Exception { + + new MockUnit(Binder.class, Throwing.Runnable.class) + .expect(guice) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(routes) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(err) + .expect(internalOnStart(false)) + .expect(unit -> { + unit.get(Throwing.Runnable.class).run(); + unit.get(Throwing.Runnable.class).run(); + }) + .run(unit -> { + + Jooby app = new Jooby() + .onStart(unit.get(Throwing.Runnable.class)) + .onStop(unit.get(Throwing.Runnable.class)); + app.start(); + app.stop(); + + }, boot); + } + + @Test(expected = IllegalStateException.class) + public void appDidnStart() throws Exception { + new Jooby().require(Object.class); + } + + @Test + public void onStopCallbackLogError() throws Exception { + + new MockUnit(Binder.class, Throwing.Runnable.class) + .expect(guice) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(routes) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(err) + .expect(internalOnStart(false)) + .expect(unit -> { + unit.get(Throwing.Runnable.class).run(); + unit.get(Throwing.Runnable.class).run(); + expectLastCall().andThrow(new IllegalStateException("intentional err")); + }) + .run(unit -> { + + Jooby app = new Jooby() + .onStart(unit.get(Throwing.Runnable.class)) + .onStop(unit.get(Throwing.Runnable.class)); + app.start(); + app.stop(); + + }, boot); + } + + @Test + public void defaultsWithCallback() throws Exception { + + Jooby jooby = new Jooby(); + assertNotNull(Jooby.exportRoutes(jooby)); + } + + @Test + public void customEnv() throws Exception { + + new MockUnit(Binder.class, Env.Builder.class) + .expect(guice) + .expect(shutdown) + .expect(config) + .expect(unit -> { + Env env = unit.mock(Env.class); + expect(env.name()).andReturn("dev").times(2); + expect(env.startTasks()).andReturn(Collections.emptyList()); + expect(env.startedTasks()).andReturn(Collections.emptyList()); + expect(env.stopTasks()).andReturn(Collections.emptyList()); + + Env.Builder builder = unit.get(Env.Builder.class); + expect(builder.build(isA(Config.class), isA(Jooby.class), isA(Locale.class))) + .andReturn(env); + + unit.mockStatic(UrlEscapers.class); + unit.mockStatic(HtmlEscapers.class); + Escaper escaper = unit.mock(Escaper.class); + + expect(UrlEscapers.urlFragmentEscaper()).andReturn(escaper); + expect(UrlEscapers.urlFormParameterEscaper()).andReturn(escaper); + expect(UrlEscapers.urlPathSegmentEscaper()).andReturn(escaper); + expect(HtmlEscapers.htmlEscaper()).andReturn(escaper); + + expect(env.xss(eq("urlFragment"), unit.capture(Function.class))).andReturn(env); + expect(env.xss(eq("formParam"), unit.capture(Function.class))).andReturn(env); + expect(env.xss(eq("pathSegment"), unit.capture(Function.class))).andReturn(env); + expect(env.xss(eq("html"), unit.capture(Function.class))).andReturn(env); + + Binder binder = unit.get(Binder.class); + + AnnotatedBindingBuilder binding = unit.mock(AnnotatedBindingBuilder.class); + binding.toInstance(env); + + expect(binder.bind(Env.class)).andReturn(binding); + }) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(routes) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(err) + .expect(internalOnStart(false)) + .run(unit -> { + + Jooby jooby = new Jooby(); + + jooby.env(unit.get(Env.Builder.class)); + + jooby.start(); + + }, boot); + } + + @Test + public void exportRoutes() { + Jooby app = new Jooby(); + app.get("/export", () -> "OK"); + List routes = Jooby.exportRoutes(app); + assertEquals(1, routes.size()); + assertEquals("/export", routes.get(0).pattern()); + assertEquals("GET", routes.get(0).method()); + } + + @Test + public void exportConf() { + Jooby app = new Jooby(); + app.use(ConfigFactory.empty().withValue("JoobyTest", ConfigValueFactory.fromAnyRef("foo"))); + Config conf = Jooby.exportConf(app); + assertEquals("foo", conf.getString("JoobyTest")); + } + + @Test + public void exportRoutesFailure() { + Jooby app = new Jooby(); + // generate an error on bootstrap + app.use(ConfigFactory.empty().withValue("application.lang", ConfigValueFactory.fromAnyRef(""))); + + app.get("/export", () -> "OK"); + List routes = Jooby.exportRoutes(app); + assertEquals(0, routes.size()); + } + + @Test + public void customLang() throws Exception { + + new MockUnit(Binder.class) + .expect(guice) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(routes) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(internalOnStart(false)) + .expect(err) + .run( + unit -> { + + Jooby jooby = new Jooby(); + jooby.use(ConfigFactory.empty().withValue("application.lang", + ConfigValueFactory.fromAnyRef("es"))); + + jooby.start(); + + }, boot); + } + + @Test + public void stopOnServerFailure() throws Exception { + + new MockUnit(Binder.class) + .expect( + unit -> { + Server server = unit.mock(Server.class); + server.start(); + server.join(); + server.stop(); + expectLastCall().andThrow(new Exception()); + + ScopedBindingBuilder serverScope = unit.mock(ScopedBindingBuilder.class); + serverScope.in(Singleton.class); + expectLastCall().times(0, 1); + + AnnotatedBindingBuilder serverBinding = unit + .mock(AnnotatedBindingBuilder.class); + expect(serverBinding.to(isA(Class.class))).andReturn(serverScope).times(0, 1); + + Binder binder = unit.get(Binder.class); + binder.install(anyObject(ProviderMethodsModule.class)); + expect(binder.bind(Server.class)).andReturn(serverBinding).times(0, 1); + + // ConfigOrigin configOrigin = unit.mock(ConfigOrigin.class); + // expect(configOrigin.description()).andReturn("test.conf, mock.conf").times(0, 1); + + Config config = unit.mock(Config.class); + expect(config.getString("application.env")).andReturn("dev"); + expect(config.hasPath("server.join")).andReturn(true); + expect(config.getBoolean("server.join")).andReturn(true); + unit.registerMock(Config.class, config); + + AppPrinter printer = unit.constructor(AppPrinter.class) + .args(Set.class, Set.class, Config.class) + .build(isA(Set.class), isA(Set.class), isA(Config.class)); + printer.printConf(isA(Logger.class), eq(config)); + + Injector injector = unit.mock(Injector.class); + expect(injector.getInstance(Server.class)).andReturn(server).times(1, 2); + expect(injector.getInstance(Config.class)).andReturn(config); + expect(injector.getInstance(Route.KEY)).andReturn(Collections.emptySet()); + expect(injector.getInstance(WebSocket.KEY)).andReturn(Collections.emptySet()); + injector.injectMembers(isA(Jooby.class)); + + unit.mockStatic(Guice.class); + expect(Guice.createInjector(eq(Stage.DEVELOPMENT), unit.capture(Module.class))) + .andReturn( + injector); + + unit.mockStatic(OptionalBinder.class); + + TypeConverters tc = unit.mockConstructor(TypeConverters.class); + tc.configure(binder); + }) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(routes) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(internalOnStart(false)) + .expect(tmpdir) + .expect(err) + .run(unit -> { + + Jooby jooby = new Jooby(); + + jooby.start(); + + }, boot); + } + + @Test + public void useFilter() throws Exception { + + List expected = new LinkedList<>(); + + new MockUnit(Binder.class, Route.Filter.class) + .expect(guice) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(unit -> { + Multibinder multibinder = unit.mock(Multibinder.class); + + Binder binder = unit.get(Binder.class); + + expect(Multibinder.newSetBinder(binder, Route.Definition.class)).andReturn(multibinder); + + LinkedBindingBuilder binding = unit.mock(LinkedBindingBuilder.class); + expect(multibinder.addBinding()).andReturn(binding); + expect(multibinder.addBinding()).andReturn(binding); + + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + }) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(internalOnStart(false)) + .expect(err) + .run(unit -> { + + Jooby jooby = new Jooby(); + + Route.Definition first = jooby.use("/filter", unit.get(Route.Filter.class)); + assertNotNull(first); + assertEquals("/filter", first.pattern()); + assertEquals("*", first.method()); + assertEquals("/anonymous", first.name()); + assertEquals(MediaType.ALL, first.consumes()); + assertEquals(MediaType.ALL, first.produces()); + + expected.add(first); + + Route.Definition second = jooby.use("GET", "*", unit.get(Route.Filter.class)); + assertNotNull(second); + assertEquals("/**", second.pattern()); + assertEquals("GET", second.method()); + assertEquals("/anonymous", second.name()); + assertEquals(MediaType.ALL, second.consumes()); + assertEquals(MediaType.ALL, second.produces()); + + expected.add(second); + + jooby.start(); + + }, boot, + unit -> { + List found = unit.captured(Route.Definition.class); + assertEquals(expected, found); + }); + } + + @Test + public void useHandler() throws Exception { + + List expected = new LinkedList<>(); + + new MockUnit(Binder.class, Route.Handler.class) + .expect(guice) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(unit -> { + Multibinder multibinder = unit.mock(Multibinder.class); + + Binder binder = unit.get(Binder.class); + + expect(Multibinder.newSetBinder(binder, Route.Definition.class)).andReturn(multibinder); + + LinkedBindingBuilder binding = unit.mock(LinkedBindingBuilder.class); + expect(multibinder.addBinding()).andReturn(binding); + expect(multibinder.addBinding()).andReturn(binding); + + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + }) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(err) + .expect(internalOnStart(false)) + .run(unit -> { + + Jooby jooby = new Jooby(); + + Route.Definition first = jooby.use("/first", unit.get(Route.Handler.class)); + assertNotNull(first); + assertEquals("/first", first.pattern()); + assertEquals("*", first.method()); + assertEquals("/anonymous", first.name()); + assertEquals(MediaType.ALL, first.consumes()); + assertEquals(MediaType.ALL, first.produces()); + + expected.add(first); + + Route.Definition second = jooby.use("GET", "*", unit.get(Route.Handler.class)); + assertNotNull(second); + assertEquals("/**", second.pattern()); + assertEquals("GET", second.method()); + assertEquals("/anonymous", second.name()); + assertEquals(MediaType.ALL, second.consumes()); + assertEquals(MediaType.ALL, second.produces()); + + expected.add(second); + + jooby.start(); + + }, boot, + unit -> { + List found = unit.captured(Route.Definition.class); + assertEquals(expected, found); + }); + } + + @Test + public void postHandlers() throws Exception { + + List expected = new LinkedList<>(); + + new MockUnit(Binder.class, Route.Handler.class, Route.OneArgHandler.class, + Route.ZeroArgHandler.class, Route.Filter.class) + .expect(guice) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(unit -> { + Multibinder multibinder = unit.mock(Multibinder.class); + + Binder binder = unit.get(Binder.class); + + expect(Multibinder.newSetBinder(binder, Route.Definition.class)) + .andReturn(multibinder); + + LinkedBindingBuilder binding = unit + .mock(LinkedBindingBuilder.class); + expect(multibinder.addBinding()).andReturn(binding).times(4); + + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + }) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(internalOnStart(false)) + .expect(err) + .run(unit -> { + + Jooby jooby = new Jooby(); + + Route.Definition first = jooby.post("/first", unit.get(Route.Handler.class)); + assertNotNull(first); + assertEquals("/first", first.pattern()); + assertEquals("POST", first.method()); + assertEquals("/anonymous", first.name()); + assertEquals(MediaType.ALL, first.consumes()); + assertEquals(MediaType.ALL, first.produces()); + + expected.add(first); + + Route.Definition second = jooby.post("/second", unit.get(Route.OneArgHandler.class)); + assertNotNull(second); + assertEquals("/second", second.pattern()); + assertEquals("POST", second.method()); + assertEquals("/anonymous", second.name()); + assertEquals(MediaType.ALL, second.consumes()); + assertEquals(MediaType.ALL, second.produces()); + + expected.add(second); + + Route.Definition third = jooby.post("/third", unit.get(Route.ZeroArgHandler.class)); + assertNotNull(third); + assertEquals("/third", third.pattern()); + assertEquals("POST", third.method()); + assertEquals("/anonymous", third.name()); + assertEquals(MediaType.ALL, third.consumes()); + assertEquals(MediaType.ALL, third.produces()); + + expected.add(third); + + Route.Definition fourth = jooby.post("/fourth", unit.get(Route.Filter.class)); + assertNotNull(fourth); + assertEquals("/fourth", fourth.pattern()); + assertEquals("POST", fourth.method()); + assertEquals("/anonymous", fourth.name()); + assertEquals(MediaType.ALL, fourth.consumes()); + assertEquals(MediaType.ALL, fourth.produces()); + + expected.add(fourth); + + jooby.start(); + + }, boot, + unit -> { + List found = unit.captured(Route.Definition.class); + assertEquals(expected, found); + }); + } + + @Test + public void headHandlers() throws Exception { + + List expected = new LinkedList<>(); + + new MockUnit(Binder.class, Route.Handler.class, Route.OneArgHandler.class, + Route.ZeroArgHandler.class, Route.Filter.class) + .expect(guice) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(unit -> { + Multibinder multibinder = unit.mock(Multibinder.class); + + Binder binder = unit.get(Binder.class); + + expect(Multibinder.newSetBinder(binder, Route.Definition.class)) + .andReturn(multibinder); + + LinkedBindingBuilder binding = unit + .mock(LinkedBindingBuilder.class); + expect(multibinder.addBinding()).andReturn(binding).times(4); + + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + }) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(internalOnStart(false)) + .expect(err) + .run(unit -> { + + Jooby jooby = new Jooby(); + + Route.Definition first = jooby.head("/first", unit.get(Route.Handler.class)); + assertNotNull(first); + assertEquals("/first", first.pattern()); + assertEquals("HEAD", first.method()); + assertEquals("/anonymous", first.name()); + assertEquals(MediaType.ALL, first.consumes()); + assertEquals(MediaType.ALL, first.produces()); + + expected.add(first); + + Route.Definition second = jooby.head("/second", unit.get(Route.OneArgHandler.class)); + assertNotNull(second); + assertEquals("/second", second.pattern()); + assertEquals("HEAD", second.method()); + assertEquals("/anonymous", second.name()); + assertEquals(MediaType.ALL, second.consumes()); + assertEquals(MediaType.ALL, second.produces()); + + expected.add(second); + + Route.Definition third = jooby.head("/third", unit.get(Route.ZeroArgHandler.class)); + assertNotNull(third); + assertEquals("/third", third.pattern()); + assertEquals("HEAD", third.method()); + assertEquals("/anonymous", third.name()); + assertEquals(MediaType.ALL, third.consumes()); + assertEquals(MediaType.ALL, third.produces()); + + expected.add(third); + + Route.Definition fourth = jooby.head("/fourth", unit.get(Route.Filter.class)); + assertNotNull(fourth); + assertEquals("/fourth", fourth.pattern()); + assertEquals("HEAD", fourth.method()); + assertEquals("/anonymous", fourth.name()); + assertEquals(MediaType.ALL, fourth.consumes()); + assertEquals(MediaType.ALL, fourth.produces()); + + expected.add(fourth); + + jooby.start(); + + }, boot, + unit -> { + List found = unit.captured(Route.Definition.class); + assertEquals(expected, found); + }); + } + + @Test + public void optionsHandlers() throws Exception { + + List expected = new LinkedList<>(); + + new MockUnit(Binder.class, Route.Handler.class, Route.OneArgHandler.class, + Route.ZeroArgHandler.class, Route.Filter.class) + .expect(guice) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(unit -> { + Multibinder multibinder = unit.mock(Multibinder.class); + + Binder binder = unit.get(Binder.class); + + expect(Multibinder.newSetBinder(binder, Route.Definition.class)) + .andReturn(multibinder); + + LinkedBindingBuilder binding = unit + .mock(LinkedBindingBuilder.class); + expect(multibinder.addBinding()).andReturn(binding).times(4); + + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + }) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(internalOnStart(false)) + .expect(err) + .run(unit -> { + + Jooby jooby = new Jooby(); + + Route.Definition first = jooby.options("/first", unit.get(Route.Handler.class)); + assertNotNull(first); + assertEquals("/first", first.pattern()); + assertEquals("OPTIONS", first.method()); + assertEquals("/anonymous", first.name()); + assertEquals(MediaType.ALL, first.consumes()); + assertEquals(MediaType.ALL, first.produces()); + + expected.add(first); + + Route.Definition second = jooby.options("/second", + unit.get(Route.OneArgHandler.class)); + assertNotNull(second); + assertEquals("/second", second.pattern()); + assertEquals("OPTIONS", second.method()); + assertEquals("/anonymous", second.name()); + assertEquals(MediaType.ALL, second.consumes()); + assertEquals(MediaType.ALL, second.produces()); + + expected.add(second); + + Route.Definition third = jooby.options("/third", + unit.get(Route.ZeroArgHandler.class)); + assertNotNull(third); + assertEquals("/third", third.pattern()); + assertEquals("OPTIONS", third.method()); + assertEquals("/anonymous", third.name()); + assertEquals(MediaType.ALL, third.consumes()); + assertEquals(MediaType.ALL, third.produces()); + + expected.add(third); + + Route.Definition fourth = jooby.options("/fourth", unit.get(Route.Filter.class)); + assertNotNull(fourth); + assertEquals("/fourth", fourth.pattern()); + assertEquals("OPTIONS", fourth.method()); + assertEquals("/anonymous", fourth.name()); + assertEquals(MediaType.ALL, fourth.consumes()); + assertEquals(MediaType.ALL, fourth.produces()); + + expected.add(fourth); + + jooby.start(); + + }, boot, + unit -> { + List found = unit.captured(Route.Definition.class); + assertEquals(expected, found); + }); + } + + @Test + public void putHandlers() throws Exception { + + List expected = new LinkedList<>(); + + new MockUnit(Binder.class, Route.Handler.class, Route.OneArgHandler.class, + Route.ZeroArgHandler.class, Route.Filter.class) + .expect(guice) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(unit -> { + Multibinder multibinder = unit.mock(Multibinder.class); + + Binder binder = unit.get(Binder.class); + + expect(Multibinder.newSetBinder(binder, Route.Definition.class)) + .andReturn(multibinder); + + LinkedBindingBuilder binding = unit + .mock(LinkedBindingBuilder.class); + expect(multibinder.addBinding()).andReturn(binding).times(4); + + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + }) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(internalOnStart(false)) + .expect(err) + .run(unit -> { + + Jooby jooby = new Jooby(); + + Route.Definition first = jooby.put("/first", unit.get(Route.Handler.class)); + assertNotNull(first); + assertEquals("/first", first.pattern()); + assertEquals("PUT", first.method()); + assertEquals("/anonymous", first.name()); + assertEquals(MediaType.ALL, first.consumes()); + assertEquals(MediaType.ALL, first.produces()); + + expected.add(first); + + Route.Definition second = jooby.put("/second", unit.get(Route.OneArgHandler.class)); + assertNotNull(second); + assertEquals("/second", second.pattern()); + assertEquals("PUT", second.method()); + assertEquals("/anonymous", second.name()); + assertEquals(MediaType.ALL, second.consumes()); + assertEquals(MediaType.ALL, second.produces()); + + expected.add(second); + + Route.Definition third = jooby.put("/third", unit.get(Route.ZeroArgHandler.class)); + assertNotNull(third); + assertEquals("/third", third.pattern()); + assertEquals("PUT", third.method()); + assertEquals("/anonymous", third.name()); + assertEquals(MediaType.ALL, third.consumes()); + assertEquals(MediaType.ALL, third.produces()); + + expected.add(third); + + Route.Definition fourth = jooby.put("/fourth", unit.get(Route.Filter.class)); + assertNotNull(fourth); + assertEquals("/fourth", fourth.pattern()); + assertEquals("PUT", fourth.method()); + assertEquals("/anonymous", fourth.name()); + assertEquals(MediaType.ALL, fourth.consumes()); + assertEquals(MediaType.ALL, fourth.produces()); + + expected.add(fourth); + + jooby.start(); + + }, boot, + unit -> { + List found = unit.captured(Route.Definition.class); + assertEquals(expected, found); + }); + } + + @Test + public void patchHandlers() throws Exception { + + List expected = new LinkedList<>(); + + new MockUnit(Binder.class, Route.Handler.class, Route.OneArgHandler.class, + Route.ZeroArgHandler.class, Route.Filter.class) + .expect(guice) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(unit -> { + Multibinder multibinder = unit.mock(Multibinder.class); + + Binder binder = unit.get(Binder.class); + + expect(Multibinder.newSetBinder(binder, Route.Definition.class)) + .andReturn(multibinder); + + LinkedBindingBuilder binding = unit + .mock(LinkedBindingBuilder.class); + expect(multibinder.addBinding()).andReturn(binding).times(4); + + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + }) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(internalOnStart(false)) + .expect(err) + .run(unit -> { + + Jooby jooby = new Jooby(); + + Route.Definition first = jooby.patch("/first", unit.get(Route.Handler.class)); + assertNotNull(first); + assertEquals("/first", first.pattern()); + assertEquals("PATCH", first.method()); + assertEquals("/anonymous", first.name()); + assertEquals(MediaType.ALL, first.consumes()); + assertEquals(MediaType.ALL, first.produces()); + + expected.add(first); + + Route.Definition second = jooby.patch("/second", unit.get(Route.OneArgHandler.class)); + assertNotNull(second); + assertEquals("/second", second.pattern()); + assertEquals("PATCH", second.method()); + assertEquals("/anonymous", second.name()); + assertEquals(MediaType.ALL, second.consumes()); + assertEquals(MediaType.ALL, second.produces()); + + expected.add(second); + + Route.Definition third = jooby.patch("/third", unit.get(Route.ZeroArgHandler.class)); + assertNotNull(third); + assertEquals("/third", third.pattern()); + assertEquals("PATCH", third.method()); + assertEquals("/anonymous", third.name()); + assertEquals(MediaType.ALL, third.consumes()); + assertEquals(MediaType.ALL, third.produces()); + + expected.add(third); + + Route.Definition fourth = jooby.patch("/fourth", unit.get(Route.Filter.class)); + assertNotNull(fourth); + assertEquals("/fourth", fourth.pattern()); + assertEquals("PATCH", fourth.method()); + assertEquals("/anonymous", fourth.name()); + assertEquals(MediaType.ALL, fourth.consumes()); + assertEquals(MediaType.ALL, fourth.produces()); + + expected.add(fourth); + + jooby.start(); + + }, boot, + unit -> { + List found = unit.captured(Route.Definition.class); + assertEquals(expected, found); + }); + } + + @Test + public void deleteHandlers() throws Exception { + + List expected = new LinkedList<>(); + + new MockUnit(Binder.class, Route.Handler.class, Route.OneArgHandler.class, + Route.ZeroArgHandler.class, Route.Filter.class) + .expect(guice) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(unit -> { + Multibinder multibinder = unit.mock(Multibinder.class); + + Binder binder = unit.get(Binder.class); + + expect(Multibinder.newSetBinder(binder, Route.Definition.class)) + .andReturn(multibinder); + + LinkedBindingBuilder binding = unit + .mock(LinkedBindingBuilder.class); + expect(multibinder.addBinding()).andReturn(binding).times(4); + + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + }) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(internalOnStart(false)) + .expect(err) + .run(unit -> { + + Jooby jooby = new Jooby(); + + Route.Definition first = jooby.delete("/first", unit.get(Route.Handler.class)); + assertNotNull(first); + assertEquals("/first", first.pattern()); + assertEquals("DELETE", first.method()); + assertEquals("/anonymous", first.name()); + assertEquals(MediaType.ALL, first.consumes()); + assertEquals(MediaType.ALL, first.produces()); + + expected.add(first); + + Route.Definition second = jooby.delete("/second", + unit.get(Route.OneArgHandler.class)); + assertNotNull(second); + assertEquals("/second", second.pattern()); + assertEquals("DELETE", second.method()); + assertEquals("/anonymous", second.name()); + assertEquals(MediaType.ALL, second.consumes()); + assertEquals(MediaType.ALL, second.produces()); + + expected.add(second); + + Route.Definition third = jooby.delete("/third", unit.get(Route.ZeroArgHandler.class)); + assertNotNull(third); + assertEquals("/third", third.pattern()); + assertEquals("DELETE", third.method()); + assertEquals("/anonymous", third.name()); + assertEquals(MediaType.ALL, third.consumes()); + assertEquals(MediaType.ALL, third.produces()); + + expected.add(third); + + Route.Definition fourth = jooby.delete("/fourth", unit.get(Route.Filter.class)); + assertNotNull(fourth); + assertEquals("/fourth", fourth.pattern()); + assertEquals("DELETE", fourth.method()); + assertEquals("/anonymous", fourth.name()); + assertEquals(MediaType.ALL, fourth.consumes()); + assertEquals(MediaType.ALL, fourth.produces()); + + expected.add(fourth); + + jooby.start(); + + }, boot, + unit -> { + List found = unit.captured(Route.Definition.class); + assertEquals(expected, found); + }); + } + + @Test + public void connectHandlers() throws Exception { + + List expected = new LinkedList<>(); + + new MockUnit(Binder.class, Route.Handler.class, Route.OneArgHandler.class, + Route.ZeroArgHandler.class, Route.Filter.class) + .expect(guice) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(unit -> { + Multibinder multibinder = unit.mock(Multibinder.class); + + Binder binder = unit.get(Binder.class); + + expect(Multibinder.newSetBinder(binder, Route.Definition.class)) + .andReturn(multibinder); + + LinkedBindingBuilder binding = unit + .mock(LinkedBindingBuilder.class); + expect(multibinder.addBinding()).andReturn(binding).times(4); + + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + }) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(internalOnStart(false)) + .expect(err) + .run(unit -> { + + Jooby jooby = new Jooby(); + + Route.Definition first = jooby.connect("/first", unit.get(Route.Handler.class)); + assertNotNull(first); + assertEquals("/first", first.pattern()); + assertEquals("CONNECT", first.method()); + assertEquals("/anonymous", first.name()); + assertEquals(MediaType.ALL, first.consumes()); + assertEquals(MediaType.ALL, first.produces()); + + expected.add(first); + + Route.Definition second = jooby.connect("/second", + unit.get(Route.OneArgHandler.class)); + assertNotNull(second); + assertEquals("/second", second.pattern()); + assertEquals("CONNECT", second.method()); + assertEquals("/anonymous", second.name()); + assertEquals(MediaType.ALL, second.consumes()); + assertEquals(MediaType.ALL, second.produces()); + + expected.add(second); + + Route.Definition third = jooby.connect("/third", + unit.get(Route.ZeroArgHandler.class)); + assertNotNull(third); + assertEquals("/third", third.pattern()); + assertEquals("CONNECT", third.method()); + assertEquals("/anonymous", third.name()); + assertEquals(MediaType.ALL, third.consumes()); + assertEquals(MediaType.ALL, third.produces()); + + expected.add(third); + + Route.Definition fourth = jooby.connect("/fourth", unit.get(Route.Filter.class)); + assertNotNull(fourth); + assertEquals("/fourth", fourth.pattern()); + assertEquals("CONNECT", fourth.method()); + assertEquals("/anonymous", fourth.name()); + assertEquals(MediaType.ALL, fourth.consumes()); + assertEquals(MediaType.ALL, fourth.produces()); + + expected.add(fourth); + + jooby.start(); + + }, boot, + unit -> { + List found = unit.captured(Route.Definition.class); + assertEquals(expected, found); + }); + } + + @Test + public void traceHandlers() throws Exception { + + List expected = new LinkedList<>(); + + new MockUnit(Binder.class, Route.Handler.class, Route.OneArgHandler.class, + Route.ZeroArgHandler.class, Route.Filter.class) + .expect(guice) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(unit -> { + Multibinder multibinder = unit.mock(Multibinder.class); + + Binder binder = unit.get(Binder.class); + + expect(Multibinder.newSetBinder(binder, Route.Definition.class)) + .andReturn(multibinder); + + LinkedBindingBuilder binding = unit + .mock(LinkedBindingBuilder.class); + expect(multibinder.addBinding()).andReturn(binding).times(4); + + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + }) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(internalOnStart(false)) + .expect(err) + .run(unit -> { + + Jooby jooby = new Jooby(); + + Route.Definition first = jooby.trace("/first", unit.get(Route.Handler.class)); + assertNotNull(first); + assertEquals("/first", first.pattern()); + assertEquals("TRACE", first.method()); + assertEquals("/anonymous", first.name()); + assertEquals(MediaType.ALL, first.consumes()); + assertEquals(MediaType.ALL, first.produces()); + + expected.add(first); + + Route.Definition second = jooby.trace("/second", unit.get(Route.OneArgHandler.class)); + assertNotNull(second); + assertEquals("/second", second.pattern()); + assertEquals("TRACE", second.method()); + assertEquals("/anonymous", second.name()); + assertEquals(MediaType.ALL, second.consumes()); + assertEquals(MediaType.ALL, second.produces()); + + expected.add(second); + + Route.Definition third = jooby.trace("/third", unit.get(Route.ZeroArgHandler.class)); + assertNotNull(third); + assertEquals("/third", third.pattern()); + assertEquals("TRACE", third.method()); + assertEquals("/anonymous", third.name()); + assertEquals(MediaType.ALL, third.consumes()); + assertEquals(MediaType.ALL, third.produces()); + + expected.add(third); + + Route.Definition fourth = jooby.trace("/fourth", unit.get(Route.Filter.class)); + assertNotNull(fourth); + assertEquals("/fourth", fourth.pattern()); + assertEquals("TRACE", fourth.method()); + assertEquals("/anonymous", fourth.name()); + assertEquals(MediaType.ALL, fourth.consumes()); + assertEquals(MediaType.ALL, fourth.produces()); + + expected.add(fourth); + + jooby.start(); + + }, boot, + unit -> { + List found = unit.captured(Route.Definition.class); + assertEquals(expected, found); + }); + } + + @Test + public void assets() throws Exception { + + List expected = new LinkedList<>(); + + String path = "/org/jooby/JoobyTest.js"; + new MockUnit(Binder.class, Request.class, Response.class, Route.Chain.class) + .expect(guice) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(unit -> { + Multibinder multibinder = unit.mock(Multibinder.class); + + Binder binder = unit.get(Binder.class); + + unit.mockStatic(Multibinder.class); + expect(Multibinder.newSetBinder(binder, Renderer.class)).andReturn(multibinder); + + LinkedBindingBuilder customFormatter = unit + .mock(LinkedBindingBuilder.class); + customFormatter.toInstance(BuiltinRenderer.asset); + + LinkedBindingBuilder formatByteArray = unit.mock(LinkedBindingBuilder.class); + formatByteArray.toInstance(BuiltinRenderer.bytes); + + LinkedBindingBuilder formatByteBuffer = unit.mock(LinkedBindingBuilder.class); + formatByteBuffer.toInstance(BuiltinRenderer.byteBuffer); + + LinkedBindingBuilder file = unit.mock(LinkedBindingBuilder.class); + file.toInstance(BuiltinRenderer.file); + + LinkedBindingBuilder formatStream = unit.mock(LinkedBindingBuilder.class); + formatStream.toInstance(BuiltinRenderer.stream); + + LinkedBindingBuilder reader = unit.mock(LinkedBindingBuilder.class); + reader.toInstance(BuiltinRenderer.reader); + + LinkedBindingBuilder charBuffer = unit.mock(LinkedBindingBuilder.class); + charBuffer.toInstance(BuiltinRenderer.charBuffer); + + LinkedBindingBuilder fchannel = unit.mock(LinkedBindingBuilder.class); + fchannel.toInstance(BuiltinRenderer.fileChannel); + + LinkedBindingBuilder err = unit.mock(LinkedBindingBuilder.class); + err.toInstance(isA(DefaulErrRenderer.class)); + + LinkedBindingBuilder formatAny = unit.mock(LinkedBindingBuilder.class); + formatAny.toInstance(BuiltinRenderer.text); + + expect(multibinder.addBinding()).andReturn(customFormatter); + expect(multibinder.addBinding()).andReturn(formatByteArray); + expect(multibinder.addBinding()).andReturn(formatByteBuffer); + expect(multibinder.addBinding()).andReturn(file); + expect(multibinder.addBinding()).andReturn(charBuffer); + expect(multibinder.addBinding()).andReturn(formatStream); + expect(multibinder.addBinding()).andReturn(reader); + expect(multibinder.addBinding()).andReturn(fchannel); + expect(multibinder.addBinding()).andReturn(err); + expect(multibinder.addBinding()).andReturn(formatAny); + }) + .expect(session) + .expect(unit -> { + Multibinder multibinder = unit.mock(Multibinder.class); + + Binder binder = unit.get(Binder.class); + + expect(Multibinder.newSetBinder(binder, Route.Definition.class)).andReturn(multibinder); + + LinkedBindingBuilder binding = unit.mock(LinkedBindingBuilder.class); + expect(multibinder.addBinding()).andReturn(binding).times(2); + + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + }) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(err) + .expect(unit -> { + Mutant ifModifiedSince = unit.mock(Mutant.class); + expect(ifModifiedSince.toOptional(Long.class)).andReturn(Optional.empty()); + + Mutant ifnm = unit.mock(Mutant.class); + expect(ifnm.toOptional()).andReturn(Optional.empty()); + + Request req = unit.get(Request.class); + expect(req.path()).andReturn(path); + expect(req.header("If-Modified-Since")).andReturn(ifModifiedSince); + expect(req.header("If-None-Match")).andReturn(ifnm); + + Response rsp = unit.get(Response.class); + expect(rsp.header(eq("Last-Modified"), unit.capture(java.util.Date.class))) + .andReturn(rsp); + expect(rsp.header(eq("ETag"), isA(String.class))).andReturn(rsp); + rsp.send(isA(Asset.class)); + + Route.Chain chain = unit.get(Route.Chain.class); + chain.next(req, rsp); + }) + .expect(internalOnStart(false)) + .expect(unit -> { + Config conf = unit.get(Config.class); + expect(conf.getString("assets.cdn")).andReturn("").times(2); + expect(conf.getBoolean("assets.lastModified")).andReturn(true).times(2); + expect(conf.getBoolean("assets.etag")).andReturn(true).times(2); + expect(conf.getString("assets.cache.maxAge")).andReturn("-1").times(2); + + Injector injector = unit.get(Injector.class); + expect(injector.getInstance(Key.get(Config.class))).andReturn(conf).times(2); + }) + .run(unit -> { + Jooby jooby = new Jooby(); + + Route.Definition assets = jooby.assets("/org/jooby/**"); + expected.add(assets); + + Route.Definition dir = jooby.assets("/dir/**"); + expected.add(dir); + + jooby.start(); + + Optional route = assets.matches("GET", "/org/jooby/JoobyTest.js", + MediaType.all, MediaType.ALL); + assertNotNull(route); + assertTrue(route.isPresent()); + + ((RouteImpl) route.get()).handle(unit.get(Request.class), unit.get(Response.class), + unit.get(Route.Chain.class)); + + }, boot, unit -> { + List found = unit.captured(Route.Definition.class); + assertEquals(expected, found); + }); + } + + @Test + public void mvcRoute() throws Exception { + + new MockUnit(Binder.class) + .expect(guice) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(unit -> { + Multibinder multibinder = unit.mock(Multibinder.class); + + Binder binder = unit.get(Binder.class); + + expect(Multibinder.newSetBinder(binder, Route.Definition.class)).andReturn( + multibinder); + + LinkedBindingBuilder binding = unit + .mock(LinkedBindingBuilder.class); + expect(multibinder.addBinding()).andReturn(binding).times(7); + + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + binding.toInstance(unit.capture(Route.Definition.class)); + + expect(binder.bind(SingletonTestRoute.class)).andReturn(null); + + expect(binder.bind(GuiceSingletonTestRoute.class)).andReturn(null); + + expect(binder.bind(ProtoTestRoute.class)).andReturn(null); + }) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(internalOnStart(false)) + .expect(err) + .run(unit -> { + + Jooby jooby = new Jooby(); + jooby.use(SingletonTestRoute.class); + jooby.use(GuiceSingletonTestRoute.class); + jooby.use(ProtoTestRoute.class); + jooby.use("/test", SingletonTestRoute.class); + jooby.start(); + + }, + boot, + unit -> { + // assert routes + List defs = unit.captured(Route.Definition.class); + assertEquals(7, defs.size()); + + assertEquals("GET", defs.get(0).method()); + assertEquals("/singleton", defs.get(0).pattern()); + assertEquals("/SingletonTestRoute.m1", defs.get(0).name()); + + assertEquals("POST", defs.get(1).method()); + assertEquals("/singleton", defs.get(1).pattern()); + assertEquals("/SingletonTestRoute.m1", defs.get(1).name()); + + assertEquals("GET", defs.get(2).method()); + assertEquals("/singleton", defs.get(2).pattern()); + assertEquals("/GuiceSingletonTestRoute.m1", defs.get(2).name()); + + assertEquals("POST", defs.get(3).method()); + assertEquals("/singleton", defs.get(3).pattern()); + assertEquals("/GuiceSingletonTestRoute.m1", defs.get(3).name()); + + assertEquals("GET", defs.get(4).method()); + assertEquals("/proto", defs.get(4).pattern()); + assertEquals("/ProtoTestRoute.m1", defs.get(4).name()); + + assertEquals("GET", defs.get(5).method()); + assertEquals("/test/singleton", defs.get(5).pattern()); + assertEquals("/SingletonTestRoute.m1", defs.get(5).name()); + + assertEquals("POST", defs.get(6).method()); + assertEquals("/test/singleton", defs.get(6).pattern()); + assertEquals("/SingletonTestRoute.m1", defs.get(6).name()); + }); + } + + @Test + public void globHead() throws Exception { + new MockUnit(Request.class, Response.class) + .run(unit -> { + Jooby jooby = new Jooby(); + + Route.Definition head = jooby.head(); + assertNotNull(head); + assertEquals("/**", head.pattern()); + assertEquals("HEAD", head.method()); + }); + } + + @Test + public void globOptions() throws Exception { + new MockUnit(Request.class, Response.class) + .run(unit -> { + Jooby jooby = new Jooby(); + + Route.Definition options = jooby.options(); + assertNotNull(options); + assertEquals("/**", options.pattern()); + assertEquals("OPTIONS", options.method()); + }); + } + + @Test + public void globTrace() throws Exception { + new MockUnit(Request.class, Response.class) + .run(unit -> { + Jooby jooby = new Jooby(); + + Route.Definition trace = jooby.trace(); + assertNotNull(trace); + assertEquals("/**", trace.pattern()); + assertEquals("TRACE", trace.method()); + }); + } + + @Test + public void ws() throws Exception { + + List defs = new LinkedList<>(); + + new MockUnit(Binder.class) + .expect(guice) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(routes) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(unit -> { + Multibinder multibinder = unit.mock(Multibinder.class); + + LinkedBindingBuilder binding = unit + .mock(LinkedBindingBuilder.class); + binding.toInstance(unit.capture(WebSocket.Definition.class)); + + expect(multibinder.addBinding()).andReturn(binding); + + Binder binder = unit.get(Binder.class); + + expect(Multibinder.newSetBinder(binder, WebSocket.Definition.class)).andReturn( + multibinder); + }) + .expect(tmpdir) + .expect(err) + .expect(internalOnStart(false)) + .run(unit -> { + + Jooby jooby = new Jooby(); + + WebSocket.Definition ws = jooby.ws("/", (socket) -> { + }); + assertEquals("/", ws.pattern()); + assertEquals(MediaType.plain, ws.consumes()); + assertEquals(MediaType.plain, ws.produces()); + defs.add(ws); + + jooby.start(); + + }, boot, unit -> { + assertEquals(defs, unit.captured(WebSocket.Definition.class)); + }); + } + + @Test + public void useStore() throws Exception { + + new MockUnit(Store.class, Binder.class) + .expect(guice) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect( + unit -> { + Binder binder = unit.get(Binder.class); + + AnnotatedBindingBuilder smABB = unit + .mock(AnnotatedBindingBuilder.class); + expect(smABB.to(ServerSessionManager.class)).andReturn(smABB); + smABB.asEagerSingleton(); + + ScopedBindingBuilder ssSBB = unit.mock(ScopedBindingBuilder.class); + ssSBB.asEagerSingleton(); + + AnnotatedBindingBuilder ssABB = unit.mock(AnnotatedBindingBuilder.class); + expect(ssABB.to(unit.get(Session.Store.class).getClass())).andReturn(ssSBB); + + expect(binder.bind(SessionManager.class)).andReturn(smABB); + expect(binder.bind(Session.Store.class)).andReturn(ssABB); + + AnnotatedBindingBuilder sdABB = unit + .mock(AnnotatedBindingBuilder.class); + expect(sdABB.toProvider(unit.capture(com.google.inject.Provider.class))) + .andReturn(sdABB); + sdABB.asEagerSingleton(); + + expect(binder.bind(Session.Definition.class)).andReturn(sdABB); + }) + .expect(routes) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(internalOnStart(false)) + .expect(err) + .run(unit -> { + + Jooby jooby = new Jooby(); + jooby.session(unit.get(Store.class).getClass()); + + jooby.start(); + + }, boot, unit -> { + Definition def = (Definition) unit.captured(com.google.inject.Provider.class) + .iterator().next().get(); + assertEquals(unit.get(Store.class).getClass(), def.store()); + }); + } + + @Test + public void renderer() throws Exception { + + new MockUnit(Renderer.class, Binder.class) + .expect(guice) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(session) + .expect(unit -> { + Multibinder multibinder = unit.mock(Multibinder.class); + + Binder binder = unit.get(Binder.class); + + unit.mockStatic(Multibinder.class); + expect(Multibinder.newSetBinder(binder, Renderer.class)).andReturn(multibinder); + + LinkedBindingBuilder customFormatter = unit + .mock(LinkedBindingBuilder.class); + customFormatter.toInstance(unit.get(Renderer.class)); + + LinkedBindingBuilder formatAsset = unit.mock(LinkedBindingBuilder.class); + formatAsset.toInstance(BuiltinRenderer.asset); + + LinkedBindingBuilder formatByteArray = unit.mock(LinkedBindingBuilder.class); + formatByteArray.toInstance(BuiltinRenderer.bytes); + + LinkedBindingBuilder formatByteBuffer = unit.mock(LinkedBindingBuilder.class); + formatByteBuffer.toInstance(BuiltinRenderer.byteBuffer); + + LinkedBindingBuilder file = unit.mock(LinkedBindingBuilder.class); + file.toInstance(BuiltinRenderer.file); + + LinkedBindingBuilder formatStream = unit.mock(LinkedBindingBuilder.class); + formatStream.toInstance(BuiltinRenderer.stream); + + LinkedBindingBuilder reader = unit.mock(LinkedBindingBuilder.class); + reader.toInstance(BuiltinRenderer.reader); + + LinkedBindingBuilder charBuffer = unit.mock(LinkedBindingBuilder.class); + charBuffer.toInstance(BuiltinRenderer.charBuffer); + + LinkedBindingBuilder fchannel = unit.mock(LinkedBindingBuilder.class); + fchannel.toInstance(BuiltinRenderer.fileChannel); + + LinkedBindingBuilder err = unit.mock(LinkedBindingBuilder.class); + err.toInstance(isA(DefaulErrRenderer.class)); + + LinkedBindingBuilder formatAny = unit.mock(LinkedBindingBuilder.class); + formatAny.toInstance(BuiltinRenderer.text); + + expect(multibinder.addBinding()).andReturn(formatAsset); + expect(multibinder.addBinding()).andReturn(formatByteArray); + expect(multibinder.addBinding()).andReturn(formatByteBuffer); + expect(multibinder.addBinding()).andReturn(file); + expect(multibinder.addBinding()).andReturn(charBuffer); + expect(multibinder.addBinding()).andReturn(formatStream); + expect(multibinder.addBinding()).andReturn(reader); + expect(multibinder.addBinding()).andReturn(fchannel); + expect(multibinder.addBinding()).andReturn(customFormatter); + expect(multibinder.addBinding()).andReturn(err); + expect(multibinder.addBinding()).andReturn(formatAny); + }) + .expect(routes) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(internalOnStart(false)) + .expect(err) + .run(unit -> { + + Jooby jooby = new Jooby(); + jooby.renderer(unit.get(Renderer.class)); + + jooby.start(); + + }, boot); + } + + @Test + @SuppressWarnings("rawtypes") + public void useParser() throws Exception { + + new MockUnit(Parser.class, Binder.class) + .expect(guice) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(unit -> { + Binder binder = unit.get(Binder.class); + + AnnotatedBindingBuilder parambinding = unit + .mock(AnnotatedBindingBuilder.class); + parambinding.in(Singleton.class); + + expect(binder.bind(ParserExecutor.class)).andReturn(parambinding); + + Multibinder multibinder = unit.mock(Multibinder.class, true); + + LinkedBindingBuilder customParser = unit.mock(LinkedBindingBuilder.class); + customParser.toInstance(unit.get(Parser.class)); + + for (Parser parser : BuiltinParser.values()) { + LinkedBindingBuilder converterBinding = unit.mock(LinkedBindingBuilder.class); + converterBinding.toInstance(parser); + expect(multibinder.addBinding()).andReturn(converterBinding); + } + + expect(multibinder.addBinding()).andReturn(customParser); + + Class[] parserClasses = { + DateParser.class, + LocalDateParser.class, + ZonedDateTimeParser.class, + LocaleParser.class, + StaticMethodParser.class, + StaticMethodParser.class, + StaticMethodParser.class, + StringConstructorParser.class, + BeanParser.class + }; + + for (Class converter : parserClasses) { + LinkedBindingBuilder converterBinding = unit.mock(LinkedBindingBuilder.class); + converterBinding.toInstance(isA(converter)); + expect(multibinder.addBinding()).andReturn(converterBinding); + } + + expect(Multibinder.newSetBinder(binder, Parser.class)).andReturn(multibinder); + }) + .expect(session) + .expect(routes) + .expect(routeHandler) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(internalOnStart(false)) + .expect(err) + .run(unit -> { + + Jooby jooby = new Jooby(); + + jooby.parser(unit.get(Parser.class)); + + jooby.start(); + + }, boot); + } + + @Test + public void useModule() throws Exception { + + new MockUnit(Binder.class, Jooby.Module.class) + .expect(guice) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(routes) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(internalOnStart(false)) + .expect(err) + .expect(unit -> { + Binder binder = unit.get(Binder.class); + Jooby.Module module = unit.get(Jooby.Module.class); + + Config config = ConfigFactory.empty(); + + expect(module.config()).andReturn(config).times(2); + + module.configure(isA(Env.class), isA(Config.class), eq(binder)); + }) + .run(unit -> { + + Jooby jooby = new Jooby(); + + jooby.use(unit.get(Jooby.Module.class)); + + jooby.start(); + + }, boot); + } + + @Test + public void useModuleWithError() throws Exception { + Jooby jooby = new Jooby(); + + jooby.use((env, conf, binder) -> { + throw new NullPointerException("intentional err"); + }); + + jooby.start(); + } + + @Test + public void useConfig() throws Exception { + + new MockUnit(Binder.class) + .expect(guice) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(routes) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(internalOnStart(false)) + .expect(err) + .expect(unit -> { + AnnotatedBindingBuilder> listAnnotatedBinding = unit + .mock(AnnotatedBindingBuilder.class); + listAnnotatedBinding.toInstance(Arrays.asList(1, 2, 3)); + + Binder binder = unit.get(Binder.class); + Key> key = (Key>) Key.get(Types.listOf(Integer.class), + Names.named("list")); + expect(binder.bind(key)).andReturn(listAnnotatedBinding); + }) + .run(unit -> { + + Jooby jooby = new Jooby(); + + jooby.use(ConfigFactory.parseResources(getClass(), "JoobyTest.conf")); + + jooby.start(); + + }, boot); + } + + @Test + public void customConf() throws Exception { + + new MockUnit(Binder.class) + .expect(guice) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(routes) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(internalOnStart(false)) + .expect(err) + .run(unit -> { + + Jooby jooby = new Jooby(); + + jooby.conf("JoobyTest.conf"); + + jooby.start(); + + }, boot); + } + + @Test + public void customConfFile() throws Exception { + + new MockUnit(Binder.class) + .expect(guice) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(classInfo) + .expect(ssl) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(routes) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(internalOnStart(false)) + .expect(err) + .run(unit -> { + + Jooby jooby = new Jooby(); + + jooby.conf(new File("JoobyTest.conf")); + + jooby.start(); + + }, boot); + } + + @Test + public void useMissingConfig() throws Exception { + + new MockUnit(Binder.class) + .expect(guice) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(ssl) + .expect(classInfo) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(routes) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(internalOnStart(false)) + .expect(err) + .run(unit -> { + + Jooby jooby = new Jooby(); + + jooby.use(ConfigFactory.parseResources("missing.conf")); + + jooby.start(); + + }, boot); + } + + @Test + public void useErr() throws Exception { + + new MockUnit(Binder.class, Err.Handler.class) + .expect(guice) + .expect(shutdown) + .expect(config) + .expect(env) + .expect(ssl) + .expect(classInfo) + .expect(charset) + .expect(locale) + .expect(zoneId) + .expect(timeZone) + .expect(dateTimeFormatter) + .expect(numberFormat) + .expect(decimalFormat) + .expect(renderers) + .expect(session) + .expect(routes) + .expect(routeHandler) + .expect(params) + .expect(requestScope) + .expect(webSockets) + .expect(tmpdir) + .expect(internalOnStart(false)) + .expect(unit -> { + Binder binder = unit.get(Binder.class); + + LinkedBindingBuilder ehlbb = unit.mock(LinkedBindingBuilder.class); + ehlbb.toInstance(unit.get(Err.Handler.class)); + + LinkedBindingBuilder dehlbb = unit.mock(LinkedBindingBuilder.class); + dehlbb.toInstance(isA(Err.DefHandler.class)); + + Multibinder multibinder = unit.mock(Multibinder.class); + expect(Multibinder.newSetBinder(binder, Err.Handler.class)).andReturn(multibinder); + + expect(multibinder.addBinding()).andReturn(ehlbb); + expect(multibinder.addBinding()).andReturn(dehlbb); + }) + .run(unit -> { + + Jooby jooby = new Jooby(); + + jooby.err(unit.get(Err.Handler.class)); + + jooby.start(); + + }, boot); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/LogbackConfTest.java b/jooby/src/test/java-excluded/org/jooby/LogbackConfTest.java new file mode 100644 index 00000000..abb97d9f --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/LogbackConfTest.java @@ -0,0 +1,201 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static org.easymock.EasyMock.expect; +import static org.junit.Assert.assertEquals; + +import java.io.File; + +import org.jooby.test.MockUnit; +import org.jooby.test.MockUnit.Block; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +import com.typesafe.config.Config; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({Jooby.class, File.class }) +public class LogbackConfTest { + + @Test + public void withConfigFile() throws Exception { + new MockUnit(Config.class) + .expect(conflog(true)) + .expect(unit -> { + Config config = unit.get(Config.class); + expect(config.getString("logback.configurationFile")).andReturn("logback.xml"); + }) + .run(unit -> { + assertEquals("logback.xml", Jooby.logback(unit.get(Config.class))); + }); + } + + @Test + public void rootFile() throws Exception { + new MockUnit(Config.class) + .expect(conflog(false)) + .expect(env(null)) + .expect(unit -> { + File dir = unit.constructor(File.class) + .args(String.class) + .build(System.getProperty("user.dir")); + + File conf = unit.constructor(File.class) + .args(File.class, String.class) + .build(dir, "conf"); + + File rlogback = unit.constructor(File.class) + .args(File.class, String.class) + .build(dir, "logback.xml"); + expect(rlogback.exists()).andReturn(false); + + File clogback = unit.constructor(File.class) + .args(File.class, String.class) + .build(conf, "logback.xml"); + expect(clogback.exists()).andReturn(false); + }) + .run(unit -> { + assertEquals("logback.xml", Jooby.logback(unit.get(Config.class))); + }); + } + + @Test + public void rootFileFound() throws Exception { + new MockUnit(Config.class) + .expect(conflog(false)) + .expect(env(null)) + .expect(unit -> { + File dir = unit.constructor(File.class) + .args(String.class) + .build(System.getProperty("user.dir")); + + File conf = unit.constructor(File.class) + .args(File.class, String.class) + .build(dir, "conf"); + + File rlogback = unit.constructor(File.class) + .args(File.class, String.class) + .build(dir, "logback.xml"); + expect(rlogback.exists()).andReturn(true); + expect(rlogback.getAbsolutePath()).andReturn("foo/logback.xml"); + + unit.constructor(File.class) + .args(File.class, String.class) + .build(conf, "logback.xml"); + }) + .run(unit -> { + assertEquals("foo/logback.xml", Jooby.logback(unit.get(Config.class))); + }); + } + + @Test + public void confFile() throws Exception { + new MockUnit(Config.class) + .expect(conflog(false)) + .expect(env("foo")) + .expect(unit -> { + File dir = unit.constructor(File.class) + .args(String.class) + .build(System.getProperty("user.dir")); + + File conf = unit.constructor(File.class) + .args(File.class, String.class) + .build(dir, "conf"); + + File relogback = unit.constructor(File.class) + .args(File.class, String.class) + .build(dir, "logback.foo.xml"); + expect(relogback.exists()).andReturn(false); + + File rlogback = unit.constructor(File.class) + .args(File.class, String.class) + .build(dir, "logback.xml"); + expect(rlogback.exists()).andReturn(false); + + File clogback = unit.constructor(File.class) + .args(File.class, String.class) + .build(conf, "logback.xml"); + expect(clogback.exists()).andReturn(false); + + File celogback = unit.constructor(File.class) + .args(File.class, String.class) + .build(conf, "logback.foo.xml"); + expect(celogback.exists()).andReturn(false); + }) + .run(unit -> { + assertEquals("logback.xml", Jooby.logback(unit.get(Config.class))); + }); + } + + @Test + public void confFileFound() throws Exception { + new MockUnit(Config.class) + .expect(conflog(false)) + .expect(env("foo")) + .expect(unit -> { + File dir = unit.constructor(File.class) + .args(String.class) + .build(System.getProperty("user.dir")); + + File conf = unit.constructor(File.class) + .args(File.class, String.class) + .build(dir, "conf"); + + File relogback = unit.constructor(File.class) + .args(File.class, String.class) + .build(dir, "logback.foo.xml"); + expect(relogback.exists()).andReturn(false); + + unit.constructor(File.class) + .args(File.class, String.class) + .build(dir, "logback.xml"); + + File celogback = unit.constructor(File.class) + .args(File.class, String.class) + .build(conf, "logback.foo.xml"); + expect(celogback.exists()).andReturn(true); + expect(celogback.getAbsolutePath()).andReturn("logback.foo.xml"); + + unit.constructor(File.class) + .args(File.class, String.class) + .build(conf, "logback.xml"); + }) + .run(unit -> { + assertEquals("logback.foo.xml", Jooby.logback(unit.get(Config.class))); + }); + } + + private Block env(final String env) { + return unit -> { + Config config = unit.get(Config.class); + expect(config.hasPath("application.env")).andReturn(env != null); + if (env != null) { + expect(config.getString("application.env")).andReturn(env); + } + }; + } + + private Block conflog(final boolean b) { + return unit -> { + Config config = unit.get(Config.class); + expect(config.hasPath("logback.configurationFile")).andReturn(b); + }; + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/RequestForwardingTest.java b/jooby/src/test/java-excluded/org/jooby/RequestForwardingTest.java new file mode 100644 index 00000000..0bdb1dfc --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/RequestForwardingTest.java @@ -0,0 +1,905 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static org.easymock.EasyMock.expect; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.function.BiFunction; + +import org.jooby.Request.Forwarding; +import org.jooby.test.MockUnit; +import org.junit.Test; + +import com.google.common.base.Charsets; +import com.google.common.collect.ImmutableMap; +import com.google.inject.Key; +import com.google.inject.TypeLiteral; + +public class RequestForwardingTest { + + @Test + public void unwrap() throws Exception { + new MockUnit(Request.class) + .run(unit -> { + Request req = unit.get(Request.class); + + assertEquals(req, Request.Forwarding.unwrap(new Request.Forwarding(req))); + + // 2 level + assertEquals(req, + Request.Forwarding.unwrap(new Request.Forwarding(new Request.Forwarding(req)))); + + // 3 level + assertEquals(req, Request.Forwarding.unwrap(new Request.Forwarding(new Request.Forwarding( + new Request.Forwarding(req))))); + + }); + } + + @Test + public void path() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.path()).andReturn("/path"); + }) + .run(unit -> { + assertEquals("/path", new Request.Forwarding(unit.get(Request.class)).path()); + }); + + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.path(true)).andReturn("/path"); + }) + .run(unit -> { + assertEquals("/path", new Request.Forwarding(unit.get(Request.class)).path(true)); + }); + } + + @Test + public void rawPath() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.rawPath()).andReturn("/path"); + }) + .run(unit -> { + assertEquals("/path", new Request.Forwarding(unit.get(Request.class)).rawPath()); + }); + } + + @Test + public void port() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.port()).andReturn(80); + }) + .run(unit -> { + assertEquals(80, new Request.Forwarding(unit.get(Request.class)).port()); + }); + } + + @Test + public void matches() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.matches("/x")).andReturn(true); + }) + .run(unit -> { + assertEquals(true, new Request.Forwarding(unit.get(Request.class)).matches("/x")); + }); + } + + @Test + public void cpath() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.contextPath()).andReturn(""); + }) + .run(unit -> { + assertEquals("", new Request.Forwarding(unit.get(Request.class)).contextPath()); + }); + } + + @Test + public void verb() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.method()).andReturn("HEAD"); + }) + .run(unit -> { + assertEquals("HEAD", new Request.Forwarding(unit.get(Request.class)).method()); + }); + } + + @Test + public void queryString() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.queryString()).andReturn(Optional.empty()); + }) + .run(unit -> { + assertEquals(Optional.empty(), + new Request.Forwarding(unit.get(Request.class)).queryString()); + }); + } + + @Test + public void type() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.type()).andReturn(MediaType.json); + }) + .run(unit -> { + assertEquals(MediaType.json, new Request.Forwarding(unit.get(Request.class)).type()); + }); + } + + @Test + public void accept() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.accept()).andReturn(MediaType.ALL); + + expect(req.accepts(MediaType.ALL)).andReturn(Optional.empty()); + + expect(req.accepts(MediaType.json, MediaType.js)).andReturn(Optional.empty()); + + expect(req.accepts("json", "js")).andReturn(Optional.empty()); + }) + .run( + unit -> { + assertEquals(MediaType.ALL, new Request.Forwarding(unit.get(Request.class)).accept()); + + assertEquals(Optional.empty(), + new Request.Forwarding(unit.get(Request.class)).accepts(MediaType.ALL)); + + assertEquals(Optional.empty(), + new Request.Forwarding(unit.get(Request.class)).accepts(MediaType.json, + MediaType.js)); + + assertEquals(Optional.empty(), + new Request.Forwarding(unit.get(Request.class)).accepts("json", "js")); + }); + } + + @Test + public void is() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + + expect(req.is(MediaType.ALL)).andReturn(true); + + expect(req.is(MediaType.json, MediaType.js)).andReturn(true); + + expect(req.is("json", "js")).andReturn(true); + }) + .run(unit -> { + assertEquals(true, + new Request.Forwarding(unit.get(Request.class)).is(MediaType.ALL)); + + assertEquals(true, + new Request.Forwarding(unit.get(Request.class)).is(MediaType.json, MediaType.js)); + + assertEquals(true, + new Request.Forwarding(unit.get(Request.class)).is("json", "js")); + }); + } + + @Test + public void isSet() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + + expect(req.isSet("x")).andReturn(true); + }) + .run(unit -> { + assertEquals(true, + new Request.Forwarding(unit.get(Request.class)).isSet("x")); + }); + } + + @Test + public void params() throws Exception { + new MockUnit(Request.class, Mutant.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.params()).andReturn(unit.get(Mutant.class)); + }) + .run(unit -> { + assertEquals(unit.get(Mutant.class), + new Request.Forwarding(unit.get(Request.class)).params()); + }); + + new MockUnit(Request.class, Mutant.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.params("xss")).andReturn(unit.get(Mutant.class)); + }) + .run(unit -> { + assertEquals(unit.get(Mutant.class), + new Request.Forwarding(unit.get(Request.class)).params("xss")); + }); + } + + @Test + public void beanParam() throws Exception { + Object bean = new Object(); + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + Mutant params = unit.mock(Mutant.class); + expect(params.to(Object.class)).andReturn(bean); + expect(params.to(TypeLiteral.get(Object.class))).andReturn(bean); + + expect(req.params()).andReturn(params).times(2); + }) + .run( + unit -> { + assertEquals(bean, + new Request.Forwarding(unit.get(Request.class)).params().to(Object.class)); + + assertEquals( + bean, + new Request.Forwarding(unit.get(Request.class)).params().to( + TypeLiteral.get(Object.class))); + }); + } + + @Test + public void param() throws Exception { + new MockUnit(Request.class, Mutant.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.param("p")).andReturn(unit.get(Mutant.class)); + }) + .run(unit -> { + assertEquals(unit.get(Mutant.class), + new Request.Forwarding(unit.get(Request.class)).param("p")); + }); + + new MockUnit(Request.class, Mutant.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.param("p", "xss")).andReturn(unit.get(Mutant.class)); + }) + .run(unit -> { + assertEquals(unit.get(Mutant.class), + new Request.Forwarding(unit.get(Request.class)).param("p", "xss")); + }); + } + + @Test + public void header() throws Exception { + new MockUnit(Request.class, Mutant.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.header("h")).andReturn(unit.get(Mutant.class)); + }) + .run(unit -> { + assertEquals(unit.get(Mutant.class), + new Request.Forwarding(unit.get(Request.class)).header("h")); + }); + + new MockUnit(Request.class, Mutant.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.header("h", "xss")).andReturn(unit.get(Mutant.class)); + }) + .run(unit -> { + assertEquals(unit.get(Mutant.class), + new Request.Forwarding(unit.get(Request.class)).header("h", "xss")); + }); + } + + @Test + public void headers() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.headers()).andReturn(Collections.emptyMap()); + }) + .run(unit -> { + assertEquals(Collections.emptyMap(), + new Request.Forwarding(unit.get(Request.class)).headers()); + }); + } + + @Test + public void cookie() throws Exception { + new MockUnit(Request.class, Mutant.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.cookie("c")).andReturn(unit.get(Mutant.class)); + }) + .run(unit -> { + assertEquals(unit.get(Mutant.class), + new Request.Forwarding(unit.get(Request.class)).cookie("c")); + }); + } + + @Test + public void cookies() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.cookies()).andReturn(Collections.emptyList()); + }) + .run(unit -> { + assertEquals(Collections.emptyList(), + new Request.Forwarding(unit.get(Request.class)).cookies()); + }); + } + + @Test + public void body() throws Exception { + TypeLiteral typeLiteral = TypeLiteral.get(Object.class); + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + Mutant body = unit.mock(Mutant.class); + expect(body.to(typeLiteral)).andReturn(null); + expect(body.to(Object.class)).andReturn(null); + + expect(req.body()).andReturn(body).times(2); + }) + .run( + unit -> { + assertEquals(null, + new Request.Forwarding(unit.get(Request.class)).body().to(typeLiteral)); + + assertEquals(null, + new Request.Forwarding(unit.get(Request.class)).body().to(Object.class)); + }); + } + + @Test + public void getInstance() throws Exception { + Key key = Key.get(Object.class); + TypeLiteral typeLiteral = TypeLiteral.get(Object.class); + + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.require(key)).andReturn(null); + + expect(req.require(typeLiteral)).andReturn(null); + + expect(req.require(Object.class)).andReturn(null); + }) + .run( + unit -> { + assertEquals(null, new Request.Forwarding(unit.get(Request.class)).require(key)); + + assertEquals(null, + new Request.Forwarding(unit.get(Request.class)).require(typeLiteral)); + + assertEquals(null, + new Request.Forwarding(unit.get(Request.class)).require(Object.class)); + }); + } + + @Test + public void charset() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.charset()).andReturn(Charsets.UTF_8); + }) + .run(unit -> { + assertEquals(Charsets.UTF_8, new Request.Forwarding(unit.get(Request.class)).charset()); + }); + } + + @Test + public void file() throws Exception { + new MockUnit(Request.class, Upload.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.file("f")).andReturn(unit.get(Upload.class)); + }) + .run(unit -> { + assertEquals(unit.get(Upload.class), + new Request.Forwarding(unit.get(Request.class)).file("f")); + }); + } + + @SuppressWarnings("unchecked") + @Test + public void files() throws Exception { + new MockUnit(Request.class, List.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.files("f")).andReturn(unit.get(List.class)); + }) + .run(unit -> { + assertEquals(unit.get(List.class), + new Request.Forwarding(unit.get(Request.class)).files("f")); + }); + } + + @Test + public void length() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.length()).andReturn(10L); + }) + .run(unit -> { + assertEquals(10L, new Request.Forwarding(unit.get(Request.class)).length()); + }); + } + + @Test + public void locale() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.locale()).andReturn(Locale.getDefault()); + }) + .run( + unit -> { + assertEquals(Locale.getDefault(), + new Request.Forwarding(unit.get(Request.class)).locale()); + }); + } + + @Test + public void localeLookup() throws Exception { + BiFunction, List, Locale> lookup = Locale::lookup; + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.locale(lookup)).andReturn(Locale.getDefault()); + }) + .run( + unit -> { + assertEquals(Locale.getDefault(), + new Request.Forwarding(unit.get(Request.class)).locale(lookup)); + }); + } + + @Test + public void locales() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.locales()).andReturn(Arrays.asList(Locale.getDefault())); + }) + .run( + unit -> { + assertEquals(Arrays.asList(Locale.getDefault()), + new Request.Forwarding(unit.get(Request.class)).locales()); + }); + } + + @Test + public void localesFilter() throws Exception { + BiFunction, List, List> lookup = Locale::filter; + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.locales(lookup)).andReturn(Arrays.asList(Locale.getDefault())); + }) + .run(unit -> { + assertEquals(Arrays.asList(Locale.getDefault()), + new Request.Forwarding(unit.get(Request.class)).locales(lookup)); + }); + } + + @Test + public void ip() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.ip()).andReturn("127.0.0.1"); + }) + .run(unit -> { + assertEquals("127.0.0.1", new Request.Forwarding(unit.get(Request.class)).ip()); + }); + } + + @Test + public void route() throws Exception { + new MockUnit(Request.class, Route.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.route()).andReturn(unit.get(Route.class)); + }) + .run( + unit -> { + assertEquals(unit.get(Route.class), + new Request.Forwarding(unit.get(Request.class)).route()); + }); + } + + @Test + public void session() throws Exception { + new MockUnit(Request.class, Session.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.session()).andReturn(unit.get(Session.class)); + }) + .run( + unit -> { + assertEquals(unit.get(Session.class), + new Request.Forwarding(unit.get(Request.class)).session()); + }); + } + + @Test + public void ifSession() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.ifSession()).andReturn(Optional.empty()); + }) + .run( + unit -> { + assertEquals(Optional.empty(), + new Request.Forwarding(unit.get(Request.class)).ifSession()); + }); + } + + @Test + public void hostname() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.hostname()).andReturn("localhost"); + }) + .run(unit -> { + assertEquals("localhost", new Request.Forwarding(unit.get(Request.class)).hostname()); + }); + } + + @Test + public void protocol() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.protocol()).andReturn("https"); + }) + .run(unit -> { + assertEquals("https", new Request.Forwarding(unit.get(Request.class)).protocol()); + }); + } + + @Test + public void secure() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.secure()).andReturn(true); + }) + .run(unit -> { + assertEquals(true, new Request.Forwarding(unit.get(Request.class)).secure()); + }); + } + + @Test + public void xhr() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.xhr()).andReturn(true); + }) + .run(unit -> { + assertEquals(true, new Request.Forwarding(unit.get(Request.class)).xhr()); + }); + } + + @SuppressWarnings("unchecked") + @Test + public void attributes() throws Exception { + new MockUnit(Request.class, Map.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.attributes()).andReturn(unit.get(Map.class)); + }) + .run(unit -> { + assertEquals(unit.get(Map.class), + new Request.Forwarding(unit.get(Request.class)).attributes()); + }); + } + + @Test + public void ifGet() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.ifGet("name")).andReturn(Optional.of("value")); + }) + .run(unit -> { + assertEquals(Optional.of("value"), + new Request.Forwarding(unit.get(Request.class)).ifGet("name")); + }); + } + + @Test + public void get() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.get("name")).andReturn("value"); + }) + .run(unit -> { + assertEquals("value", + new Request.Forwarding(unit.get(Request.class)).get("name")); + }); + } + + @Test + public void push() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.push("/path")).andReturn(req); + }) + .run(unit -> { + Forwarding req = new Request.Forwarding(unit.get(Request.class)); + assertEquals(req, req.push("/path")); + }); + + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.push("/path", ImmutableMap.of("k", "v"))).andReturn(req); + }) + .run(unit -> { + Forwarding req = new Request.Forwarding(unit.get(Request.class)); + assertEquals(req, req.push("/path", ImmutableMap.of("k", "v"))); + }); + } + + @Test + public void getdef() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.get("name", "v")).andReturn("value"); + }) + .run(unit -> { + assertEquals("value", + new Request.Forwarding(unit.get(Request.class)).get("name", "v")); + }); + } + + @Test + public void set() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.set("name", "value")).andReturn(req); + }) + .run(unit -> { + assertNotEquals(unit.get(Request.class), + new Request.Forwarding(unit.get(Request.class)).set("name", "value")); + }); + } + + @Test + public void setWithKey() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.set(Key.get(String.class), "value")).andReturn(req); + }) + .run(unit -> { + assertNotEquals(unit.get(Request.class), + new Request.Forwarding(unit.get(Request.class)).set(Key.get(String.class), "value")); + }); + } + + @Test + public void setWithClass() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.set(String.class, "value")).andReturn(req); + }) + .run(unit -> { + assertNotEquals(unit.get(Request.class), + new Request.Forwarding(unit.get(Request.class)).set(String.class, "value")); + }); + } + + @Test + public void setWithTypeLiteral() throws Exception { + new MockUnit(Request.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.set(TypeLiteral.get(String.class), "value")).andReturn(req); + }) + .run( + unit -> { + assertNotEquals(unit.get(Request.class), + new Request.Forwarding(unit.get(Request.class)).set( + TypeLiteral.get(String.class), "value")); + }); + } + + @Test + public void unset() throws Exception { + new MockUnit(Request.class, Map.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.unset("name")).andReturn(Optional.empty()); + }) + .run(unit -> { + assertEquals(Optional.empty(), + new Request.Forwarding(unit.get(Request.class)).unset("name")); + }); + } + + @Test + public void timestamp() throws Exception { + new MockUnit(Request.class, Map.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.timestamp()).andReturn(1L); + }) + .run(unit -> { + assertEquals(1L, + new Request.Forwarding(unit.get(Request.class)).timestamp()); + }); + } + + @Test + public void flash() throws Exception { + new MockUnit(Request.class, Map.class, Request.Flash.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.flash()).andReturn(unit.get(Request.Flash.class)); + }) + .run(unit -> { + new Request.Forwarding(unit.get(Request.class)).flash(); + }); + } + + @Test + public void setFlashAttr() throws Exception { + new MockUnit(Request.class, Map.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.flash("foo", "bar")).andReturn(req); + }) + .run(unit -> { + assertNotEquals(unit.get(Request.class), + new Request.Forwarding(unit.get(Request.class)).flash("foo", "bar")); + }); + } + + @Test + public void getFlashAttr() throws Exception { + new MockUnit(Request.class, Map.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.flash("foo")).andReturn("bar"); + }) + .run(unit -> { + assertEquals("bar", + new Request.Forwarding(unit.get(Request.class)).flash("foo")); + }); + } + + @Test + public void getIfFlashAttr() throws Exception { + new MockUnit(Request.class, Map.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.ifFlash("foo")).andReturn(Optional.of("bar")); + }) + .run(unit -> { + assertEquals("bar", + new Request.Forwarding(unit.get(Request.class)).ifFlash("foo").get()); + }); + } + + @Test + public void toStringFwd() throws Exception { + new MockUnit(Request.class, Map.class) + .run(unit -> { + assertEquals(unit.get(Request.class).toString(), + new Request.Forwarding(unit.get(Request.class)).toString()); + }); + } + + @Test + public void form() throws Exception { + RequestForwardingTest v = new RequestForwardingTest(); + new MockUnit(Request.class, Map.class) + .expect(unit -> { + Request req = unit.get(Request.class); + Mutant params = unit.mock(Mutant.class); + expect(params.to(RequestForwardingTest.class)).andReturn(v); + + expect(req.params()).andReturn(params); + }) + .run( + unit -> { + assertEquals( + v, + new Request.Forwarding(unit.get(Request.class)).params().to( + RequestForwardingTest.class)); + }); + } + + @Test + public void bodyWithType() throws Exception { + RequestForwardingTest v = new RequestForwardingTest(); + new MockUnit(Request.class, Map.class) + .expect(unit -> { + Request req = unit.get(Request.class); + + expect(req.body(RequestForwardingTest.class)).andReturn(v); + }) + .run(unit -> { + assertEquals( + v, + new Request.Forwarding(unit.get(Request.class)).body( + RequestForwardingTest.class)); + }); + } + + @Test + public void paramsWithType() throws Exception { + RequestForwardingTest v = new RequestForwardingTest(); + new MockUnit(Request.class, Map.class) + .expect(unit -> { + Request req = unit.get(Request.class); + + expect(req.params(RequestForwardingTest.class)).andReturn(v); + }) + .run(unit -> { + assertEquals( + v, + new Request.Forwarding(unit.get(Request.class)).params( + RequestForwardingTest.class)); + }); + + new MockUnit(Request.class, Map.class) + .expect(unit -> { + Request req = unit.get(Request.class); + + expect(req.params(RequestForwardingTest.class, "xss")).andReturn(v); + }) + .run(unit -> { + assertEquals( + v, + new Request.Forwarding(unit.get(Request.class)).params( + RequestForwardingTest.class, "xss")); + }); + } +} diff --git a/jooby/src/test/java-excluded/org/jooby/RequestLoggerTest.java b/jooby/src/test/java-excluded/org/jooby/RequestLoggerTest.java new file mode 100644 index 00000000..e5658ba0 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/RequestLoggerTest.java @@ -0,0 +1,239 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static org.easymock.EasyMock.expect; +import static org.junit.Assert.assertEquals; + +import java.time.ZoneId; +import java.util.Locale; +import java.util.Optional; + +import org.jooby.test.MockUnit; +import org.jooby.test.MockUnit.Block; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({RequestLogger.class, System.class }) +public class RequestLoggerTest { + + @BeforeClass + public static void before() { + Locale.setDefault(Locale.US); + } + + private Block capture = unit -> { + Response rsp = unit.get(Response.class); + rsp.complete(unit.capture(Route.Complete.class)); + }; + + private Block onComplete = unit -> { + unit.captured(Route.Complete.class).iterator().next() + .handle(unit.get(Request.class), unit.get(Response.class), Optional.empty()); + }; + + @Test + public void basicUsage() throws Exception { + new MockUnit(Request.class, Response.class) + .expect(capture) + .expect(timestamp(1L)) + .expect(ip("127.0.0.1")) + .expect(method("GET")) + .expect(path("/")) + .expect(protocol("HTTP/1.1")) + .expect(status(Status.OK)) + .expect(len(345L)) + .run(unit -> { + new RequestLogger() + .dateFormatter(ZoneId.of("UTC")) + .handle(unit.get(Request.class), unit.get(Response.class)); + }, onComplete); + } + + @Test + public void latency() throws Exception { + new MockUnit(Request.class, Response.class) + .expect(capture) + .expect(timestamp(7L)) + .expect(ip("127.0.0.1")) + .expect(method("GET")) + .expect(path("/")) + .expect(protocol("HTTP/1.1")) + .expect(status(Status.OK)) + .expect(len(345L)) + .expect(unit -> { + unit.mockStatic(System.class); + expect(System.currentTimeMillis()).andReturn(10L); + }) + .run(unit -> { + new RequestLogger() + .dateFormatter(ZoneId.of("UTC")) + .latency() + .log(line -> assertEquals( + "127.0.0.1 - - [01/Jan/1970:00:00:00 +0000] \"GET / HTTP/1.1\" 200 345 3", line)) + .handle(unit.get(Request.class), unit.get(Response.class)); + }, onComplete); + } + + @Test + public void queryString() throws Exception { + new MockUnit(Request.class, Response.class) + .expect(capture) + .expect(timestamp(7L)) + .expect(ip("127.0.0.1")) + .expect(method("GET")) + .expect(path("/path")) + .expect(query("query=true")) + .expect(protocol("HTTP/1.1")) + .expect(status(Status.OK)) + .expect(len(345L)) + .run(unit -> { + new RequestLogger() + .dateFormatter(ZoneId.of("UTC")) + .queryString() + .log(line -> assertEquals( + "127.0.0.1 - - [01/Jan/1970:00:00:00 +0000] \"GET /path?query=true HTTP/1.1\" 200 345", line)) + .handle(unit.get(Request.class), unit.get(Response.class)); + }, onComplete); + } + + @Test + public void extended() throws Exception { + new MockUnit(Request.class, Response.class) + .expect(capture) + .expect(timestamp(7L)) + .expect(ip("127.0.0.1")) + .expect(method("GET")) + .expect(path("/")) + .expect(protocol("HTTP/1.1")) + .expect(status(Status.OK)) + .expect(len(345L)) + .expect(referer("/referer")) + .expect(userAgent("ugent")) + .run(unit -> { + new RequestLogger() + .dateFormatter(ZoneId.of("UTC")) + .extended() + .log(line -> assertEquals( + "127.0.0.1 - - [01/Jan/1970:00:00:00 +0000] \"GET / HTTP/1.1\" 200 345 \"/referer\" \"ugent\"", line)) + .handle(unit.get(Request.class), unit.get(Response.class)); + }, onComplete); + } + + private Block referer(final String referer) { + return unit -> { + Mutant mutant = unit.mock(Mutant.class); + expect(mutant.value("-")).andReturn(referer); + + Request req = unit.get(Request.class); + expect(req.header("Referer")).andReturn(mutant); + }; + } + + private Block userAgent(final String userAgent) { + return unit -> { + Mutant mutant = unit.mock(Mutant.class); + expect(mutant.value("-")).andReturn(userAgent); + + Request req = unit.get(Request.class); + expect(req.header("User-Agent")).andReturn(mutant); + }; + } + + @Test + public void customLog() throws Exception { + new MockUnit(Request.class, Response.class) + .expect(capture) + .expect(timestamp(1L)) + .expect(ip("127.0.0.1")) + .expect(method("GET")) + .expect(path("/")) + .expect(protocol("HTTP/1.1")) + .expect(status(Status.OK)) + .expect(len(345L)) + .run(unit -> { + new RequestLogger() + .dateFormatter(ZoneId.of("UTC")) + .log(line -> assertEquals( + "127.0.0.1 - - [01/Jan/1970:00:00:00 +0000] \"GET / HTTP/1.1\" 200 345", line)) + .handle(unit.get(Request.class), unit.get(Response.class)); + }, onComplete); + } + + private Block method(final String method) { + return unit -> { + Request req = unit.get(Request.class); + expect(req.method()).andReturn(method); + }; + } + + private Block path(final String path) { + return unit -> { + Request req = unit.get(Request.class); + expect(req.path()).andReturn(path); + }; + } + + private Block query(final String query) { + return unit -> { + Request req = unit.get(Request.class); + expect(req.queryString()).andReturn(Optional.of(query)); + }; + } + + private Block status(final Status status) { + return unit -> { + Response rsp = unit.get(Response.class); + expect(rsp.status()).andReturn(Optional.ofNullable(status)); + }; + } + + private Block len(final Long len) { + return unit -> { + Mutant mutant = unit.mock(Mutant.class); + expect(mutant.value("-")).andReturn(len.toString()); + + Response rsp = unit.get(Response.class); + expect(rsp.header("Content-Length")).andReturn(mutant); + }; + } + + private Block protocol(final String protocol) { + return unit -> { + Request req = unit.get(Request.class); + expect(req.protocol()).andReturn(protocol); + }; + } + + private Block timestamp(final long ts) { + return unit -> { + Request req = unit.get(Request.class); + expect(req.timestamp()).andReturn(ts); + }; + } + + private Block ip(final String ip) { + return unit -> { + Request req = unit.get(Request.class); + expect(req.ip()).andReturn(ip); + }; + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/RequestTest.java b/jooby/src/test/java-excluded/org/jooby/RequestTest.java new file mode 100644 index 00000000..0c94c4fb --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/RequestTest.java @@ -0,0 +1,446 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static org.easymock.EasyMock.expect; +import org.jooby.internal.handlers.FlashScopeHandler; +import static org.junit.Assert.assertEquals; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Locale.LanguageRange; +import java.util.Map; +import java.util.Optional; +import java.util.function.BiFunction; + +import org.jooby.test.MockUnit; +import org.junit.Test; + +import com.google.inject.Key; +import com.google.inject.TypeLiteral; + +import javax.annotation.Nonnull; + +public class RequestTest { + public class RequestMock implements Request { + + @Override + public MediaType type() { + throw new UnsupportedOperationException(); + } + + @Override + public String rawPath() { + throw new UnsupportedOperationException(); + } + + @Override + public Optional queryString() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean matches(final String pattern) { + throw new UnsupportedOperationException(); + } + + @Override + public List accept() { + throw new UnsupportedOperationException(); + } + + @Override + public String contextPath() { + throw new UnsupportedOperationException(); + } + + @Override + public Optional accepts(final List types) { + throw new UnsupportedOperationException(); + } + + @Override + public Mutant params() { + throw new UnsupportedOperationException(); + } + + @Override + public Mutant params(final String... xss) { + throw new UnsupportedOperationException(); + } + + @Override + public Mutant param(final String name) { + throw new UnsupportedOperationException(); + } + + @Override + public Mutant param(final String name, final String... xss) { + throw new UnsupportedOperationException(); + } + + @Override + public Mutant header(final String name) { + throw new UnsupportedOperationException(); + } + + @Override + public Mutant header(final String name, final String... xss) { + throw new UnsupportedOperationException(); + } + + @Override + public Map headers() { + throw new UnsupportedOperationException(); + } + + @Override + public Mutant cookie(final String name) { + throw new UnsupportedOperationException(); + } + + @Override + public List cookies() { + throw new UnsupportedOperationException(); + } + + @Override + public Mutant body() throws Exception { + throw new UnsupportedOperationException(); + } + + @Override + public T require(final Key key) { + throw new UnsupportedOperationException(); + } + + @Override + public Charset charset() { + throw new UnsupportedOperationException(); + } + + @Override + public Locale locale() { + throw new UnsupportedOperationException(); + } + + @Override + public List locales( + final BiFunction, List, List> filter) { + throw new UnsupportedOperationException(); + } + + @Override + public Locale locale(final BiFunction, List, Locale> filter) { + throw new UnsupportedOperationException(); + } + + @Override + public List locales() { + return Request.super.locales(); + } + + @Override + public long length() { + throw new UnsupportedOperationException(); + } + + @Override + public String ip() { + throw new UnsupportedOperationException(); + } + + @Override + public Route route() { + throw new UnsupportedOperationException(); + } + + @Override + public String hostname() { + throw new UnsupportedOperationException(); + } + + @Override + public Session session() { + throw new UnsupportedOperationException(); + } + + @Override + public Optional ifSession() { + throw new UnsupportedOperationException(); + } + + @Override + public String protocol() { + throw new UnsupportedOperationException(); + } + + @Override + public Request push(final String path, final Map headers) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean secure() { + throw new UnsupportedOperationException(); + } + + @Override + public Map attributes() { + throw new UnsupportedOperationException(); + } + + @Override + public Optional ifGet(final String name) { + throw new UnsupportedOperationException(); + } + + @Override + public Request set(final String name, final Object value) { + throw new UnsupportedOperationException(); + } + + @Override + public Request set(final Key key, final Object value) { + throw new UnsupportedOperationException(); + } + + @Override + public Request set(final Class type, final Object value) { + throw new UnsupportedOperationException(); + } + + @Override + public Request set(final TypeLiteral type, final Object value) { + throw new UnsupportedOperationException(); + } + + @Override + public Optional unset(final String name) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isSet(final String name) { + throw new UnsupportedOperationException(); + } + + @Override + public int port() { + throw new UnsupportedOperationException(); + } + + @Override + public long timestamp() { + throw new UnsupportedOperationException(); + } + + @Override + public List files(final String name) throws IOException { + throw new UnsupportedOperationException(); + } + + @Nonnull + @Override + public List files() throws IOException { + throw new UnsupportedOperationException(); + } + } + + @Test + public void accepts() throws Exception { + LinkedList dataList = new LinkedList<>(); + new RequestMock() { + @Override + public Optional accepts(final List types) { + dataList.addAll(types); + return null; + } + }.accepts(MediaType.json); + assertEquals(Arrays.asList(MediaType.json), dataList); + } + + @Test + public void acceptsStr() throws Exception { + LinkedList dataList = new LinkedList<>(); + new RequestMock() { + @Override + public Optional accepts(final List types) { + dataList.addAll(types); + return null; + } + }.accepts("json"); + assertEquals(Arrays.asList(MediaType.json), dataList); + } + + @Test + public void getInstance() throws Exception { + LinkedList dataList = new LinkedList<>(); + new RequestMock() { + @Override + public T require(final Key key) { + dataList.add(key); + return null; + } + }.require(Object.class); + assertEquals(Arrays.asList(Key.get(Object.class)), dataList); + } + + @Test + public void getTypeLiteralInstance() throws Exception { + LinkedList dataList = new LinkedList<>(); + new RequestMock() { + @Override + public T require(final Key key) { + dataList.add(key); + return null; + } + }.require(TypeLiteral.get(Object.class)); + assertEquals(Arrays.asList(Key.get(Object.class)), dataList); + } + + @Test + public void xhr() throws Exception { + new MockUnit(Mutant.class) + .expect(unit -> { + Mutant xRequestedWith = unit.get(Mutant.class); + expect(xRequestedWith.toOptional(String.class)).andReturn(Optional.of("XMLHttpRequest")); + + expect(xRequestedWith.toOptional(String.class)).andReturn(Optional.empty()); + }) + .run(unit -> { + assertEquals(true, new RequestMock() { + @Override + public Mutant header(final String name) { + assertEquals("X-Requested-With", name); + return unit.get(Mutant.class); + } + }.xhr()); + + assertEquals(false, new RequestMock() { + @Override + public Mutant header(final String name) { + assertEquals("X-Requested-With", name); + return unit.get(Mutant.class); + } + }.xhr()); + }); + } + + @Test + public void path() throws Exception { + new MockUnit(Route.class) + .expect(unit -> { + Route route = unit.get(Route.class); + expect(route.path()).andReturn("/path"); + }) + .run(unit -> { + assertEquals("/path", new RequestMock() { + @Override + public Route route() { + return unit.get(Route.class); + } + }.path()); + }); + } + + @Test + public void verb() throws Exception { + new MockUnit(Route.class) + .expect(unit -> { + Route route = unit.get(Route.class); + expect(route.method()).andReturn("PATCH"); + }) + .run(unit -> { + assertEquals("PATCH", new RequestMock() { + @Override + public Route route() { + return unit.get(Route.class); + } + }.method()); + }); + } + + @Test + public void locales() throws Exception { + new MockUnit(Route.class) + .run(unit -> { + assertEquals(null, new RequestMock() { + @Override + public List locales( + final BiFunction, List, List> filter) { + return null; + } + }.locales()); + }); + } + + @Test + public void setFlashAttr() throws Exception { + FlashScopeHandler.FlashMap flash = new FlashScopeHandler.FlashMap(new HashMap<>()); + new RequestMock() { + @Override + public Flash flash() { + return flash; + } + }.flash("foo", "bar"); + assertEquals("bar", flash.get("foo")); + } + + @Test + public void removeFlashAttr() throws Exception { + FlashScopeHandler.FlashMap flash = new FlashScopeHandler.FlashMap(new HashMap<>()); + flash.put("foo", "bar"); + new RequestMock() { + @Override + public Request.Flash flash() { + return flash; + } + }.flash("foo", null); + assertEquals(null, flash.get("foo")); + } + + @Test + public void getFlashAttr() throws Exception { + FlashScopeHandler.FlashMap flash = new FlashScopeHandler.FlashMap(new HashMap<>()); + flash.put("foo", "bar"); + RequestMock req = new RequestMock() { + @Override + public Request.Flash flash() { + return flash; + } + }; + assertEquals("bar", req.flash("foo")); + } + + @Test(expected = Err.class) + public void noSuchFlashAttr() throws Exception { + FlashScopeHandler.FlashMap flash = new FlashScopeHandler.FlashMap(new HashMap<>()); + RequestMock req = new RequestMock() { + @Override + public Request.Flash flash() { + return flash; + } + }; + req.flash("foo"); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/ResponseForwardingTest.java b/jooby/src/test/java-excluded/org/jooby/ResponseForwardingTest.java new file mode 100644 index 00000000..d43abcf1 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/ResponseForwardingTest.java @@ -0,0 +1,390 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static org.easymock.EasyMock.eq; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.isA; +import static org.junit.Assert.assertEquals; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.InputStream; +import java.util.Date; +import java.util.Optional; + +import org.jooby.test.MockUnit; +import org.junit.Test; + +import com.google.common.base.Charsets; +import com.google.common.collect.Lists; + +public class ResponseForwardingTest { + + @Test + public void unwrap() throws Exception { + new MockUnit(Response.class) + .run(unit -> { + Response rsp = unit.get(Response.class); + + assertEquals(rsp, Response.Forwarding.unwrap(new Response.Forwarding(rsp))); + + // 2 level + assertEquals(rsp, + Response.Forwarding.unwrap(new Response.Forwarding(new Response.Forwarding(rsp)))); + + // 3 level + assertEquals(rsp, + Response.Forwarding.unwrap(new Response.Forwarding(new Response.Forwarding( + new Response.Forwarding(rsp))))); + + }); + } + + @Test + public void type() throws Exception { + new MockUnit(Response.class) + .expect(unit -> { + Response rsp = unit.get(Response.class); + + expect(rsp.type()).andReturn(Optional.empty()); + + expect(rsp.type("json")).andReturn(rsp); + expect(rsp.type(MediaType.js)).andReturn(rsp); + }) + .run(unit -> { + Response rsp = new Response.Forwarding(unit.get(Response.class)); + + assertEquals(Optional.empty(), rsp.type()); + assertEquals(rsp, rsp.type("json")); + assertEquals(rsp, rsp.type(MediaType.js)); + }); + } + + @Test + public void header() throws Exception { + new MockUnit(Response.class, Mutant.class) + .expect(unit -> { + Response rsp = unit.get(Response.class); + expect(rsp.header("h")).andReturn(unit.get(Mutant.class)); + }) + .run(unit -> { + assertEquals(unit.get(Mutant.class), + new Response.Forwarding(unit.get(Response.class)).header("h")); + }); + } + + @Test + public void setheader() throws Exception { + Date now = new Date(); + new MockUnit(Response.class) + .expect(unit -> { + Response rsp = unit.get(Response.class); + expect(rsp.header("b", (byte) 1)).andReturn(null); + expect(rsp.header("c", 'c')).andReturn(null); + expect(rsp.header("s", "s")).andReturn(null); + expect(rsp.header("d", now)).andReturn(null); + expect(rsp.header("d", 3d)).andReturn(null); + expect(rsp.header("f", 4f)).andReturn(null); + expect(rsp.header("i", 8)).andReturn(null); + expect(rsp.header("l", 9l)).andReturn(null); + expect(rsp.header("s", (short) 2)).andReturn(null); + }) + .run(unit -> { + Response rsp = new Response.Forwarding(unit.get(Response.class)); + assertEquals(rsp, rsp.header("b", (byte) 1)); + assertEquals(rsp, rsp.header("c", 'c')); + assertEquals(rsp, rsp.header("s", "s")); + assertEquals(rsp, rsp.header("d", now)); + assertEquals(rsp, rsp.header("d", 3d)); + assertEquals(rsp, rsp.header("f", 4f)); + assertEquals(rsp, rsp.header("i", 8)); + assertEquals(rsp, rsp.header("l", 9l)); + assertEquals(rsp, rsp.header("s", (short) 2)); + }); + } + + @Test + public void cookie() throws Exception { + new MockUnit(Response.class, Cookie.class, Cookie.Definition.class) + .expect(unit -> { + Response rsp = unit.get(Response.class); + expect(rsp.cookie(unit.get(Cookie.class))).andReturn(null); + + expect(rsp.cookie(unit.get(Cookie.Definition.class))).andReturn(null); + + expect(rsp.cookie("name", "value")).andReturn(null); + }) + .run(unit -> { + Response rsp = new Response.Forwarding(unit.get(Response.class)); + assertEquals(rsp, rsp.cookie(unit.get(Cookie.class))); + assertEquals(rsp, rsp.cookie(unit.get(Cookie.Definition.class))); + assertEquals(rsp, rsp.cookie("name", "value")); + }); + } + + @Test + public void download() throws Exception { + File file = new File("file.ppt"); + new MockUnit(Response.class) + .expect(unit -> { + Response rsp = unit.get(Response.class); + + rsp.download(file); + rsp.download("alias", file); + + rsp.download("file.pdf"); + rsp.download("alias", "file.pdf"); + + rsp.download(eq("file.pdf"), isA(InputStream.class)); + + }) + .run(unit -> { + Response rsp = new Response.Forwarding(unit.get(Response.class)); + + rsp.download(file); + + rsp.download("alias", file); + + rsp.download("file.pdf"); + + rsp.download("alias", "file.pdf"); + + rsp.download("file.pdf", new ByteArrayInputStream(new byte[0])); + + }); + } + + @Test + public void charset() throws Exception { + new MockUnit(Response.class) + .expect(unit -> { + Response rsp = unit.get(Response.class); + expect(rsp.charset()).andReturn(Charsets.UTF_8); + + expect(rsp.charset(Charsets.US_ASCII)).andReturn(null); + }) + .run(unit -> { + Response rsp = new Response.Forwarding(unit.get(Response.class)); + assertEquals(Charsets.UTF_8, rsp.charset()); + + assertEquals(rsp, rsp.charset(Charsets.US_ASCII)); + }); + } + + @Test + public void clearCookie() throws Exception { + new MockUnit(Response.class) + .expect(unit -> { + Response rsp = unit.get(Response.class); + expect(rsp.clearCookie("cookie")).andReturn(null); + }) + .run(unit -> { + Response rsp = new Response.Forwarding(unit.get(Response.class)); + assertEquals(rsp, rsp.clearCookie("cookie")); + }); + } + + @Test + public void committed() throws Exception { + new MockUnit(Response.class) + .expect(unit -> { + Response rsp = unit.get(Response.class); + expect(rsp.committed()).andReturn(true); + }) + .run(unit -> { + Response rsp = new Response.Forwarding(unit.get(Response.class)); + assertEquals(true, rsp.committed()); + }); + } + + @Test + public void length() throws Exception { + new MockUnit(Response.class) + .expect(unit -> { + Response rsp = unit.get(Response.class); + expect(rsp.length(10)).andReturn(null); + }) + .run(unit -> { + Response rsp = new Response.Forwarding(unit.get(Response.class)); + assertEquals(rsp, rsp.length(10)); + }); + } + + @Test + public void redirect() throws Exception { + new MockUnit(Response.class) + .expect(unit -> { + Response rsp = unit.get(Response.class); + rsp.redirect("/location"); + + rsp.redirect(Status.MOVED_PERMANENTLY, "/location"); + }) + .run(unit -> { + Response rsp = new Response.Forwarding(unit.get(Response.class)); + rsp.redirect("/location"); + + rsp.redirect(Status.MOVED_PERMANENTLY, "/location"); + }); + } + + @Test + public void send() throws Exception { + Result body = Results.ok(); + Object obody = new Object(); + new MockUnit(Response.class) + .expect(unit -> { + Response rsp = unit.get(Response.class); + + expect(rsp.status()).andReturn(Optional.empty()); + expect(rsp.type()).andReturn(Optional.empty()); + }) + .expect(unit -> { + Response rsp = unit.get(Response.class); + + rsp.send(body); + + rsp.send(unit.capture(Result.class)); + }) + .run(unit -> { + Response rsp = new Response.Forwarding(unit.get(Response.class)); + + rsp.send(body); + + rsp.send(obody); + }); + } + + @Test + public void status() throws Exception { + new MockUnit(Response.class) + .expect(unit -> { + Response rsp = unit.get(Response.class); + + expect(rsp.status()).andReturn(Optional.empty()); + + expect(rsp.status(200)).andReturn(rsp); + expect(rsp.status(Status.BAD_REQUEST)).andReturn(rsp); + }) + .run(unit -> { + Response rsp = new Response.Forwarding(unit.get(Response.class)); + + assertEquals(Optional.empty(), rsp.status()); + assertEquals(rsp, rsp.status(200)); + assertEquals(rsp, rsp.status(Status.BAD_REQUEST)); + }); + } + + @Test + public void toStr() throws Exception { + + Response rsp = new Response.Forwarding(new ResponseTest.ResponseMock() { + @Override + public String toString() { + return "something something dark"; + } + }); + + assertEquals("something something dark", rsp.toString()); + } + + @Test + public void singleHeader() throws Exception { + new MockUnit(Response.class) + .expect(unit -> { + Response rsp = unit.get(Response.class); + + expect(rsp.header("h", "v")).andReturn(rsp); + }) + .run(unit -> { + Response rsp = new Response.Forwarding(unit.get(Response.class)); + + assertEquals(rsp, rsp.header("h", "v")); + }); + } + + @Test + public void arrayHeader() throws Exception { + new MockUnit(Response.class) + .expect(unit -> { + Response rsp = unit.get(Response.class); + + expect(rsp.header("h", "v1", 2)).andReturn(rsp); + }) + .run(unit -> { + Response rsp = new Response.Forwarding(unit.get(Response.class)); + + assertEquals(rsp, rsp.header("h", "v1", 2)); + }); + } + + @Test + public void listHeader() throws Exception { + new MockUnit(Response.class) + .expect(unit -> { + Response rsp = unit.get(Response.class); + + expect(rsp.header("h", Lists. newArrayList("v1", 2))).andReturn(rsp); + }) + .run(unit -> { + Response rsp = new Response.Forwarding(unit.get(Response.class)); + + assertEquals(rsp, rsp.header("h", Lists. newArrayList("v1", 2))); + }); + } + + @Test + public void end() throws Exception { + new MockUnit(Response.class) + .expect(unit -> { + Response rsp = unit.get(Response.class); + rsp.end(); + }) + .run(unit -> { + Response rsp = new Response.Forwarding(unit.get(Response.class)); + + rsp.end(); + }); + } + + @Test + public void pushAfter() throws Exception { + new MockUnit(Response.class, Route.After.class) + .expect(unit -> { + Response rsp = unit.get(Response.class); + rsp.after(unit.get(Route.After.class)); + }) + .run(unit -> { + Response rsp = new Response.Forwarding(unit.get(Response.class)); + + rsp.after(unit.get(Route.After.class)); + }); + } + + @Test + public void pushComplete() throws Exception { + new MockUnit(Response.class, Route.Complete.class) + .expect(unit -> { + Response rsp = unit.get(Response.class); + rsp.complete(unit.get(Route.Complete.class)); + }) + .run(unit -> { + Response rsp = new Response.Forwarding(unit.get(Response.class)); + rsp.complete(unit.get(Route.Complete.class)); + }); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/RouteDefinitionTest.java b/jooby/src/test/java-excluded/org/jooby/RouteDefinitionTest.java new file mode 100644 index 00000000..5856df2a --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/RouteDefinitionTest.java @@ -0,0 +1,374 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import com.google.common.collect.ImmutableMap; +import issues.RouteSourceLocation; +import org.jooby.Route.Definition; +import org.jooby.internal.RouteImpl; +import org.jooby.test.MockUnit; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +public class RouteDefinitionTest { + + enum HttpStatus { + OK + } + + @Test + public void newHandler() throws Exception { + new MockUnit(Request.class, Response.class, Route.Chain.class) + .expect(unit -> { + Response rsp = unit.get(Response.class); + rsp.send("x"); + }) + .run(unit -> { + Definition def = new Route.Definition("GET", "/", (req, rsp, chain) -> { + rsp.send("x"); + }); + + RouteImpl route = (RouteImpl) (def.matches("GET", "/", MediaType.all, + MediaType.ALL)).get(); + + route.handle(unit.get(Request.class), unit.get(Response.class), + unit.get(Route.Chain.class)); + }); + } + + @Test + public void newOneArgHandler() throws Exception { + new MockUnit(Request.class, Response.class, Route.Chain.class) + .expect(unit -> { + Request req = unit.get(Request.class); + + Response rsp = unit.get(Response.class); + rsp.send("x"); + + Route.Chain chain = unit.get(Route.Chain.class); + + chain.next(req, rsp); + }) + .run(unit -> { + Definition def = new Route.Definition("GET", "/", (req) -> { + return "x"; + }); + + RouteImpl route = (RouteImpl) (def.matches("GET", "/", MediaType.all, + MediaType.ALL)).get(); + + route.handle(unit.get(Request.class), unit.get(Response.class), + unit.get(Route.Chain.class)); + }); + } + + @Test + public void newZeroArgHandler() throws Exception { + new MockUnit(Request.class, Response.class, Route.Chain.class) + .expect(unit -> { + Request req = unit.get(Request.class); + + Response rsp = unit.get(Response.class); + rsp.send("x"); + + Route.Chain chain = unit.get(Route.Chain.class); + + chain.next(req, rsp); + }) + .run(unit -> { + Definition def = new Route.Definition("GET", "/", () -> { + return "x"; + }); + + RouteImpl route = (RouteImpl) (def.matches("GET", "/", MediaType.all, + MediaType.ALL)).get(); + + route.handle(unit.get(Request.class), unit.get(Response.class), + unit.get(Route.Chain.class)); + }); + } + + @Test + public void newFilter() throws Exception { + new MockUnit(Request.class, Response.class, Route.Chain.class) + .expect(unit -> { + Response rsp = unit.get(Response.class); + rsp.send("x"); + + }) + .run(unit -> { + Definition def = new Route.Definition("GET", "/", (req, rsp, chain) -> { + rsp.send("x"); + }); + + RouteImpl route = (RouteImpl) (def.matches("GET", "/", MediaType.all, + MediaType.ALL)).get(); + + route.handle(unit.get(Request.class), unit.get(Response.class), + unit.get(Route.Chain.class)); + }); + } + + @Test + public void toStr() throws Exception { + new MockUnit(Request.class, Response.class, Route.Chain.class) + .run(unit -> { + Definition def = new Route.Definition("GET", "/", (req, rsp, chain) -> { + }).excludes("/**/logout"); + + assertEquals("GET /\n" + + " name: /anonymous\n" + + " excludes: [/**/logout]\n" + + " consumes: [*/*]\n" + + " produces: [*/*]\n", def.toString()); + }); + } + + @Test + public void attributes() throws Exception { + new MockUnit(Request.class, Response.class, Route.Chain.class) + .run(unit -> { + Definition def = new Route.Definition("GET", "/", (req, rsp, chain) -> { + }).attr("foo", "bar"); + + assertEquals("bar", def.attr("foo")); + assertEquals("{foo=bar}", def.attributes().toString()); + }); + } + + @Test + public void rendererAttr() throws Exception { + new MockUnit(Request.class, Response.class, Route.Chain.class) + .run(unit -> { + Definition def = new Route.Definition("GET", "/", (req, rsp, chain) -> { + }).renderer("json"); + + assertEquals("json", def.renderer()); + assertEquals("{}", def.attributes().toString()); + }); + } + + @Test(expected = NullPointerException.class) + public void nullVerb() throws Exception { + new Route.Definition(null, "/", (req, rsp, chain) -> { + }); + } + + @Test + public void noMatches() throws Exception { + Optional matches = new Route.Definition("delete", "/", (req, rsp, chain) -> { + }).matches("POST", "/", MediaType.all, MediaType.ALL); + assertEquals(Optional.empty(), matches); + } + + @Test + public void chooseMostSpecific() throws Exception { + Optional matches = new Route.Definition("GET", "/", (req, rsp, chain) -> { + }).matches("GET", "/", MediaType.all, Arrays.asList(MediaType.json)); + assertEquals(true, matches.isPresent()); + } + + @Test + public void consumesMany() throws Exception { + Route.Definition def = new Route.Definition("GET", "/", (req, rsp, chain) -> { + }).consumes("text/*", "json") + .produces("json"); + assertEquals(MediaType.json, def.consumes().get(0)); + assertEquals(MediaType.valueOf("text/*"), def.consumes().get(1)); + + assertEquals(true, def.matches("GET", "/", MediaType.all, MediaType.ALL) + .isPresent()); + assertEquals(true, def.matches("GET", "/", MediaType.json, MediaType.ALL) + .isPresent()); + assertEquals(false, def.matches("GET", "/", MediaType.xml, MediaType.ALL) + .isPresent()); + assertEquals(false, + def.matches("GET", "/", MediaType.json, Arrays.asList(MediaType.html)) + .isPresent()); + } + + @Test(expected = IllegalArgumentException.class) + public void consumesEmpty() throws Exception { + new Route.Definition("GET", "/", (req, rsp, chain) -> { + }).consumes(Collections.emptyList()); + } + + @Test(expected = IllegalArgumentException.class) + public void consumesNull() throws Exception { + new Route.Definition("GET", "/", (req, rsp, chain) -> { + }).consumes((List) null); + } + + @Test + public void consumesOne() throws Exception { + Route.Definition def = new Route.Definition("GET", "/", (req, rsp, chain) -> { + }).consumes("json"); + assertEquals(MediaType.json, def.consumes().get(0)); + } + + @Test + public void canConsume() throws Exception { + Route.Definition def = new Route.Definition("GET", "/", (req, rsp, chain) -> { + }).consumes("json"); + assertEquals(true, def.canConsume("json")); + assertEquals(false, def.canConsume("html")); + assertEquals(true, def.canConsume(MediaType.json)); + assertEquals(false, def.canConsume(MediaType.html)); + } + + @Test + public void producesMany() throws Exception { + Route.Definition def = new Route.Definition("GET", "/", (req, rsp, chain) -> { + }).produces("text/*", "json"); + assertEquals(MediaType.json, def.produces().get(0)); + assertEquals(MediaType.valueOf("text/*"), def.produces().get(1)); + + assertEquals(true, def.matches("GET", "/", MediaType.all, MediaType.ALL) + .isPresent()); + } + + @Test(expected = IllegalArgumentException.class) + public void producesEmpty() throws Exception { + new Route.Definition("GET", "/", (req, rsp, chain) -> { + }).produces(Collections.emptyList()); + } + + @Test(expected = IllegalArgumentException.class) + public void producesNull() throws Exception { + new Route.Definition("GET", "/", (req, rsp, chain) -> { + }).produces((List) null); + } + + @Test(expected = IllegalArgumentException.class) + public void nullName() throws Exception { + new Route.Definition("GET", "/", (req, rsp, chain) -> { + }).name(null); + } + + @Test(expected = IllegalArgumentException.class) + public void emptyName() throws Exception { + new Route.Definition("GET", "/", (req, rsp, chain) -> { + }).name(""); + } + + @Test + public void producesOne() throws Exception { + Route.Definition def = new Route.Definition("GET", "/", (req, rsp, chain) -> { + }).produces("json"); + assertEquals(MediaType.json, def.produces().get(0)); + } + + @Test + public void canProduce() throws Exception { + Route.Definition def = new Route.Definition("GET", "/", (req, rsp, chain) -> { + }).produces("json", "html"); + assertEquals(true, def.canProduce("json")); + assertEquals(true, def.canProduce("html")); + assertEquals(true, def.canProduce(MediaType.json)); + assertEquals(true, def.canProduce(MediaType.html)); + assertEquals(false, def.canProduce("xml")); + } + + @Test + public void properties() throws Exception { + Route.Definition def = new Route.Definition("put", "/test/path", (req, rsp, chain) -> { + }) + .name("test") + .consumes(MediaType.json) + .produces(MediaType.json); + + assertEquals("/test", def.name()); + assertEquals("/test/path", def.pattern()); + assertEquals("PUT", def.method()); + assertEquals(MediaType.json, def.consumes().get(0)); + assertEquals(MediaType.json, def.produces().get(0)); + } + + @Test + public void reverse() throws Exception { + Function route = path -> new Route.Definition("*", path, () -> null); + assertEquals("/1", route.apply("/:id").reverse(1)); + + assertEquals("/cat/1", route.apply("/:type/:id").reverse("cat", 1)); + + assertEquals("/cat/5", route.apply("/{type}/{id}").reverse("cat", 5)); + + assertEquals("/ccat/1", + route.apply("/c{type}/{id}").reverse(ImmutableMap.of("type", "cat", "id", 1))); + + assertEquals("/cat/tom", route.apply("/cat/tom").reverse("cat", 1)); + } + + @Test + public void attrs() throws Exception { + Function route = path -> new Route.Definition("*", path, () -> null); + Route.Definition r = route.apply("/") + .attr("i", 7) + .attr("s", "string") + .attr("enum", HttpStatus.OK) + .attr("type", Route.class); + + assertEquals(Integer.valueOf(7), r.attr("i")); + assertEquals("string", r.attr("s")); + assertEquals(HttpStatus.OK, r.attr("enum")); + assertEquals(Route.class, r.attr("type")); + } + + @Test + public void src() throws Exception { + Route.Definition r = new RouteSourceLocation().route().apply("/"); + + assertEquals("issues.RouteSourceLocation:9", r.source().toString()); + } + + @Test + public void glob() throws Exception { + Function route = path -> new Route.Definition("*", path, () -> null); + + assertEquals(false, route.apply("/").glob()); + assertEquals(false, route.apply("/static").glob()); + assertEquals(true, route.apply("/t?st").glob()); + assertEquals(true, route.apply("/*/id").glob()); + assertEquals(true, route.apply("*").glob()); + assertEquals(true, route.apply("/public/**").glob()); + } + + @Test + public void attrsArray() throws Exception { + Function route = path -> new Route.Definition("*", path, () -> null); + Route.Definition r = route.apply("/") + .attr("i", new int[]{7}); + + assertTrue(Arrays.equals(new int[]{7}, (int[]) r.attr("i"))); + } + + @Test + public void attrUnsupportedType() throws Exception { + Function route = path -> new Route.Definition("*", path, () -> null); + Route.Definition r = route.apply("/"); + r.attr("i", new Object()); + assertNull(r.attr("i")); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/RouteForwardingTest.java b/jooby/src/test/java-excluded/org/jooby/RouteForwardingTest.java new file mode 100644 index 00000000..b81626f2 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/RouteForwardingTest.java @@ -0,0 +1,259 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static org.easymock.EasyMock.expect; +import static org.junit.Assert.assertEquals; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.jooby.test.MockUnit; +import org.junit.Test; + +public class RouteForwardingTest { + + @Test + public void consumes() throws Exception { + List consumes = Arrays.asList(MediaType.js); + new MockUnit(Route.class) + .expect(unit -> { + Route route = unit.get(Route.class); + expect(route.consumes()).andReturn(consumes); + }) + .run(unit -> { + assertEquals(consumes, new Route.Forwarding(unit.get(Route.class)).consumes()); + }); + } + + @Test + public void produces() throws Exception { + List produces = Arrays.asList(MediaType.js); + new MockUnit(Route.class) + .expect(unit -> { + Route route = unit.get(Route.class); + expect(route.produces()).andReturn(produces); + }) + .run(unit -> { + assertEquals(produces, new Route.Forwarding(unit.get(Route.class)).produces()); + }); + } + + @Test + public void name() throws Exception { + new MockUnit(Route.class) + .expect(unit -> { + Route route = unit.get(Route.class); + expect(route.name()).andReturn("xXX"); + }) + .run(unit -> { + assertEquals("xXX", new Route.Forwarding(unit.get(Route.class)).name()); + }); + } + + @Test + public void path() throws Exception { + new MockUnit(Route.class) + .expect(unit -> { + Route route = unit.get(Route.class); + expect(route.path()).andReturn("/xXX"); + }) + .run(unit -> { + assertEquals("/xXX", new Route.Forwarding(unit.get(Route.class)).path()); + }); + } + + @Test + public void pattern() throws Exception { + new MockUnit(Route.class) + .expect(unit -> { + Route route = unit.get(Route.class); + expect(route.pattern()).andReturn("/**/*"); + }) + .run(unit -> { + assertEquals("/**/*", new Route.Forwarding(unit.get(Route.class)).pattern()); + }); + } + + @Test + public void attributes() throws Exception { + Map attributes = new HashMap<>(); + new MockUnit(Route.class) + .expect(unit -> { + Route route = unit.get(Route.class); + expect(route.attributes()).andReturn(attributes); + }) + .run(unit -> { + assertEquals(attributes, new Route.Forwarding(unit.get(Route.class)).attributes()); + }); + } + + @Test + public void attr() throws Exception { + new MockUnit(Route.class) + .expect(unit -> { + Route route = unit.get(Route.class); + expect(route.attr("foo")).andReturn("bar"); + }) + .run(unit -> { + assertEquals("bar", new Route.Forwarding(unit.get(Route.class)).attr("foo")); + }); + } + + @Test + public void renderer() throws Exception { + new MockUnit(Route.class) + .expect(unit -> { + Route route = unit.get(Route.class); + expect(route.renderer()).andReturn("text"); + }) + .run(unit -> { + assertEquals("text", new Route.Forwarding(unit.get(Route.class)).renderer()); + }); + } + + @Test + public void toStr() throws Exception { + new MockUnit(Route.class) + .run(unit -> { + assertEquals(unit.get(Route.class).toString(), + new Route.Forwarding(unit.get(Route.class)).toString()); + }); + } + + @Test + public void verb() throws Exception { + new MockUnit(Route.class) + .expect(unit -> { + Route route = unit.get(Route.class); + expect(route.method()).andReturn("OPTIONS"); + }) + .run(unit -> { + assertEquals("OPTIONS", new Route.Forwarding(unit.get(Route.class)).method()); + }); + } + + @Test + public void vars() throws Exception { + Map vars = new HashMap<>(); + new MockUnit(Route.class) + .expect(unit -> { + Route route = unit.get(Route.class); + expect(route.vars()).andReturn(vars); + }) + .run(unit -> { + assertEquals(vars, new Route.Forwarding(unit.get(Route.class)).vars()); + }); + } + + @Test + public void glob() throws Exception { + new MockUnit(Route.class) + .expect(unit -> { + Route route = unit.get(Route.class); + expect(route.glob()).andReturn(true); + }) + .run(unit -> { + assertEquals(true, new Route.Forwarding(unit.get(Route.class)).glob()); + }); + } + + @Test + public void reverseMap() throws Exception { + Map vars = new HashMap<>(); + new MockUnit(Route.class) + .expect(unit -> { + Route route = unit.get(Route.class); + expect(route.reverse(vars)).andReturn("/"); + }) + .run(unit -> { + assertEquals("/", new Route.Forwarding(unit.get(Route.class)).reverse(vars)); + }); + } + + @Test + public void reverseVars() throws Exception { + Object[] vars = {}; + new MockUnit(Route.class) + .expect(unit -> { + Route route = unit.get(Route.class); + expect(route.reverse(vars)).andReturn("/"); + }) + .run(unit -> { + assertEquals("/", new Route.Forwarding(unit.get(Route.class)).reverse(vars)); + }); + } + + @Test + public void source() throws Exception { + new MockUnit(Route.class, Route.Source.class) + .expect(unit -> { + Route route = unit.get(Route.class); + expect(route.source()).andReturn(unit.get(Route.Source.class)); + }) + .run(unit -> { + assertEquals(unit.get(Route.Source.class), + new Route.Forwarding(unit.get(Route.class)).source()); + }); + } + + @Test + public void print() throws Exception { + new MockUnit(Route.class, Route.Source.class) + .expect(unit -> { + Route route = unit.get(Route.class); + expect(route.print()).andReturn("x"); + }) + .run(unit -> { + assertEquals("x", + new Route.Forwarding(unit.get(Route.class)).print()); + }); + } + + @Test + public void printWithIndent() throws Exception { + new MockUnit(Route.class, Route.Source.class) + .expect(unit -> { + Route route = unit.get(Route.class); + expect(route.print(6)).andReturn("x"); + }) + .run(unit -> { + assertEquals("x", + new Route.Forwarding(unit.get(Route.class)).print(6)); + }); + } + + @Test + public void unwrap() throws Exception { + new MockUnit(Route.class) + .run(unit -> { + Route route = unit.get(Route.class); + + assertEquals(route, Route.Forwarding.unwrap(new Route.Forwarding(route))); + + // 2 level + assertEquals(route, + Route.Forwarding.unwrap(new Route.Forwarding(new Route.Forwarding(route)))); + + // 3 level + assertEquals(route, Route.Forwarding.unwrap(new Route.Forwarding(new Route.Forwarding( + new Route.Forwarding(route))))); + + }); + } +} diff --git a/jooby/src/test/java-excluded/org/jooby/SseTest.java b/jooby/src/test/java-excluded/org/jooby/SseTest.java new file mode 100644 index 00000000..36fc61aa --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/SseTest.java @@ -0,0 +1,551 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Sets; +import com.google.inject.Injector; +import com.google.inject.Key; +import com.google.inject.TypeLiteral; +import com.google.inject.name.Names; +import static org.easymock.EasyMock.eq; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.expectLastCall; +import static org.easymock.EasyMock.isA; +import org.jooby.internal.SseRenderer; +import org.jooby.test.MockUnit; +import org.jooby.test.MockUnit.Block; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +import java.io.IOException; +import java.nio.channels.ClosedChannelException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({Sse.class, Deferred.class, Executors.class, SseRenderer.class}) +public class SseTest { + + private Block handshake = unit -> { + Request request = unit.get(Request.class); + Injector injector = unit.get(Injector.class); + Route route = unit.get(Route.class); + Mutant lastEventId = unit.mock(Mutant.class); + + expect(route.produces()).andReturn(MediaType.ALL); + + expect(request.require(Injector.class)).andReturn(injector); + expect(request.route()).andReturn(route); + expect(request.attributes()).andReturn(ImmutableMap.of()); + expect(request.header("Last-Event-ID")).andReturn(lastEventId); + + expect(injector.getInstance(Renderer.KEY)).andReturn(Sets.newHashSet()); + }; + + private Block locale = unit -> { + Request req = unit.get(Request.class); + expect(req.locale()).andReturn(Locale.CANADA); + }; + + @Test + public void sseId() throws Exception { + Sse sse = new Sse() { + + @Override + protected void closeInternal() { + } + + @Override + protected CompletableFuture> send(final Optional id, + final byte[] data) { + return null; + } + + @Override + protected void handshake(final Runnable handler) throws Exception { + } + }; + assertNotNull(sse.id()); + UUID.fromString(sse.id()); + sse.close(); + } + + @Test + public void handshake() throws Exception { + new MockUnit(Request.class, Injector.class, Runnable.class, Route.class) + .expect(handshake) + .expect(locale) + .expect(unit -> { + Injector injector = unit.get(Injector.class); + expect(injector.getInstance(Key.get(Object.class))).andReturn(null).times(2); + expect(injector.getInstance(Key.get(TypeLiteral.get(Object.class)))).andReturn(null); + expect(injector.getInstance(Key.get(Object.class, Names.named("n")))).andReturn(null); + }) + .run(unit -> { + Sse sse = new Sse() { + + @Override + protected void closeInternal() { + } + + @Override + protected CompletableFuture> send(final Optional id, + final byte[] data) { + return null; + } + + @Override + protected void handshake(final Runnable handler) throws Exception { + } + }; + sse.handshake(unit.get(Request.class), unit.get(Runnable.class)); + sse.require(Object.class); + sse.require(Key.get(Object.class)); + sse.require(TypeLiteral.get(Object.class)); + sse.require("n", Object.class); + sse.close(); + }); + } + + @Test + public void ifCloseClosedChannel() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + new MockUnit() + .run(unit -> { + Sse sse = new Sse() { + + @Override + protected void closeInternal() { + latch.countDown(); + } + + @Override + protected CompletableFuture> send(final Optional id, + final byte[] data) { + return null; + } + + @Override + protected void handshake(final Runnable handler) throws Exception { + } + }; + sse.onClose(() -> sse.close()); + sse.ifClose(new ClosedChannelException()); + latch.await(); + }); + } + + @Test + public void ifCloseBrokenPipe() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + new MockUnit() + .run(unit -> { + Sse sse = new Sse() { + + @Override + protected void closeInternal() { + latch.countDown(); + } + + @Override + protected CompletableFuture> send(final Optional id, + final byte[] data) { + return null; + } + + @Override + protected void handshake(final Runnable handler) throws Exception { + } + }; + sse.onClose(() -> sse.close()); + sse.ifClose(new IOException("Broken pipe")); + latch.await(); + }); + } + + @SuppressWarnings("resource") + @Test + public void ifCloseErrorOnFireClose() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + new MockUnit() + .run(unit -> { + Sse sse = new Sse() { + + @Override + protected void closeInternal() { + latch.countDown(); + } + + @Override + protected CompletableFuture> send(final Optional id, + final byte[] data) { + return null; + } + + @Override + protected void handshake(final Runnable handler) throws Exception { + } + }; + sse.onClose(() -> { + throw new IllegalStateException("intentional err"); + }); + sse.ifClose(new IOException("Broken pipe")); + latch.await(); + }); + } + + @Test + public void ifCloseFailure() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + new MockUnit() + .run(unit -> { + Sse sse = new Sse() { + + @Override + protected void closeInternal() { + latch.countDown(); + } + + @Override + protected CompletableFuture> send(final Optional id, + final byte[] data) { + return null; + } + + @Override + protected void handshake(final Runnable handler) throws Exception { + } + }; + sse.onClose(() -> sse.close()); + sse.ifClose(new IOException("Broken pipe")); + latch.await(); + }); + } + + @Test(expected = IllegalStateException.class) + public void closeFailure() throws Exception { + new MockUnit() + .run(unit -> { + Sse sse = new Sse() { + + @Override + protected void closeInternal() { + throw new IllegalStateException("intentional err"); + } + + @Override + protected CompletableFuture> send(final Optional id, + final byte[] data) { + return null; + } + + @Override + protected void handshake(final Runnable handler) throws Exception { + } + }; + sse.close(); + }); + } + + @Test + public void ifCloseIgnoreIO() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + new MockUnit() + .run(unit -> { + Sse sse = new Sse() { + + @Override + protected void closeInternal() { + latch.countDown(); + } + + @Override + protected CompletableFuture> send(final Optional id, + final byte[] data) { + return null; + } + + @Override + protected void handshake(final Runnable handler) throws Exception { + } + }; + sse.onClose(() -> sse.close()); + sse.ifClose(new IOException("Ignored")); + assertEquals(1, latch.getCount()); + }); + } + + @Test + public void ifCloseIgnoreEx() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + new MockUnit() + .run(unit -> { + Sse sse = new Sse() { + + @Override + protected void closeInternal() { + latch.countDown(); + } + + @Override + protected CompletableFuture> send(final Optional id, + final byte[] data) { + return null; + } + + @Override + protected void handshake(final Runnable handler) throws Exception { + } + }; + sse.onClose(() -> sse.close()); + sse.ifClose(new IllegalArgumentException("Ignored")); + assertEquals(1, latch.getCount()); + }); + } + + @Test + public void sseHandlerSuccess() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + new MockUnit(Request.class, Response.class, Route.Chain.class, Sse.class) + .expect(unit -> { + Request req = unit.get(Request.class); + Sse sse = unit.get(Sse.class); + + sse.handshake(eq(unit.get(Request.class)), unit.capture(Runnable.class)); + + expect(req.require(Sse.class)).andReturn(sse); + expect(req.path()).andReturn("/sse"); + }) + .expect(unit -> { + Response rsp = unit.get(Response.class); + rsp.send(unit.capture(Deferred.class)); + }) + .run(unit -> { + Sse.Handler handler = (req, sse) -> { + latch.countDown(); + }; + handler.handle(unit.get(Request.class), unit.get(Response.class), + unit.get(Route.Chain.class)); + }, unit -> { + Deferred deferred = unit.captured(Deferred.class).iterator().next(); + deferred.handler(null, (value, ex) -> { + }); + + unit.captured(Runnable.class).iterator().next().run(); + + latch.await(); + }); + } + + @Test + public void sseHandlerFailure() throws Exception { + new MockUnit(Request.class, Response.class, Sse.class, Route.Chain.class) + .expect(unit -> { + Request req = unit.get(Request.class); + Sse sse = unit.get(Sse.class); + + sse.handshake(eq(unit.get(Request.class)), unit.capture(Runnable.class)); + + expect(req.require(Sse.class)).andReturn(sse); + expect(req.path()).andReturn("/sse"); + }) + .expect(unit -> { + Response rsp = unit.get(Response.class); + rsp.send(unit.capture(Deferred.class)); + }) + .run(unit -> { + Sse.Handler handler = (req, sse) -> { + throw new IllegalStateException("intentional err"); + }; + handler.handle(unit.get(Request.class), unit.get(Response.class), + unit.get(Route.Chain.class)); + }, unit -> { + Deferred deferred = unit.captured(Deferred.class).iterator().next(); + deferred.handler(null, (value, ex) -> { + }); + + unit.captured(Runnable.class).iterator().next().run(); + }); + } + + @Test + public void sseHandlerHandshakeFailure() throws Exception { + new MockUnit(Request.class, Response.class, Sse.class, Route.Chain.class) + .expect(unit -> { + Request req = unit.get(Request.class); + Sse sse = unit.get(Sse.class); + + sse.handshake(eq(unit.get(Request.class)), unit.capture(Runnable.class)); + expectLastCall().andThrow(new IllegalStateException("intentional error")); + + expect(req.require(Sse.class)).andReturn(sse); + expect(req.path()).andReturn("/sse"); + }) + .expect(unit -> { + Response rsp = unit.get(Response.class); + rsp.send(unit.capture(Deferred.class)); + }) + .run(unit -> { + Sse.Handler handler = (req, sse) -> { + }; + handler.handle(unit.get(Request.class), unit.get(Response.class), + unit.get(Route.Chain.class)); + }, unit -> { + Deferred deferred = unit.captured(Deferred.class).iterator().next(); + deferred.handler(null, (value, ex) -> { + }); + }); + } + + @Test + public void sseKeepAlive() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + new MockUnit() + .run(unit -> { + Sse sse = new Sse() { + + @Override + protected void closeInternal() { + } + + @Override + protected CompletableFuture> send(final Optional id, + final byte[] data) { + return CompletableFuture.completedFuture(id); + } + + @Override + public Sse keepAlive(final long millis) { + assertEquals(100, millis); + latch.countDown(); + return this; + } + + @Override + protected void handshake(final Runnable handler) throws Exception { + } + }; + + new Sse.KeepAlive(sse, 100).run(); + latch.await(); + }); + } + + @SuppressWarnings("resource") + @Test + public void renderFailure() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + Object data = new Object(); + new MockUnit(Request.class, Route.class, Injector.class, Runnable.class) + .expect(handshake) + .expect(locale) + .expect(unit -> { + SseRenderer renderer = unit.constructor(SseRenderer.class) + .args(List.class, List.class, Charset.class, Locale.class, Map.class) + .build(isA(List.class), isA(List.class), eq(StandardCharsets.UTF_8), + eq(Locale.CANADA), isA(Map.class)); + + expect(renderer.format(isA(Sse.Event.class))).andThrow(new IOException("failure")); + }) + .run(unit -> { + Sse sse = new Sse() { + + @Override + protected void closeInternal() { + } + + @Override + protected void fireCloseEvent() { + } + + @Override + protected CompletableFuture> send(final Optional id, + final byte[] data) { + CompletableFuture> promise = new CompletableFuture<>(); + promise.completeExceptionally(new IOException("intentional err")); + return promise; + } + + @Override + public Sse keepAlive(final long millis) { + return this; + } + + @Override + protected void handshake(final Runnable handler) throws Exception { + } + }; + sse.handshake(unit.get(Request.class), unit.get(Runnable.class)); + sse.event(data).type(MediaType.all).send() + .whenComplete((v, x) -> Optional.ofNullable(x).ifPresent(ex -> latch.countDown())); + latch.await(); + }); + } + + @Test + public void sseKeepAliveFailure() throws Exception { + CountDownLatch latch = new CountDownLatch(2); + new MockUnit() + .run(unit -> { + Sse sse = new Sse() { + + @Override + protected void closeInternal() { + latch.countDown(); + } + + @Override + protected void fireCloseEvent() { + latch.countDown(); + } + + @Override + protected CompletableFuture> send(final Optional id, + final byte[] data) { + CompletableFuture> promise = new CompletableFuture<>(); + promise.completeExceptionally(new IOException("intentional err")); + return promise; + } + + @Override + public Sse keepAlive(final long millis) { + return this; + } + + @Override + protected void handshake(final Runnable handler) throws Exception { + } + }; + + new Sse.KeepAlive(sse, 100).run(); + latch.await(); + }); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/WebSocketTest.java b/jooby/src/test/java-excluded/org/jooby/WebSocketTest.java new file mode 100644 index 00000000..fa1ba833 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/WebSocketTest.java @@ -0,0 +1,477 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import com.google.inject.Key; +import com.google.inject.TypeLiteral; +import static org.easymock.EasyMock.expect; +import org.jooby.WebSocket.CloseStatus; +import org.jooby.test.MockUnit; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nullable; +import java.util.LinkedList; +import java.util.Map; +import java.util.Optional; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({WebSocket.class, LoggerFactory.class }) +public class WebSocketTest { + + static class WebSocketMock implements WebSocket { + + @Override + public void close(final CloseStatus status) { + throw new UnsupportedOperationException(); + } + + @Override + public void resume() { + throw new UnsupportedOperationException(); + } + + @Override + public void pause() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isOpen() { + throw new UnsupportedOperationException(); + } + + @Override + public void terminate() throws Exception { + throw new UnsupportedOperationException(); + } + + @Override + public void send(final Object data, final SuccessCallback success, final OnError err) + throws Exception { + throw new UnsupportedOperationException(); + } + + @Override + public void broadcast(final Object data, final SuccessCallback success, final OnError err) + throws Exception { + throw new UnsupportedOperationException(); + } + + @Override + public void onMessage(final OnMessage callback) throws Exception { + throw new UnsupportedOperationException(); + } + + @Override + public String path() { + throw new UnsupportedOperationException(); + } + + @Override + public String pattern() { + throw new UnsupportedOperationException(); + } + + @Override + public Map vars() { + throw new UnsupportedOperationException(); + } + + @Override + public MediaType consumes() { + throw new UnsupportedOperationException(); + } + + @Override + public MediaType produces() { + throw new UnsupportedOperationException(); + } + + @Override + public T require(final Key key) { + throw new UnsupportedOperationException(); + } + + @Override + public String toString() { + throw new UnsupportedOperationException(); + } + + @Override + public void onError(final OnError callback) { + throw new UnsupportedOperationException(); + } + + @Override + public void onClose(final OnClose callback) throws Exception { + throw new UnsupportedOperationException(); + } + + @Override public T get(String name) { + throw new UnsupportedOperationException(); + } + + @Override public Optional ifGet(String name) { + throw new UnsupportedOperationException(); + } + + @Nullable @Override public WebSocket set(String name, Object value) { + throw new UnsupportedOperationException(); + } + + @Override public WebSocket unset() { + throw new UnsupportedOperationException(); + } + + @Override public Optional unset(String name) { + throw new UnsupportedOperationException(); + } + + @Override public Map attributes() { + throw new UnsupportedOperationException(); + } + } + + @Test + public void noopSuccess() throws Exception { + WebSocket.SUCCESS.invoke(); + } + + @Test + public void err() throws Exception { + Exception ex = new Exception(); + new MockUnit(Logger.class) + .expect(unit -> { + Logger log = unit.get(Logger.class); + log.error("error while sending data", ex); + + unit.mockStatic(LoggerFactory.class); + expect(LoggerFactory.getLogger(WebSocket.class)).andReturn(log); + }) + .run(unit -> { + WebSocket.ERR.onError(ex); + }); + } + + @Test(expected = IllegalArgumentException.class) + public void tooLowCode() throws Exception { + CloseStatus.of(200); + } + + @Test(expected = IllegalArgumentException.class) + public void tooHighCode() throws Exception { + CloseStatus.of(5001); + } + + @Test + public void closeStatus() throws Exception { + assertEquals(1000, WebSocket.NORMAL.code()); + assertEquals("Normal", WebSocket.NORMAL.reason()); + assertEquals("1000 (Normal)", WebSocket.NORMAL.toString()); + assertEquals("1000", WebSocket.CloseStatus.of(1000).toString()); + + assertEquals(1001, WebSocket.GOING_AWAY.code()); + assertEquals("Going away", WebSocket.GOING_AWAY.reason()); + + assertEquals(1002, WebSocket.PROTOCOL_ERROR.code()); + assertEquals("Protocol error", WebSocket.PROTOCOL_ERROR.reason()); + + assertEquals(1003, WebSocket.NOT_ACCEPTABLE.code()); + assertEquals("Not acceptable", WebSocket.NOT_ACCEPTABLE.reason()); + + assertEquals(1007, WebSocket.BAD_DATA.code()); + assertEquals("Bad data", WebSocket.BAD_DATA.reason()); + + assertEquals(1008, WebSocket.POLICY_VIOLATION.code()); + assertEquals("Policy violation", WebSocket.POLICY_VIOLATION.reason()); + + assertEquals(1009, WebSocket.TOO_BIG_TO_PROCESS.code()); + assertEquals("Too big to process", WebSocket.TOO_BIG_TO_PROCESS.reason()); + + assertEquals(1010, WebSocket.REQUIRED_EXTENSION.code()); + assertEquals("Required extension", WebSocket.REQUIRED_EXTENSION.reason()); + + assertEquals(1011, WebSocket.SERVER_ERROR.code()); + assertEquals("Server error", WebSocket.SERVER_ERROR.reason()); + + assertEquals(1012, WebSocket.SERVICE_RESTARTED.code()); + assertEquals("Service restarted", WebSocket.SERVICE_RESTARTED.reason()); + + assertEquals(1013, WebSocket.SERVICE_OVERLOAD.code()); + assertEquals("Service overload", WebSocket.SERVICE_OVERLOAD.reason()); + } + + @Test + public void closeCodeAndReason() throws Exception { + LinkedList statusList = new LinkedList<>(); + WebSocket ws = new WebSocketMock() { + @Override + public void close(final CloseStatus status) { + assertEquals(1004, status.code()); + assertEquals("My reason", status.reason()); + statusList.add(status); + } + }; + ws.close(1004, "My reason"); + assertTrue(statusList.size() > 0); + } + + @Test + public void closeStatusCode() throws Exception { + LinkedList statusList = new LinkedList<>(); + WebSocket ws = new WebSocketMock() { + @Override + public void close(final CloseStatus status) { + assertEquals(1007, status.code()); + assertEquals(null, status.reason()); + statusList.add(status); + } + }; + ws.close(1007); + assertTrue(statusList.size() > 0); + } + + @Test + public void close() throws Exception { + + LinkedList statusList = new LinkedList<>(); + WebSocket ws = new WebSocketMock() { + @Override + public void close(final CloseStatus status) { + assertEquals(1000, status.code()); + assertEquals("Normal", status.reason()); + statusList.add(status); + } + }; + ws.close(WebSocket.NORMAL); + assertTrue(statusList.size() > 0); + } + + @Test + public void closeDefault() throws Exception { + + LinkedList statusList = new LinkedList<>(); + WebSocket ws = new WebSocketMock() { + @Override + public void close(final CloseStatus status) { + assertEquals(1000, status.code()); + assertEquals("Normal", status.reason()); + statusList.add(status); + } + }; + ws.close(); + assertTrue(statusList.size() > 0); + } + + @SuppressWarnings("resource") + @Test + public void send() throws Exception { + Object data = new Object(); + WebSocket.SuccessCallback SUCCESS_ = WebSocket.SUCCESS; + WebSocket.OnError ERR_ = WebSocket.ERR; + LinkedList dataList = new LinkedList<>(); + WebSocket ws = new WebSocketMock() { + @Override + public void send(final Object data, final SuccessCallback success, final OnError err) + throws Exception { + dataList.add(data); + assertEquals(SUCCESS_, success); + assertEquals(ERR_, err); + } + }; + ws.send(data); + assertTrue(dataList.size() > 0); + assertEquals(data, dataList.getFirst()); + } + + @SuppressWarnings("resource") + @Test + public void broadcast() throws Exception { + Object data = new Object(); + WebSocket.SuccessCallback SUCCESS_ = WebSocket.SUCCESS; + WebSocket.OnError ERR_ = WebSocket.ERR; + LinkedList dataList = new LinkedList<>(); + WebSocket ws = new WebSocketMock() { + @Override + public void broadcast(final Object data, final SuccessCallback success, final OnError err) + throws Exception { + dataList.add(data); + assertEquals(SUCCESS_, success); + assertEquals(ERR_, err); + } + }; + ws.broadcast(data); + assertTrue(dataList.size() > 0); + assertEquals(data, dataList.getFirst()); + } + + @SuppressWarnings("resource") + @Test + public void sendCustomSuccess() throws Exception { + Object data = new Object(); + WebSocket.SuccessCallback SUCCESS_ = () -> { + }; + WebSocket.OnError ERR_ = WebSocket.ERR; + LinkedList dataList = new LinkedList<>(); + WebSocket ws = new WebSocketMock() { + @Override + public void send(final Object data, final SuccessCallback success, final OnError err) + throws Exception { + dataList.add(data); + assertEquals(SUCCESS_, success); + assertEquals(ERR_, err); + } + }; + ws.send(data, SUCCESS_); + assertTrue(dataList.size() > 0); + assertEquals(data, dataList.getFirst()); + } + + @SuppressWarnings("resource") + @Test + public void broadcastCustomSuccess() throws Exception { + Object data = new Object(); + WebSocket.SuccessCallback SUCCESS_ = () -> { + }; + WebSocket.OnError ERR_ = WebSocket.ERR; + LinkedList dataList = new LinkedList<>(); + WebSocket ws = new WebSocketMock() { + @Override + public void broadcast(final Object data, final SuccessCallback success, final OnError err) + throws Exception { + dataList.add(data); + assertEquals(SUCCESS_, success); + assertEquals(ERR_, err); + } + }; + ws.broadcast(data, SUCCESS_); + assertTrue(dataList.size() > 0); + assertEquals(data, dataList.getFirst()); + } + + @SuppressWarnings("resource") + @Test + public void sendCustomErr() throws Exception { + Object data = new Object(); + WebSocket.SuccessCallback SUCCESS_ = WebSocket.SUCCESS; + WebSocket.OnError ERR_ = (ex) -> { + }; + LinkedList dataList = new LinkedList<>(); + WebSocket ws = new WebSocketMock() { + @Override + public void send(final Object data, final SuccessCallback success, final OnError err) + throws Exception { + dataList.add(data); + assertEquals(SUCCESS_, success); + assertEquals(ERR_, err); + } + }; + ws.send(data, ERR_); + assertTrue(dataList.size() > 0); + assertEquals(data, dataList.getFirst()); + } + + @SuppressWarnings("resource") + @Test + public void broadcastCustomErr() throws Exception { + Object data = new Object(); + WebSocket.SuccessCallback SUCCESS_ = WebSocket.SUCCESS; + WebSocket.OnError ERR_ = (ex) -> { + }; + LinkedList dataList = new LinkedList<>(); + WebSocket ws = new WebSocketMock() { + @Override + public void broadcast(final Object data, final SuccessCallback success, final OnError err) + throws Exception { + dataList.add(data); + assertEquals(SUCCESS_, success); + assertEquals(ERR_, err); + } + }; + ws.broadcast(data, ERR_); + assertTrue(dataList.size() > 0); + assertEquals(data, dataList.getFirst()); + } + + @SuppressWarnings("resource") + @Test + public void sendCustomSuccessAndErr() throws Exception { + Object data = new Object(); + WebSocket.SuccessCallback SUCCESS_ = () -> { + }; + WebSocket.OnError ERR_ = (ex) -> { + }; + LinkedList dataList = new LinkedList<>(); + WebSocket ws = new WebSocketMock() { + @Override + public void send(final Object data, final SuccessCallback success, final OnError err) + throws Exception { + dataList.add(data); + assertEquals(SUCCESS_, success); + assertEquals(ERR_, err); + } + }; + ws.send(data, SUCCESS_, ERR_); + assertTrue(dataList.size() > 0); + assertEquals(data, dataList.getFirst()); + } + + @SuppressWarnings("resource") + @Test + public void broadcastCustomSuccessAndErr() throws Exception { + Object data = new Object(); + WebSocket.SuccessCallback SUCCESS_ = () -> { + }; + WebSocket.OnError ERR_ = (ex) -> { + }; + LinkedList dataList = new LinkedList<>(); + WebSocket ws = new WebSocketMock() { + @Override + public void broadcast(final Object data, final SuccessCallback success, final OnError err) + throws Exception { + dataList.add(data); + assertEquals(SUCCESS_, success); + assertEquals(ERR_, err); + } + }; + ws.broadcast(data, SUCCESS_, ERR_); + assertTrue(dataList.size() > 0); + assertEquals(data, dataList.getFirst()); + } + + @SuppressWarnings("resource") + @Test + public void getInstance() throws Exception { + Object instance = new Object(); + WebSocket ws = new WebSocketMock() { + @SuppressWarnings("unchecked") + @Override + public T require(final Key key) { + return (T) instance; + } + }; + assertEquals(instance, ws.require(WebSocket.class)); + assertEquals(instance, ws.require(TypeLiteral.get(String.class))); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/handlers/AssetHandlerTest.java b/jooby/src/test/java-excluded/org/jooby/handlers/AssetHandlerTest.java new file mode 100644 index 00000000..ea92ac55 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/handlers/AssetHandlerTest.java @@ -0,0 +1,129 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.handlers; + +import org.jooby.Route; +import org.jooby.test.MockUnit; +import org.jooby.test.MockUnit.Block; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +import java.io.File; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.easymock.EasyMock.expect; +import static org.junit.Assert.assertNotNull; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({AssetHandler.class, File.class, Paths.class, Files.class}) +public class AssetHandlerTest { + + @Test + public void customClassloader() throws Exception { + URI uri = Paths.get("src", "test", "resources", "org", "jooby").toUri(); + new MockUnit(ClassLoader.class) + .expect(publicDir(uri, "JoobyTest.js")) + .run(unit -> { + URL value = newHandler(unit, "/") + .resolve("JoobyTest.js"); + assertNotNull(value); + }); + } + + private AssetHandler newHandler(MockUnit unit, String location) { + AssetHandler handler = new AssetHandler(location, unit.get(ClassLoader.class)); + new Route.AssetDefinition("GET", "/assets/**", handler, false); + return handler; + } + + @Test + public void shouldCallParentOnMissing() throws Exception { + URI uri = Paths.get("src", "test", "resources", "org", "jooby").toUri(); + new MockUnit(ClassLoader.class) + .expect(publicDir(uri, "assets/index.js", false)) + .expect(unit -> { + ClassLoader loader = unit.get(ClassLoader.class); + expect(loader.getResource("assets/index.js")).andReturn(uri.toURL()); + }) + .run(unit -> { + URL value = newHandler(unit, "/") + .resolve("assets/index.js"); + assertNotNull(value); + }); + } + + @Test + public void ignoreMalformedURL() throws Exception { + Path path = Paths.get("src", "test", "resources", "org", "jooby"); + new MockUnit(ClassLoader.class, URI.class) + .expect(publicDir(null, "assets/index.js")) + .expect(unit -> { + URI uri = unit.get(URI.class); + expect(uri.toURL()).andThrow(new MalformedURLException()); + }) + .expect(unit -> { + ClassLoader loader = unit.get(ClassLoader.class); + expect(loader.getResource("assets/index.js")).andReturn(path.toUri().toURL()); + }) + .run(unit -> { + URL value = newHandler(unit, "/") + .resolve("assets/index.js"); + assertNotNull(value); + }); + } + + private Block publicDir(final URI uri, final String name) { + return publicDir(uri, name, true); + } + + private Block publicDir(final URI uri, final String name, final boolean exists) { + return unit -> { + unit.mockStatic(Paths.class); + + Path basedir = unit.mock(Path.class); + + expect(Paths.get("public")).andReturn(basedir); + + Path path = unit.mock(Path.class); + expect(basedir.resolve(name)).andReturn(path); + expect(path.normalize()).andReturn(path); + + if (exists) { + expect(path.startsWith(basedir)).andReturn(true); + } + + unit.mockStatic(Files.class); + expect(Files.exists(basedir)).andReturn(true); + expect(Files.exists(path)).andReturn(exists); + + if (exists) { + if (uri != null) { + expect(path.toUri()).andReturn(uri); + } else { + expect(path.toUri()).andReturn(unit.get(URI.class)); + } + } + }; + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/AbstractRendererContextTest.java b/jooby/src/test/java-excluded/org/jooby/internal/AbstractRendererContextTest.java new file mode 100644 index 00000000..d44cd6cf --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/AbstractRendererContextTest.java @@ -0,0 +1,69 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +import org.jooby.Err; +import org.jooby.MediaType; +import org.jooby.Renderer; +import org.jooby.Results; +import org.jooby.View; +import org.jooby.test.MockUnit; +import org.junit.Test; + +import com.google.common.collect.ImmutableList; + +public class AbstractRendererContextTest { + + @Test(expected = Err.class) + public void norenderer() throws Throwable { + List renderers = new ArrayList<>(); + List produces = ImmutableList.of(MediaType.json); + View value = Results.html("view"); + new MockUnit() + .run(unit -> { + new AbstractRendererContext(renderers, produces, StandardCharsets.UTF_8, Locale.US, + Collections.emptyMap()) { + + @Override + protected void _send(final byte[] bytes) throws Exception { + } + + @Override + protected void _send(final ByteBuffer buffer) throws Exception { + } + + @Override + protected void _send(final FileChannel file) throws Exception { + } + + @Override + protected void _send(final InputStream stream) throws Exception { + } + + }.render(value); + }); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/BodyReferenceImplTest.java b/jooby/src/test/java-excluded/org/jooby/internal/BodyReferenceImplTest.java new file mode 100644 index 00000000..efbe2771 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/BodyReferenceImplTest.java @@ -0,0 +1,309 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.expectLastCall; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.jooby.test.MockUnit; +import org.jooby.test.MockUnit.Block; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +import com.google.common.io.ByteStreams; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({BodyReferenceImpl.class, ByteStreams.class, FileOutputStream.class, Files.class, + File.class, ByteArrayOutputStream.class }) +public class BodyReferenceImplTest { + + private Block mkdir = unit -> { + File dir = unit.mock(File.class); + expect(dir.mkdirs()).andReturn(true); + + File file = unit.get(File.class); + expect(file.getParentFile()).andReturn(dir); + }; + + private Block fos = unit -> { + FileOutputStream fos = unit.constructor(FileOutputStream.class) + .build(unit.get(File.class)); + + unit.registerMock(FileOutputStream.class, fos); + }; + + @Test + public void fromBytes() throws Exception { + long len = 1; + long bsize = 2; + byte[] bytes = "bytes".getBytes(); + new MockUnit(File.class, InputStream.class) + .expect(baos(bytes)) + .expect(copy(ByteArrayOutputStream.class)) + .run(unit -> { + new BodyReferenceImpl(len, StandardCharsets.UTF_8, unit.get(File.class), + unit.get(InputStream.class), bsize); + }); + } + + @Test(expected = IOException.class) + public void inErr() throws Exception { + long len = 1; + long bsize = 2; + byte[] bytes = "bytes".getBytes(); + new MockUnit(File.class, InputStream.class) + .expect(baos(bytes)) + .expect(unit -> { + InputStream in = unit.get(InputStream.class); + + expect(in.read(unit.capture(byte[].class))).andThrow(new IOException()); + + OutputStream out = unit.get(ByteArrayOutputStream.class); + + in.close(); + out.close(); + }) + .run(unit -> { + new BodyReferenceImpl(len, StandardCharsets.UTF_8, unit.get(File.class), + unit.get(InputStream.class), bsize); + }); + } + + @Test(expected = IOException.class) + public void outErr() throws Exception { + long len = 1; + long bsize = 2; + byte[] bytes = "bytes".getBytes(); + new MockUnit(File.class, InputStream.class) + .expect(baos(bytes)) + .expect(unit -> { + InputStream in = unit.get(InputStream.class); + in.close(); + + OutputStream out = unit.get(ByteArrayOutputStream.class); + out.close(); + expectLastCall().andThrow(new IOException()); + + unit.mockStatic(ByteStreams.class); + expect(ByteStreams.copy(in, out)).andReturn(1L); + }) + .run(unit -> { + new BodyReferenceImpl(len, StandardCharsets.UTF_8, unit.get(File.class), + unit.get(InputStream.class), bsize); + }); + } + + @Test(expected = IOException.class) + public void inErrOnClose() throws Exception { + long len = 1; + long bsize = 2; + byte[] bytes = "bytes".getBytes(); + new MockUnit(File.class, InputStream.class) + .expect(baos(bytes)) + .expect(unit -> { + InputStream in = unit.get(InputStream.class); + in.close(); + expectLastCall().andThrow(new IOException()); + + OutputStream out = unit.get(ByteArrayOutputStream.class); + out.close(); + + unit.mockStatic(ByteStreams.class); + expect(ByteStreams.copy(in, out)).andReturn(1L); + }) + .run(unit -> { + new BodyReferenceImpl(len, StandardCharsets.UTF_8, unit.get(File.class), + unit.get(InputStream.class), bsize); + }); + } + + @Test + public void fromFile() throws Exception { + long len = 1; + long bsize = -1; + + new MockUnit(File.class, InputStream.class) + .expect(mkdir) + .expect(fos) + .expect(copy(FileOutputStream.class)) + .run(unit -> { + new BodyReferenceImpl(len, StandardCharsets.UTF_8, unit.get(File.class), + unit.get(InputStream.class), bsize); + }); + } + + @Test + public void bytesFromBytes() throws Exception { + long len = 1; + long bsize = 2; + byte[] bytes = "bytes".getBytes(); + new MockUnit(File.class, InputStream.class) + .expect(baos(bytes)) + .expect(copy(ByteArrayOutputStream.class)) + .run(unit -> { + byte[] rsp = new BodyReferenceImpl(len, StandardCharsets.UTF_8, unit.get(File.class), + unit.get(InputStream.class), bsize).bytes(); + assertArrayEquals(bytes, rsp); + }); + } + + @Test + public void bytesFromFile() throws Exception { + long len = 1; + long bsize = -1; + byte[] bytes = "bytes".getBytes(); + new MockUnit(File.class, InputStream.class, Path.class) + .expect(mkdir) + .expect(fos) + .expect(copy(FileOutputStream.class)) + .expect(unit -> { + expect(unit.get(File.class).toPath()).andReturn(unit.get(Path.class)); + + unit.mockStatic(Files.class); + expect(Files.readAllBytes(unit.get(Path.class))).andReturn(bytes); + }) + .run(unit -> { + byte[] rsp = new BodyReferenceImpl(len, StandardCharsets.UTF_8, unit.get(File.class), + unit.get(InputStream.class), bsize) + .bytes(); + assertEquals(bytes, rsp); + }); + } + + @Test + public void textFromBytes() throws Exception { + long len = 1; + long bsize = 2; + byte[] bytes = "bytes".getBytes(); + new MockUnit(File.class, InputStream.class) + .expect(baos(bytes)) + .expect(copy(ByteArrayOutputStream.class)) + .run(unit -> { + String rsp = new BodyReferenceImpl(len, StandardCharsets.UTF_8, unit.get(File.class), + unit.get(InputStream.class), bsize).text(); + assertEquals("bytes", rsp); + }); + } + + @Test + public void textFromFile() throws Exception { + long len = 1; + long bsize = -1; + byte[] bytes = "bytes".getBytes(); + new MockUnit(File.class, InputStream.class, Path.class) + .expect(mkdir) + .expect(fos) + .expect(copy(FileOutputStream.class)) + .expect(unit -> { + expect(unit.get(File.class).toPath()).andReturn(unit.get(Path.class)); + + unit.mockStatic(Files.class); + expect(Files.readAllBytes(unit.get(Path.class))).andReturn(bytes); + }) + .run(unit -> { + String rsp = new BodyReferenceImpl(len, StandardCharsets.UTF_8, unit.get(File.class), + unit.get(InputStream.class), bsize) + .text(); + assertEquals("bytes", rsp); + }); + } + + @Test + public void writeToFromFile() throws Exception { + long len = 1; + long bsize = -1; + new MockUnit(File.class, InputStream.class, Path.class, OutputStream.class) + .expect(mkdir) + .expect(fos) + .expect(copy(FileOutputStream.class)) + .expect(unit -> { + expect(unit.get(File.class).toPath()).andReturn(unit.get(Path.class)); + + unit.mockStatic(Files.class); + expect(Files.copy(unit.get(Path.class), unit.get(OutputStream.class))).andReturn(1L); + }) + .run(unit -> { + new BodyReferenceImpl(len, StandardCharsets.UTF_8, unit.get(File.class), + unit.get(InputStream.class), bsize) + .writeTo(unit.get(OutputStream.class)); + }); + } + + @Test + public void bytesWriteTo() throws Exception { + long len = 1; + long bsize = 2; + byte[] bytes = "bytes".getBytes(); + new MockUnit(File.class, InputStream.class, OutputStream.class) + .expect(baos(bytes)) + .expect(copy(ByteArrayOutputStream.class)) + .expect(unit -> { + unit.get(OutputStream.class).write(bytes); + }) + .run(unit -> { + new BodyReferenceImpl(len, StandardCharsets.UTF_8, unit.get(File.class), + unit.get(InputStream.class), bsize) + .writeTo(unit.get(OutputStream.class)); + }); + } + + private Block copy(final Class oclass) { + return copy(oclass, true); + } + + private Block copy(final Class oclass, final boolean close) { + return unit -> { + + InputStream in = unit.get(InputStream.class); + + OutputStream out = unit.get(oclass); + + if (close) { + in.close(); + out.close(); + } + + unit.mockStatic(ByteStreams.class); + expect(ByteStreams.copy(in, out)).andReturn(1L); + }; + } + + private Block baos(final byte[] bytes) { + return unit -> { + ByteArrayOutputStream baos = unit.constructor(ByteArrayOutputStream.class) + .build(); + + expect(baos.toByteArray()).andReturn(bytes); + + unit.registerMock(ByteArrayOutputStream.class, baos); + }; + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/ByteBufferRendererTest.java b/jooby/src/test/java-excluded/org/jooby/internal/ByteBufferRendererTest.java new file mode 100644 index 00000000..22e02c8d --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/ByteBufferRendererTest.java @@ -0,0 +1,75 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.easymock.EasyMock.expect; + +import java.io.OutputStream; +import java.nio.ByteBuffer; + +import org.jooby.MediaType; +import org.jooby.Renderer; +import org.jooby.test.MockUnit; +import org.jooby.test.MockUnit.Block; +import org.junit.Test; + +public class ByteBufferRendererTest { + + private Block defaultType = unit -> { + Renderer.Context ctx = unit.get(Renderer.Context.class); + expect(ctx.type(MediaType.octetstream)).andReturn(ctx); + }; + + @Test + public void renderArray() throws Exception { + ByteBuffer bytes = ByteBuffer.wrap("bytes".getBytes()); + new MockUnit(Renderer.Context.class, OutputStream.class) + .expect(defaultType) + .expect(unit -> { + Renderer.Context ctx = unit.get(Renderer.Context.class); + ctx.send(bytes); + }) + .run(unit -> { + BuiltinRenderer.byteBuffer + .render(bytes, unit.get(Renderer.Context.class)); + }); + } + + @Test + public void renderDirect() throws Exception { + ByteBuffer bytes = ByteBuffer.allocateDirect(0); + new MockUnit(Renderer.Context.class, OutputStream.class) + .expect(defaultType) + .expect(unit -> { + Renderer.Context ctx = unit.get(Renderer.Context.class); + ctx.send(bytes); + }) + .run(unit -> { + BuiltinRenderer.byteBuffer + .render(bytes, unit.get(Renderer.Context.class)); + }); + } + + @Test + public void renderIgnore() throws Exception { + new MockUnit(Renderer.Context.class) + .run(unit -> { + BuiltinRenderer.byteBuffer + .render(new Object(), unit.get(Renderer.Context.class)); + }); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/BytesRendererTest.java b/jooby/src/test/java-excluded/org/jooby/internal/BytesRendererTest.java new file mode 100644 index 00000000..efdbe536 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/BytesRendererTest.java @@ -0,0 +1,89 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.expectLastCall; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import org.jooby.MediaType; +import org.jooby.Renderer; +import org.jooby.test.MockUnit; +import org.jooby.test.MockUnit.Block; +import org.junit.Test; + +public class BytesRendererTest { + + private Block defaultType = unit -> { + Renderer.Context ctx = unit.get(Renderer.Context.class); + expect(ctx.type(MediaType.octetstream)).andReturn(ctx); + }; + + @Test + public void render() throws Exception { + byte[] bytes = "bytes".getBytes(); + new MockUnit(Renderer.Context.class) + .expect(defaultType) + .expect(unit -> { + Renderer.Context ctx = unit.get(Renderer.Context.class); + ctx.send(bytes); + }) + .run(unit -> { + BuiltinRenderer.bytes + .render(bytes, unit.get(Renderer.Context.class)); + }); + } + + @Test + public void renderIgnoredAnyOtherArray() throws Exception { + int[] bytes = new int[0]; + new MockUnit(Renderer.Context.class) + .run(unit -> { + BuiltinRenderer.bytes + .render(bytes, unit.get(Renderer.Context.class)); + }); + } + + @Test + public void renderIgnore() throws Exception { + new MockUnit(Renderer.Context.class) + .run(unit -> { + BuiltinRenderer.bytes + .render(new Object(), unit.get(Renderer.Context.class)); + }); + } + + @Test(expected = IOException.class) + public void renderWithFailure() throws Exception { + byte[] bytes = "bytes".getBytes(); + new MockUnit(Renderer.Context.class, InputStream.class, OutputStream.class) + .expect(defaultType) + .expect(unit -> { + Renderer.Context ctx = unit.get(Renderer.Context.class); + ctx.send(bytes); + expectLastCall().andThrow(new IOException("intentational err")); + + }) + .run(unit -> { + BuiltinRenderer.bytes + .render(bytes, unit.get(Renderer.Context.class)); + }); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/CookieImplTest.java b/jooby/src/test/java-excluded/org/jooby/internal/CookieImplTest.java new file mode 100644 index 00000000..b5091cf5 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/CookieImplTest.java @@ -0,0 +1,195 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.easymock.EasyMock.expect; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Locale; + +import org.jooby.Cookie; +import org.jooby.test.MockUnit; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({Cookie.Definition.class, CookieImpl.class, System.class }) +public class CookieImplTest { + + static final DateTimeFormatter fmt = DateTimeFormatter + .ofPattern("E, dd-MMM-yyyy HH:mm:ss z", Locale.ENGLISH) + .withZone(ZoneId.of("GMT")); + + @Test + public void encodeNameAndValue() throws Exception { + assertEquals("jooby.sid=1234;Version=1", new Cookie.Definition("jooby.sid", "1234").toCookie() + .encode()); + } + + @Test + public void escapeQuote() throws Exception { + assertEquals("jooby.sid=\"a\\\"b\";Version=1", new Cookie.Definition("jooby.sid", "a\"b").toCookie() + .encode()); + } + + @Test + public void escapeSlash() throws Exception { + assertEquals("jooby.sid=\"a\\\\b\";Version=1", new Cookie.Definition("jooby.sid", "a\\b").toCookie() + .encode()); + } + + @Test + public void oneChar() throws Exception { + assertEquals("jooby.sid=1;Version=1", new Cookie.Definition("jooby.sid", "1").toCookie() + .encode()); + } + + @Test + public void escapeValueStartingWithQuoute() throws Exception { + assertEquals("jooby.sid=\"\\\"1\";Version=1", new Cookie.Definition("jooby.sid", "\"1").toCookie() + .encode()); + } + + @Test(expected = IllegalArgumentException.class) + public void badChar() throws Exception { + char ch = '\n'; + new Cookie.Definition("name", "" + ch).toCookie().encode(); + } + + @Test(expected = IllegalArgumentException.class) + public void badChar2() throws Exception { + char ch = 0x7f; + new Cookie.Definition("name", "" + ch).toCookie().encode(); + } + + @Test + public void encodeSessionCookie() throws Exception { + assertEquals("jooby.sid=1234;Version=1", new Cookie.Definition("jooby.sid", "1234").maxAge(-1) + .toCookie().encode()); + } + + @Test + public void nullValue() throws Exception { + assertEquals("jooby.sid=;Version=1", new Cookie.Definition("jooby.sid", "").maxAge(-1) + .toCookie().encode()); + } + + @Test + public void emptyValue() throws Exception { + assertEquals("jooby.sid=;Version=1", new Cookie.Definition("jooby.sid", "").maxAge(-1) + .toCookie().encode()); + } + + @Test + public void quotedValue() throws Exception { + assertEquals("jooby.sid=\"val 1\";Version=1", new Cookie.Definition("jooby.sid", "\"val 1\"") + .maxAge(-1) + .toCookie().encode()); + } + + @Test + public void encodeHttpOnly() throws Exception { + assertEquals("jooby.sid=1234;Version=1;HttpOnly", + new Cookie.Definition("jooby.sid", "1234").httpOnly(true).toCookie() + .encode()); + } + + @Test + public void encodeSecure() throws Exception { + assertEquals("jooby.sid=1234;Version=1;Secure", + new Cookie.Definition("jooby.sid", "1234").secure(true).toCookie() + .encode()); + } + + @Test + public void encodePath() throws Exception { + assertEquals("jooby.sid=1234;Version=1;Path=/", + new Cookie.Definition("jooby.sid", "1234").path("/").toCookie().encode()); + } + + @Test + public void encodeDomain() throws Exception { + assertEquals("jooby.sid=1234;Version=1;Domain=example.com", + new Cookie.Definition("jooby.sid", "1234").domain("example.com").toCookie().encode()); + } + + @Test + public void encodeComment() throws Exception { + assertEquals("jooby.sid=1234;Version=1;Comment=\"1,2,3\"", + new Cookie.Definition("jooby.sid", "1234").comment("1,2,3").toCookie() + .encode()); + } + + @Test + public void encodeMaxAge0() throws Exception { + assertEquals("jooby.sid=1234;Version=1;Max-Age=0;Expires=Thu, 01-Jan-1970 00:00:00 GMT", + new Cookie.Definition("jooby.sid", "1234").maxAge(0).toCookie().encode()); + } + + @Test + public void encodeMaxAge60() throws Exception { + assertTrue(new Cookie.Definition("jooby.sid", "1234") + .maxAge(60).toCookie().encode().startsWith("jooby.sid=1234;Version=1;Max-Age=60")); + + long millis = 1428708685066L; + new MockUnit() + .expect(unit -> { + unit.mockStatic(System.class); + expect(System.currentTimeMillis()).andReturn(millis); + }) + .run(unit -> { + Instant instant = Instant.ofEpochMilli(millis + 60 * 1000L); + assertEquals("jooby.sid=1234;Version=1;Max-Age=60;Expires=" + fmt.format(instant), + new Cookie.Definition("jooby.sid", "1234").maxAge(60).toCookie().encode()); + }); + } + + @Test + public void encodeEverything() throws Exception { + assertTrue(new Cookie.Definition("jooby.sid", "1234") + .maxAge(60).toCookie().encode().startsWith("jooby.sid=1234;Version=1;Max-Age=60")); + + long millis = 1428708685066L; + new MockUnit() + .expect(unit -> { + unit.mockStatic(System.class); + expect(System.currentTimeMillis()).andReturn(millis); + }) + .run( + unit -> { + Instant instant = Instant.ofEpochMilli(millis + 120 * 1000L); + assertEquals( + "jooby.sid=1234;Version=1;Path=/;Domain=example.com;Secure;HttpOnly;Max-Age=120;Expires=" + + fmt.format(instant) + ";Comment=c", + new Cookie.Definition("jooby.sid", "1234") + .comment("c") + .domain("example.com") + .httpOnly(true) + .maxAge(120) + .path("/") + .secure(true) + .toCookie() + .encode() + ); + }); + } +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/CookieSessionManagerTest.java b/jooby/src/test/java-excluded/org/jooby/internal/CookieSessionManagerTest.java new file mode 100644 index 00000000..edb5efd7 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/CookieSessionManagerTest.java @@ -0,0 +1,344 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.easymock.EasyMock.expect; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import java.util.Optional; + +import org.jooby.Cookie; +import org.jooby.Mutant; +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Result; +import org.jooby.Route; +import org.jooby.Route.After; +import org.jooby.Session; +import org.jooby.internal.parser.ParserExecutor; +import org.jooby.test.MockUnit; +import org.jooby.test.MockUnit.Block; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +import com.google.common.collect.ImmutableMap; +import com.typesafe.config.Config; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({CookieSessionManager.class, SessionImpl.class, Cookie.class }) +public class CookieSessionManagerTest { + + private Block cookie = unit -> { + Session.Definition sdef = unit.get(Session.Definition.class); + expect(sdef.cookie()).andReturn(unit.get(Cookie.Definition.class)); + }; + + private Block push = unit -> { + Response rsp = unit.get(Response.class); + rsp.after(unit.capture(Route.After.class)); + }; + + @Test + public void newInstance() throws Exception { + String secret = "shhh"; + new MockUnit(Session.Definition.class, ParserExecutor.class, Cookie.Definition.class) + .expect(cookie) + .expect(maxAge(-1)) + .run(unit -> { + new CookieSessionManager(unit.get(ParserExecutor.class), + unit.get(Session.Definition.class), secret); + }); + } + + @Test + public void destroy() throws Exception { + String secret = "shhh"; + new MockUnit(Session.Definition.class, ParserExecutor.class, Cookie.Definition.class, + Session.class) + .expect(cookie) + .expect(maxAge(-1)) + .run(unit -> { + new CookieSessionManager(unit.get(ParserExecutor.class), + unit.get(Session.Definition.class), secret) + .destroy(unit.get(Session.class)); + }); + } + + @Test + public void requestDone() throws Exception { + String secret = "shhh"; + new MockUnit(Session.Definition.class, ParserExecutor.class, Cookie.Definition.class, + Session.class) + .expect(cookie) + .expect(maxAge(-1)) + .run(unit -> { + new CookieSessionManager(unit.get(ParserExecutor.class), + unit.get(Session.Definition.class), secret) + .requestDone(unit.get(Session.class)); + }); + } + + @Test + public void create() throws Exception { + String secret = "shhh"; + new MockUnit(Session.Definition.class, ParserExecutor.class, Cookie.Definition.class, + Request.class, Response.class, SessionImpl.class) + .expect(cookie) + .expect(maxAge(-1)) + .expect(sessionBuilder(Session.COOKIE_SESSION, true, -1)) + .expect(push) + .run(unit -> { + Session session = new CookieSessionManager(unit.get(ParserExecutor.class), + unit.get(Session.Definition.class), secret) + .create(unit.get(Request.class), unit.get(Response.class)); + assertEquals(unit.get(SessionImpl.class), session); + }); + } + + @Test + public void saveAfter() throws Exception { + String secret = "shhh"; + String signed = "$#!"; + new MockUnit(Session.Definition.class, ParserExecutor.class, Cookie.Definition.class, + Request.class, Response.class, SessionImpl.class) + .expect(cookie) + .expect(maxAge(-1)) + .expect(sessionBuilder(Session.COOKIE_SESSION, true, -1)) + .expect(push) + .expect(unit -> { + Cookie.Definition cookie = unit.get(Cookie.Definition.class); + expect(cookie.name()).andReturn(Optional.of("sid")); + + Mutant mutant = unit.mock(Mutant.class); + expect(mutant.toOptional()).andReturn(Optional.of(signed)); + + Request req = unit.get(Request.class); + expect(req.cookie("sid")).andReturn(mutant); + }) + .expect(unit -> { + SessionImpl session = unit.get(SessionImpl.class); + + expect(session.attributes()).andReturn(ImmutableMap.of("foo", "2")); + + Request req = unit.get(Request.class); + expect(req.ifSession()).andReturn(Optional.of(session)); + }) + .expect(unit -> { + unit.mockStatic(Cookie.Signature.class); + expect(Cookie.Signature.unsign(signed, secret)).andReturn("foo=1"); + }) + .expect(signCookie(secret, "foo=2", "sss")) + .expect(sendCookie()) + .run(unit -> { + Session session = new CookieSessionManager(unit.get(ParserExecutor.class), + unit.get(Session.Definition.class), secret) + .create(unit.get(Request.class), unit.get(Response.class)); + assertEquals(unit.get(SessionImpl.class), session); + }, unit -> { + After next = unit.captured(Route.After.class).iterator().next(); + Result ok = next.handle(unit.get(Request.class), unit.get(Response.class), + org.jooby.Results.ok()); + assertNotNull(ok); + }); + } + + @Test + public void ignoreSaveAfterIfNoSession() throws Exception { + String secret = "shhh"; + new MockUnit(Session.Definition.class, ParserExecutor.class, Cookie.Definition.class, + Request.class, Response.class, SessionImpl.class) + .expect(cookie) + .expect(maxAge(-1)) + .expect(sessionBuilder(Session.COOKIE_SESSION, true, -1)) + .expect(push) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.ifSession()).andReturn(Optional.empty()); + }) + .run(unit -> { + Session session = new CookieSessionManager(unit.get(ParserExecutor.class), + unit.get(Session.Definition.class), secret) + .create(unit.get(Request.class), unit.get(Response.class)); + assertEquals(unit.get(SessionImpl.class), session); + }, unit -> { + After next = unit.captured(Route.After.class).iterator().next(); + Result ok = next.handle(unit.get(Request.class), unit.get(Response.class), + org.jooby.Results.ok()); + assertNotNull(ok); + }); + } + + @Test + public void saveAfterTouchSession() throws Exception { + String secret = "shhh"; + String signed = "$#!"; + new MockUnit(Session.Definition.class, ParserExecutor.class, Cookie.Definition.class, + Request.class, Response.class, SessionImpl.class) + .expect(cookie) + .expect(maxAge(30)) + .expect(sessionBuilder(Session.COOKIE_SESSION, true, -1)) + .expect(push) + .expect(unit -> { + Cookie.Definition cookie = unit.get(Cookie.Definition.class); + expect(cookie.name()).andReturn(Optional.of("sid")); + + Mutant mutant = unit.mock(Mutant.class); + expect(mutant.toOptional()).andReturn(Optional.of(signed)); + + Request req = unit.get(Request.class); + expect(req.cookie("sid")).andReturn(mutant); + }) + .expect(unit -> { + SessionImpl session = unit.get(SessionImpl.class); + + expect(session.attributes()).andReturn(ImmutableMap.of("foo", "1")); + + Request req = unit.get(Request.class); + expect(req.ifSession()).andReturn(Optional.of(session)); + }) + .expect(unit -> { + unit.mockStatic(Cookie.Signature.class); + expect(Cookie.Signature.unsign(signed, secret)).andReturn("foo=1"); + }) + .expect(unit -> { + Cookie.Definition cookie = unit.get(Cookie.Definition.class); + Cookie.Definition newCookie = unit.constructor(Cookie.Definition.class) + .build(cookie); + + expect(newCookie.value(signed)).andReturn(newCookie); + unit.registerMock(Cookie.Definition.class, newCookie); + }) + .expect(sendCookie()) + .run(unit -> { + Session session = new CookieSessionManager(unit.get(ParserExecutor.class), + unit.get(Session.Definition.class), secret) + .create(unit.get(Request.class), unit.get(Response.class)); + assertEquals(unit.get(SessionImpl.class), session); + }, unit -> { + After next = unit.captured(Route.After.class).iterator().next(); + Result ok = next.handle(unit.get(Request.class), unit.get(Response.class), + org.jooby.Results.ok()); + assertNotNull(ok); + }); + } + + private Block sendCookie() { + return unit -> { + Cookie.Definition cookie = unit.get(Cookie.Definition.class); + Response rsp = unit.get(Response.class); + expect(rsp.cookie(cookie)).andReturn(rsp); + }; + } + + @Test + public void noSession() throws Exception { + String secret = "shh"; + new MockUnit(Config.class, Session.Definition.class, Cookie.Definition.class, + Session.Store.class, ParserExecutor.class, Request.class, Response.class) + .expect(cookie) + .expect(maxAge(-1)) + .expect(unit -> { + Cookie.Definition cookie = unit.get(Cookie.Definition.class); + expect(cookie.name()).andReturn(Optional.of("sid")); + + Mutant mutant = unit.mock(Mutant.class); + expect(mutant.toOptional()).andReturn(Optional.empty()); + + Request req = unit.get(Request.class); + expect(req.cookie("sid")).andReturn(mutant); + }) + .run(unit -> { + Session session = new CookieSessionManager(unit.get(ParserExecutor.class), + unit.get(Session.Definition.class), secret).get(unit.get(Request.class), + unit.get(Response.class)); + assertEquals(null, session); + }); + } + + @Test + public void getSession() throws Exception { + String secret = "shh"; + String signed = "$#!"; + new MockUnit(Config.class, Session.Definition.class, Cookie.Definition.class, + Session.Store.class, ParserExecutor.class, Request.class, Response.class, SessionImpl.class) + .expect(cookie) + .expect(maxAge(-1)) + .expect(unit -> { + Cookie.Definition cookie = unit.get(Cookie.Definition.class); + expect(cookie.name()).andReturn(Optional.of("sid")); + + Mutant mutant = unit.mock(Mutant.class); + expect(mutant.toOptional()).andReturn(Optional.of(signed)); + + Request req = unit.get(Request.class); + expect(req.cookie("sid")).andReturn(mutant); + }) + .expect(unit -> { + unit.mockStatic(Cookie.Signature.class); + expect(Cookie.Signature.unsign(signed, secret)).andReturn("foo=1"); + }) + .expect(sessionBuilder(Session.COOKIE_SESSION, false, -1)) + .expect(unit -> { + Session.Builder builder = unit.get(Session.Builder.class); + expect(builder.set(ImmutableMap.of("foo", "1"))).andReturn(builder); + expect(builder.build()).andReturn(unit.get(SessionImpl.class)); + }) + .expect(push) + .run(unit -> { + Session session = new CookieSessionManager(unit.get(ParserExecutor.class), + unit.get(Session.Definition.class), secret).get(unit.get(Request.class), + unit.get(Response.class)); + assertEquals(unit.get(SessionImpl.class), session); + }); + } + + private Block signCookie(final String secret, final String value, final String signed) { + return unit -> { + unit.mockStatic(Cookie.Signature.class); + expect(Cookie.Signature.sign(value, secret)).andReturn(signed); + + Cookie.Definition cookie = unit.get(Cookie.Definition.class); + Cookie.Definition newCookie = unit.constructor(Cookie.Definition.class) + .build(cookie); + + expect(newCookie.value(signed)).andReturn(newCookie); + unit.registerMock(Cookie.Definition.class, newCookie); + }; + } + + private Block maxAge(final Integer maxAge) { + return unit -> { + Cookie.Definition session = unit.get(Cookie.Definition.class); + expect(session.maxAge()).andReturn(Optional.of(maxAge)); + }; + } + + private Block sessionBuilder(final String id, final boolean isNew, final long timeout) { + return unit -> { + SessionImpl.Builder builder = unit.constructor(SessionImpl.Builder.class) + .build(unit.get(ParserExecutor.class), isNew, id, timeout); + if (isNew) { + expect(builder.build()).andReturn(unit.get(SessionImpl.class)); + } + + unit.registerMock(Session.Builder.class, builder); + }; + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/InputStreamAssetTest.java b/jooby/src/test/java-excluded/org/jooby/internal/InputStreamAssetTest.java new file mode 100644 index 00000000..3e87f411 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/InputStreamAssetTest.java @@ -0,0 +1,59 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.junit.Assert.assertEquals; + +import java.io.InputStream; + +import org.jooby.MediaType; +import org.jooby.test.MockUnit; +import org.junit.Test; + +public class InputStreamAssetTest { + + @Test + public void defaults() throws Exception { + new MockUnit(InputStream.class) + .run(unit -> { + InputStreamAsset asset = + new InputStreamAsset( + unit.get(InputStream.class), + "stream.bin", + MediaType.octetstream + ); + assertEquals(-1, asset.lastModified()); + assertEquals(-1, asset.length()); + assertEquals("stream.bin", asset.name()); + assertEquals("stream.bin", asset.path()); + assertEquals(unit.get(InputStream.class), asset.stream()); + assertEquals(MediaType.octetstream, asset.type()); + }); + } + + @Test(expected = UnsupportedOperationException.class) + public void noResource() throws Exception { + new MockUnit(InputStream.class) + .run(unit -> { + new InputStreamAsset( + unit.get(InputStream.class), + "stream.bin", + MediaType.octetstream + ).resource(); + }); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/InputStreamRendererTest.java b/jooby/src/test/java-excluded/org/jooby/internal/InputStreamRendererTest.java new file mode 100644 index 00000000..b23f04fb --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/InputStreamRendererTest.java @@ -0,0 +1,76 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.expectLastCall; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import org.jooby.MediaType; +import org.jooby.Renderer; +import org.jooby.test.MockUnit; +import org.jooby.test.MockUnit.Block; +import org.junit.Test; + +public class InputStreamRendererTest { + + private Block defaultType = unit -> { + Renderer.Context ctx = unit.get(Renderer.Context.class); + expect(ctx.type(MediaType.octetstream)).andReturn(ctx); + }; + + @Test + public void render() throws Exception { + new MockUnit(Renderer.Context.class, InputStream.class) + .expect(defaultType) + .expect(unit -> { + Renderer.Context ctx = unit.get(Renderer.Context.class); + ctx.send(unit.get(InputStream.class)); + }) + .run(unit -> { + BuiltinRenderer.stream + .render(unit.get(InputStream.class), unit.get(Renderer.Context.class)); + }); + } + + @Test + public void renderIgnored() throws Exception { + new MockUnit(Renderer.Context.class) + .run(unit -> { + BuiltinRenderer.stream + .render(new Object(), unit.get(Renderer.Context.class)); + }); + } + + @Test(expected = IOException.class) + public void renderWithFailure() throws Exception { + new MockUnit(Renderer.Context.class, InputStream.class, OutputStream.class) + .expect(defaultType) + .expect(unit -> { + Renderer.Context ctx = unit.get(Renderer.Context.class); + ctx.send(unit.get(InputStream.class)); + expectLastCall().andThrow(new IOException("intentational err")); + }) + .run(unit -> { + BuiltinRenderer.stream + .render(unit.get(InputStream.class), unit.get(Renderer.Context.class)); + }); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/JvmInfoTest.java b/jooby/src/test/java-excluded/org/jooby/internal/JvmInfoTest.java new file mode 100644 index 00000000..4b1aa6b1 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/JvmInfoTest.java @@ -0,0 +1,56 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.easymock.EasyMock.expect; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.lang.management.ManagementFactory; + +import org.jooby.test.MockUnit; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({JvmInfo.class, ManagementFactory.class }) +public class JvmInfoTest { + + @Test + public void emptyConstructor() { + new JvmInfo(); + } + + @Test + public void pid() { + assertTrue(JvmInfo.pid() > 0); + } + + @Test + public void piderr() throws Exception { + new MockUnit() + .expect(unit -> { + unit.mockStatic(ManagementFactory.class); + expect(ManagementFactory.getRuntimeMXBean()).andThrow(new RuntimeException()); + }) + .run(unit -> { + assertEquals(-1, JvmInfo.pid()); + }); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/MappedHandlerTest.java b/jooby/src/test/java-excluded/org/jooby/internal/MappedHandlerTest.java new file mode 100644 index 00000000..b55821f4 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/MappedHandlerTest.java @@ -0,0 +1,51 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.easymock.EasyMock.expect; +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Route; +import org.jooby.test.MockUnit; +import org.jooby.funzy.Throwing; +import org.junit.Test; + +public class MappedHandlerTest { + + @SuppressWarnings("unchecked") + @Test + public void shouldIgnoreClassCastExceptionWhileMapping() throws Exception { + Route.Mapper m = value -> value.intValue() * 2; + String value = "1"; + new MockUnit(Throwing.Function2.class, Request.class, Response.class, Route.Chain.class) + .expect(unit -> { + Throwing.Function2 fn = unit.get(Throwing.Function2.class); + expect(fn.apply(unit.get(Request.class), unit.get(Response.class))).andReturn(value); + }) + .expect(unit -> { + Route.Chain chain = unit.get(Route.Chain.class); + Request req = unit.get(Request.class); + Response rsp = unit.get(Response.class); + rsp.send(value); + chain.next(req, rsp); + }) + .run(unit -> { + new MappedHandler(unit.get(Throwing.Function2.class), m) + .handle(unit.get(Request.class), unit.get(Response.class), + unit.get(Route.Chain.class)); + }); + } +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/ParamReferenceImplTest.java b/jooby/src/test/java-excluded/org/jooby/internal/ParamReferenceImplTest.java new file mode 100644 index 00000000..2228ff32 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/ParamReferenceImplTest.java @@ -0,0 +1,111 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.easymock.EasyMock.expect; +import static org.junit.Assert.assertEquals; + +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; + +import org.jooby.test.MockUnit; +import org.junit.Test; + +import com.google.common.collect.ImmutableList; + +public class ParamReferenceImplTest { + + @Test + public void defaults() throws Exception { + new MockUnit() + .run(unit -> { + new StrParamReferenceImpl("parameter", "name", Collections.emptyList()); + }); + } + + @Test + public void first() throws Exception { + new MockUnit() + .run(unit -> { + assertEquals("first", + new StrParamReferenceImpl("parameter", "name", ImmutableList.of("first")).first()); + }); + } + + @Test + public void last() throws Exception { + new MockUnit() + .run(unit -> { + assertEquals("last", + new StrParamReferenceImpl("parameter", "name", ImmutableList.of("last")).last()); + }); + } + + @Test + public void get() throws Exception { + new MockUnit() + .run(unit -> { + assertEquals("0", + new StrParamReferenceImpl("parameter", "name", ImmutableList.of("0")).get(0)); + assertEquals("1", + new StrParamReferenceImpl("parameter", "name", ImmutableList.of("0", "1")).get(1)); + }); + } + + @Test(expected = NoSuchElementException.class) + public void missing() throws Exception { + new MockUnit() + .run(unit -> { + new StrParamReferenceImpl("parameter", "name", ImmutableList.of("0")).get(1); + }); + } + + @Test(expected = NoSuchElementException.class) + public void missingLowIndex() throws Exception { + new MockUnit() + .run(unit -> { + new StrParamReferenceImpl("parameter", "name", ImmutableList.of("0")).get(-1); + }); + } + + @Test + public void size() throws Exception { + new MockUnit() + .run(unit -> { + assertEquals(1, + new StrParamReferenceImpl("parameter", "name", ImmutableList.of("0")).size()); + assertEquals(2, + new StrParamReferenceImpl("parameter", "name", ImmutableList.of("0", "1")).size()); + }); + } + + @SuppressWarnings({"rawtypes", "unchecked" }) + @Test + public void iterator() throws Exception { + new MockUnit(List.class, Iterator.class) + .expect(unit -> { + List list = unit.get(List.class); + expect(list.iterator()).andReturn(unit.get(Iterator.class)); + }) + .run(unit -> { + assertEquals(unit.get(Iterator.class), + new StrParamReferenceImpl("parameter", "name", unit.get(List.class)).iterator()); + }); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/RequestImplTest.java b/jooby/src/test/java-excluded/org/jooby/internal/RequestImplTest.java new file mode 100644 index 00000000..76dea8fd --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/RequestImplTest.java @@ -0,0 +1,188 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.easymock.EasyMock.expect; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Locale; +import java.util.Optional; + +import org.jooby.Err; +import org.jooby.Route; +import org.jooby.spi.NativeRequest; +import org.jooby.test.MockUnit; +import org.jooby.test.MockUnit.Block; +import org.junit.Test; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.inject.Injector; + +public class RequestImplTest { + + private Block accept = unit -> { + NativeRequest req = unit.get(NativeRequest.class); + expect(req.header("Accept")).andReturn(Optional.of("*/*")); + }; + + private Block contentType = unit -> { + NativeRequest req = unit.get(NativeRequest.class); + expect(req.header("Content-Type")).andReturn(Optional.empty()); + }; + + private Block acceptLan = unit -> { + NativeRequest req = unit.get(NativeRequest.class); + expect(req.header("Accept-Language")).andReturn(Optional.empty()); + }; + + @Test + public void defaults() throws Exception { + new MockUnit(Injector.class, NativeRequest.class, Route.class) + .expect(accept) + .expect(acceptLan) + .expect(contentType) + .run(unit -> { + new RequestImpl(unit.get(Injector.class), unit.get(NativeRequest.class), "/", 8080, + unit.get(Route.class), StandardCharsets.UTF_8, ImmutableList.of(Locale.ENGLISH), + ImmutableMap.of(), ImmutableMap.of(), 1L); + }); + } + + @Test + public void matches() throws Exception { + new MockUnit(Injector.class, NativeRequest.class, Route.class) + .expect(accept) + .expect(acceptLan) + .expect(contentType) + .expect(unit -> { + Route route = unit.get(Route.class); + expect(route.path()).andReturn("/path/x"); + }) + + .run(unit -> { + RequestImpl req = new RequestImpl(unit.get(Injector.class), unit.get(NativeRequest.class), + "/", 8080, + unit.get(Route.class), StandardCharsets.UTF_8, ImmutableList.of(Locale.ENGLISH), + ImmutableMap.of(), + ImmutableMap.of(), 1L); + assertEquals(true, req.matches("/path/**")); + }); + } + + @Test + public void lang() throws Exception { + new MockUnit(Injector.class, NativeRequest.class, Route.class) + .expect(accept) + .expect(unit -> { + NativeRequest req = unit.get(NativeRequest.class); + expect(req.header("Accept-Language")).andReturn(Optional.of("en")); + }) + .expect(contentType) + .run(unit -> { + RequestImpl req = new RequestImpl(unit.get(Injector.class), unit.get(NativeRequest.class), + "/", 8080, + unit.get(Route.class), StandardCharsets.UTF_8, ImmutableList.of(Locale.ENGLISH), + ImmutableMap.of(), + ImmutableMap.of(), 1L); + assertEquals(Locale.ENGLISH, req.locale()); + }); + } + + @Test + public void files() throws Exception { + IOException cause = new IOException("intentional err"); + new MockUnit(Injector.class, NativeRequest.class, Route.class) + .expect(accept) + .expect(acceptLan) + .expect(contentType) + .expect(unit -> { + NativeRequest req = unit.get(NativeRequest.class); + expect(req.files("f")).andThrow(cause); + }) + .run(unit -> { + try { + new RequestImpl(unit.get(Injector.class), unit.get(NativeRequest.class), "/", 8080, + unit.get(Route.class), StandardCharsets.UTF_8, ImmutableList.of(Locale.ENGLISH), + ImmutableMap.of(), + ImmutableMap.of(), 1L).file("f"); + fail("expecting error"); + } catch (IOException ex) { + assertEquals(cause, ex); + } + }); + } + + @Test + public void paramNames() throws Exception { + IOException cause = new IOException("intentional err"); + new MockUnit(Injector.class, NativeRequest.class, Route.class) + .expect(accept) + .expect(acceptLan) + .expect(contentType) + .expect(unit -> { + Route route = unit.get(Route.class); + expect(route.vars()).andReturn(ImmutableMap.of()); + + NativeRequest req = unit.get(NativeRequest.class); + expect(req.paramNames()).andThrow(cause); + }) + .run(unit -> { + try { + new RequestImpl(unit.get(Injector.class), unit.get(NativeRequest.class), "/", 8080, + unit.get(Route.class), StandardCharsets.UTF_8, ImmutableList.of(Locale.ENGLISH), + ImmutableMap.of(), + ImmutableMap.of(), 1L).params(); + fail("expecting error"); + } catch (Err ex) { + assertEquals(400, ex.statusCode()); + assertEquals(cause, ex.getCause()); + } + }); + } + + @Test + public void params() throws Exception { + IOException cause = new IOException("intentional err"); + new MockUnit(Injector.class, NativeRequest.class, Route.class) + .expect(accept) + .expect(acceptLan) + .expect(contentType) + .expect(unit -> { + Route route = unit.get(Route.class); + expect(route.vars()).andReturn(ImmutableMap.of()); + + NativeRequest req = unit.get(NativeRequest.class); + expect(req.params("p")).andThrow(cause); + }) + .run(unit -> { + try { + new RequestImpl(unit.get(Injector.class), unit.get(NativeRequest.class), "/", 8080, + unit.get(Route.class), StandardCharsets.UTF_8, ImmutableList.of(Locale.ENGLISH), + ImmutableMap.of(), + ImmutableMap.of(), 1L).param("p"); + fail("expecting error"); + } catch (Err ex) { + assertEquals(400, ex.statusCode()); + assertEquals(cause, ex.getCause()); + } + }); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/RequestScopeTest.java b/jooby/src/test/java-excluded/org/jooby/internal/RequestScopeTest.java new file mode 100644 index 00000000..5adc51d2 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/RequestScopeTest.java @@ -0,0 +1,152 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.easymock.EasyMock.expect; +import static org.junit.Assert.assertEquals; + +import java.util.Collections; +import java.util.Map; + +import org.jooby.test.MockUnit; +import org.junit.Test; + +import com.google.inject.Key; +import com.google.inject.OutOfScopeException; +import com.google.inject.Provider; +import com.google.inject.internal.CircularDependencyProxy; + +public class RequestScopeTest { + + @Test + public void enter() { + RequestScope requestScope = new RequestScope(); + requestScope.enter(Collections.emptyMap()); + requestScope.exit(); + } + + @SuppressWarnings({"unchecked", "rawtypes" }) + @Test + public void scopedValue() throws Exception { + RequestScope requestScope = new RequestScope(); + Key key = Key.get(Object.class); + Object value = new Object(); + try { + new MockUnit(Provider.class, Map.class) + .expect(unit -> { + Map scopedObjects = unit.get(Map.class); + requestScope.enter(scopedObjects); + expect(scopedObjects.get(key)).andReturn(null); + expect(scopedObjects.containsKey(key)).andReturn(false); + + expect(scopedObjects.put(key, value)).andReturn(null); + }) + .expect(unit -> { + Provider provider = unit.get(Provider.class); + expect(provider.get()).andReturn(value); + }) + .run(unit -> { + Object result = requestScope. scope(key, unit.get(Provider.class)).get(); + assertEquals(value, result); + }); + } finally { + requestScope.exit(); + } + } + + @SuppressWarnings({"unchecked", "rawtypes" }) + @Test + public void scopedNullValue() throws Exception { + RequestScope requestScope = new RequestScope(); + Key key = Key.get(Object.class); + try { + new MockUnit(Provider.class, Map.class) + .expect(unit -> { + Map scopedObjects = unit.get(Map.class); + requestScope.enter(scopedObjects); + expect(scopedObjects.get(key)).andReturn(null); + expect(scopedObjects.containsKey(key)).andReturn(true); + }) + .run(unit -> { + Object result = requestScope. scope(key, unit.get(Provider.class)).get(); + assertEquals(null, result); + }); + } finally { + requestScope.exit(); + } + } + + @SuppressWarnings({"unchecked", "rawtypes" }) + @Test + public void scopeExistingValue() throws Exception { + RequestScope requestScope = new RequestScope(); + Key key = Key.get(Object.class); + Object value = new Object(); + try { + new MockUnit(Provider.class, Map.class) + .expect(unit -> { + Map scopedObjects = unit.get(Map.class); + requestScope.enter(scopedObjects); + expect(scopedObjects.get(key)).andReturn(value); + }) + .run(unit -> { + Object result = requestScope. scope(key, unit.get(Provider.class)).get(); + assertEquals(value, result); + }); + } finally { + requestScope.exit(); + } + } + + @SuppressWarnings({"unchecked", "rawtypes" }) + @Test + public void circularScopedValue() throws Exception { + RequestScope requestScope = new RequestScope(); + Key key = Key.get(Object.class); + try { + new MockUnit(Provider.class, Map.class, CircularDependencyProxy.class) + .expect(unit -> { + Map scopedObjects = unit.get(Map.class); + requestScope.enter(scopedObjects); + expect(scopedObjects.get(key)).andReturn(null); + expect(scopedObjects.containsKey(key)).andReturn(false); + }) + .expect(unit -> { + Provider provider = unit.get(Provider.class); + expect(provider.get()).andReturn(unit.get(CircularDependencyProxy.class)); + }) + .run(unit -> { + Object result = requestScope. scope(key, unit.get(Provider.class)).get(); + assertEquals(unit.get(CircularDependencyProxy.class), result); + }); + } finally { + requestScope.exit(); + } + } + + @SuppressWarnings({"unchecked" }) + @Test(expected = OutOfScopeException.class) + public void outOfScope() throws Exception { + RequestScope requestScope = new RequestScope(); + Key key = Key.get(Object.class); + Object value = new Object(); + new MockUnit(Provider.class, Map.class) + .run(unit -> { + Object result = requestScope. scope(key, unit.get(Provider.class)).get(); + assertEquals(value, result); + }); + } +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/RequestScopedSessionTest.java b/jooby/src/test/java-excluded/org/jooby/internal/RequestScopedSessionTest.java new file mode 100644 index 00000000..72cfa9e8 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/RequestScopedSessionTest.java @@ -0,0 +1,120 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.easymock.EasyMock.expect; +import org.jooby.Cookie; +import org.jooby.Response; +import org.jooby.Session; +import org.jooby.test.MockUnit; +import static org.junit.Assert.assertEquals; +import org.junit.Test; + +/** + * TODO: complete unit tests. + */ +public class RequestScopedSessionTest { + + private MockUnit.Block destroy = unit -> { + SessionImpl session = unit.get(SessionImpl.class); + session.destroy(); + }; + + private MockUnit.Block resetSession = unit -> { + unit.get(Runnable.class).run(); + }; + + private MockUnit.Block cookie = unit -> { + Cookie.Definition cookie = unit.mock(Cookie.Definition.class); + expect(unit.get(SessionManager.class).cookie()).andReturn(cookie); + + expect(cookie.maxAge(0)).andReturn(cookie); + + Response rsp = unit.get(Response.class); + expect(rsp.cookie(cookie)).andReturn(rsp); + }; + + private MockUnit.Block smDestroy = unit -> { + unit.get(SessionManager.class).destroy(unit.get(SessionImpl.class)); + }; + + @Test + public void shouldDestroySession() throws Exception { + new MockUnit(SessionManager.class, Response.class, SessionImpl.class, Runnable.class) + .expect(sid("sid")) + .expect(destroy) + .expect(resetSession) + .expect(smDestroy) + .expect(cookie) + .run(unit -> { + RequestScopedSession session = new RequestScopedSession( + unit.get(SessionManager.class), unit.get(Response.class), unit.get(SessionImpl.class), + unit.get(Runnable.class)); + session.destroy(); + // NOOP + session.destroy(); + }); + } + + @Test(expected = Session.Destroyed.class) + public void destroyedSession() throws Exception { + new MockUnit(SessionManager.class, Response.class, SessionImpl.class, Runnable.class) + .expect(sid("sid")) + .expect(destroy) + .expect(resetSession) + .expect(smDestroy) + .expect(cookie) + .run(unit -> { + RequestScopedSession session = new RequestScopedSession( + unit.get(SessionManager.class), unit.get(Response.class), unit.get(SessionImpl.class), + unit.get(Runnable.class)); + session.destroy(); + session.id(); + }); + } + + @Test + public void isDestroyed() throws Exception { + new MockUnit(SessionManager.class, Response.class, SessionImpl.class, Runnable.class) + .expect(sid("sid")) + .expect(destroy) + .expect(resetSession) + .expect(smDestroy) + .expect(cookie) + .expect(isDestroyed(false)) + .run(unit -> { + RequestScopedSession session = new RequestScopedSession( + unit.get(SessionManager.class), unit.get(Response.class), unit.get(SessionImpl.class), + unit.get(Runnable.class)); + assertEquals(false, session.isDestroyed()); + session.destroy(); + assertEquals(true, session.isDestroyed()); + }); + } + + private MockUnit.Block isDestroyed(boolean destroyed) { + return unit -> { + expect(unit.get(SessionImpl.class).isDestroyed()).andReturn(destroyed); + }; + } + + private MockUnit.Block sid(String sid) { + return unit -> { + SessionImpl session = unit.get(SessionImpl.class); + expect(session.id()).andReturn(sid); + }; + } +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/RouteImplTest.java b/jooby/src/test/java-excluded/org/jooby/internal/RouteImplTest.java new file mode 100644 index 00000000..a1117758 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/RouteImplTest.java @@ -0,0 +1,94 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.easymock.EasyMock.expect; +import static org.junit.Assert.assertEquals; + +import java.util.Collections; +import java.util.Optional; + +import org.jooby.Err; +import org.jooby.MediaType; +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Route; +import org.jooby.Route.Source; +import org.jooby.test.MockUnit; +import org.junit.Test; + +public class RouteImplTest { + + @Test(expected = Err.class) + public void notFound() throws Exception { + new MockUnit(Request.class, Response.class, Route.Chain.class) + .expect(unit -> { + Response rsp = unit.get(Response.class); + expect(rsp.status()).andReturn(Optional.empty()); + + Request req = unit.get(Request.class); + expect(req.path()).andReturn("/x"); + }) + .run(unit -> { + RouteImpl.notFound("GET", "/x") + .handle(unit.get(Request.class), unit.get(Response.class), + unit.get(Route.Chain.class)); + }); + } + + @Test + public void statusSetOnNotFound() throws Exception { + new MockUnit(Request.class, Response.class, Route.Chain.class) + .expect(unit -> { + Response rsp = unit.get(Response.class); + expect(rsp.status()).andReturn(Optional.of(org.jooby.Status.OK)); + }) + .run(unit -> { + RouteImpl.notFound("GET", "/x") + .handle(unit.get(Request.class), unit.get(Response.class), + unit.get(Route.Chain.class)); + }); + } + + @Test + public void toStr() { + Route.Filter f = (req, rsp, chain) -> { + }; + Route route = new RouteImpl(f, new Route.Definition("GET", "/p?th", f) + .name("path") + .consumes("html", "json"), "GET", "/path", MediaType.valueOf("json", "html"), + Collections.emptyMap(), null, Source.BUILTIN); + + assertEquals( + "| Method | Path | Source | Name | Pattern | Consumes | Produces |\n" + + + "|--------|-------|----------|-------|---------|-------------------------------|-------------------------------|\n" + + + "| GET | /path | ~builtin | /path | /p?th | [text/html, application/json] | [application/json, text/html] |", + route.toString()); + } + + @Test + public void consumes() { + Route.Filter f = (req, rsp, chain) -> { + }; + Route route = new RouteImpl(f, new Route.Definition("GET", "/p?th", f).consumes("html", "json"), + "GET", "/path", Collections.emptyList(), Collections.emptyMap(), null, Source.BUILTIN); + + assertEquals(MediaType.valueOf("html", "json"), route.consumes()); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/RouteMetadataTest.java b/jooby/src/test/java-excluded/org/jooby/internal/RouteMetadataTest.java new file mode 100644 index 00000000..021e631d --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/RouteMetadataTest.java @@ -0,0 +1,262 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.easymock.EasyMock.eq; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.expectLastCall; +import static org.easymock.EasyMock.isA; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; + +import java.io.InputStream; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.net.URL; + +import org.jooby.Env; +import org.jooby.test.MockUnit; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +import com.google.common.io.Resources; +import com.typesafe.config.Config; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({RouteMetadata.class, Resources.class, URL.class, ClassReader.class }) +public class RouteMetadataTest { + + public static class Mvc { + + public Mvc() { + } + + public Mvc(final String s) { + } + + public void noarg() { + + } + + public void arg(final double v) { + + } + + public void arg(final String x) { + + } + + public void arg(final double v, final int u) { + + } + + public static void staticMethod() { + + } + } + + @Test + public void noargconst() throws Exception { + new MockUnit(Config.class) + .expect(unit -> { + Config config = unit.get(Config.class); + expect(config.hasPath("application.env")).andReturn(true); + expect(config.getString("application.env")).andReturn("dev"); + }) + .run(unit -> { + Constructor constructor = Mvc.class.getDeclaredConstructor(); + RouteMetadata ci = new RouteMetadata(Env.DEFAULT.build(unit.get(Config.class))); + assertArrayEquals(new String[0], ci.names(constructor)); + assertEquals(35, ci.startAt(constructor)); + }); + } + + @Test + public void consArgS() throws Exception { + new MockUnit(Config.class) + .expect(unit -> { + Config config = unit.get(Config.class); + expect(config.hasPath("application.env")).andReturn(true); + expect(config.getString("application.env")).andReturn("dev"); + }) + .run(unit -> { + Constructor constructor = Mvc.class.getDeclaredConstructor(String.class); + RouteMetadata ci = new RouteMetadata(Env.DEFAULT.build(unit.get(Config.class))); + assertArrayEquals(new String[]{"s" }, ci.names(constructor)); + assertEquals(38, ci.startAt(constructor)); + }); + } + + @Test + public void noargmethod() throws Exception { + new MockUnit(Config.class) + .expect(unit -> { + Config config = unit.get(Config.class); + expect(config.hasPath("application.env")).andReturn(true); + expect(config.getString("application.env")).andReturn("dev"); + }) + .run(unit -> { + Method m = Mvc.class.getDeclaredMethod("noarg"); + RouteMetadata ci = new RouteMetadata(Env.DEFAULT.build(unit.get(Config.class))); + assertArrayEquals(new String[0], ci.names(m)); + assertEquals(43, ci.startAt(m)); + }); + } + + @Test + public void argI() throws Exception { + new MockUnit(Config.class) + .expect(unit -> { + Config config = unit.get(Config.class); + expect(config.hasPath("application.env")).andReturn(true); + expect(config.getString("application.env")).andReturn("dev"); + }) + .run(unit -> { + Method m = Mvc.class.getDeclaredMethod("arg", double.class); + RouteMetadata ci = new RouteMetadata(Env.DEFAULT.build(unit.get(Config.class))); + assertArrayEquals(new String[]{"v" }, ci.names(m)); + assertEquals(47, ci.startAt(m)); + }); + } + + @Test + public void argS() throws Exception { + new MockUnit(Config.class) + .expect(unit -> { + Config config = unit.get(Config.class); + expect(config.hasPath("application.env")).andReturn(true); + expect(config.getString("application.env")).andReturn("dev"); + }) + .run(unit -> { + Method m = Mvc.class.getDeclaredMethod("arg", String.class); + RouteMetadata ci = new RouteMetadata(Env.DEFAULT.build(unit.get(Config.class))); + assertArrayEquals(new String[]{"x" }, ci.names(m)); + assertEquals(51, ci.startAt(m)); + }); + } + + @Test + public void argVU() throws Exception { + new MockUnit(Config.class) + .expect(unit -> { + Config config = unit.get(Config.class); + expect(config.hasPath("application.env")).andReturn(true); + expect(config.getString("application.env")).andReturn("dev"); + }) + .run(unit -> { + Method m = Mvc.class.getDeclaredMethod("arg", double.class, int.class); + RouteMetadata ci = new RouteMetadata(Env.DEFAULT.build(unit.get(Config.class))); + assertArrayEquals(new String[]{"v", "u" }, ci.names(m)); + assertEquals(55, ci.startAt(m)); + }); + } + + @Test + public void nocache() throws Exception { + new MockUnit(Config.class) + .expect(unit -> { + Config config = unit.get(Config.class); + expect(config.hasPath("application.env")).andReturn(true); + expect(config.getString("application.env")).andReturn("dev"); + }) + .run(unit -> { + Method m = Mvc.class.getDeclaredMethod("arg", String.class); + RouteMetadata ci = new RouteMetadata(Env.DEFAULT.build(unit.get(Config.class))); + String[] params1 = ci.names(m); + String[] params2 = ci.names(m); + assertNotSame(params1, params2); + }); + } + + @Test + public void nocacheMavenBuild() throws Exception { + InputStream stream = getClass().getResourceAsStream("RouteMetadataTest$Mvc.bc"); + new MockUnit(Config.class) + .expect(unit -> { + Config config = unit.get(Config.class); + expect(config.hasPath("application.env")).andReturn(true); + expect(config.getString("application.env")).andReturn("dev"); + }) + .expect(unit -> { + URL resource = unit.mock(URL.class); + expect(resource.openStream()).andReturn(stream); + unit.mockStatic(Resources.class); + expect(Resources.getResource(Mvc.class, "RouteMetadataTest$Mvc.class")) + .andReturn(resource); + }) + .run(unit -> { + Method method = Mvc.class.getDeclaredMethod("arg", String.class); + RouteMetadata ci = new RouteMetadata(Env.DEFAULT.build(unit.get(Config.class))); + String[] params = ci.names(method); + assertEquals("x", params[0]); + }); + } + + @Test(expected = IllegalStateException.class) + public void cannotReadByteCode() throws Exception { + new MockUnit(Config.class) + .expect(unit -> { + Config config = unit.get(Config.class); + expect(config.hasPath("application.env")).andReturn(true); + expect(config.getString("application.env")).andReturn("dev"); + }) + .expect(unit -> { + InputStream stream = unit.mock(InputStream.class); + stream.close(); + URL resource = unit.mock(URL.class); + expect(resource.openStream()).andReturn(stream); + + ClassReader reader = unit + .mockConstructor(ClassReader.class, new Class[]{InputStream.class }, stream); + reader.accept(isA(ClassVisitor.class), eq(0)); + expectLastCall().andThrow(new NullPointerException("intentional err")); + + unit.mockStatic(Resources.class); + expect(Resources.getResource(Mvc.class, "RouteMetadataTest$Mvc.class")) + .andReturn(resource); + }) + .run(unit -> { + Method method = Mvc.class.getDeclaredMethod("arg", String.class); + RouteMetadata ci = new RouteMetadata(Env.DEFAULT.build(unit.get(Config.class))); + String[] params = ci.names(method); + assertEquals("x", params[0]); + }); + } + + @Test + public void withcache() throws Exception { + new MockUnit(Config.class) + .expect(unit -> { + Config config = unit.get(Config.class); + expect(config.hasPath("application.env")).andReturn(true); + expect(config.getString("application.env")).andReturn("prod"); + }) + .run(unit -> { + Method m = Mvc.class.getDeclaredMethod("arg", String.class); + RouteMetadata ci = new RouteMetadata(Env.DEFAULT.build(unit.get(Config.class))); + String[] params1 = ci.names(m); + String[] params2 = ci.names(m); + assertSame(params1, params2); + }); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/ServerLookupTest.java b/jooby/src/test/java-excluded/org/jooby/internal/ServerLookupTest.java new file mode 100644 index 00000000..6b9092f2 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/ServerLookupTest.java @@ -0,0 +1,117 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.easymock.EasyMock.expect; +import static org.junit.Assert.assertEquals; + +import org.jooby.Env; +import org.jooby.Jooby; +import org.jooby.spi.Server; +import org.jooby.test.MockUnit; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +import com.google.inject.Binder; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({ServerLookup.class, ConfigFactory.class }) +public class ServerLookupTest { + + private static int calls = 0; + + public static class ServerModule implements Jooby.Module { + + @Override + public void configure(final Env env, final Config config, final Binder binder) { + calls += 1; + } + + } + + @Test + public void configure() throws Exception { + calls = 0; + new MockUnit(Env.class, Config.class, Binder.class) + .expect(unit -> { + Config config = unit.get(Config.class); + expect(config.hasPath("server.module")).andReturn(true); + expect(config.getString("server.module")).andReturn(ServerModule.class.getName()); + }) + .run(unit -> { + new ServerLookup() + .configure(unit.get(Env.class), unit.get(Config.class), unit.get(Binder.class)); + assertEquals(1, calls); + }); + } + + @Test + public void doNothingIfPropertyIsMissing() throws Exception { + calls = 0; + new MockUnit(Env.class, Config.class, Binder.class) + .expect(unit -> { + Config config = unit.get(Config.class); + expect(config.hasPath("server.module")).andReturn(false); + }) + .run(unit -> { + new ServerLookup() + .configure(unit.get(Env.class), unit.get(Config.class), unit.get(Binder.class)); + assertEquals(0, calls); + }); + } + + @Test(expected = ClassNotFoundException.class) + public void failOnBadServerName() throws Exception { + calls = 0; + new MockUnit(Env.class, Config.class, Binder.class) + .expect(unit -> { + Config config = unit.get(Config.class); + expect(config.hasPath("server.module")).andReturn(true); + expect(config.getString("server.module")).andReturn("org.Missing"); + }) + .run(unit -> { + new ServerLookup() + .configure(unit.get(Env.class), unit.get(Config.class), unit.get(Binder.class)); + assertEquals(0, calls); + }); + } + + @Test + public void config() throws Exception { + new MockUnit(Config.class) + .expect(unit -> { + unit.mockStatic(ConfigFactory.class); + + Config serverLookup = unit.mock(Config.class); + + Config defs = unit.mock(Config.class); + expect(serverLookup.withFallback(defs)).andReturn(unit.get(Config.class)); + + expect(ConfigFactory.parseResources(Server.class, "server-defaults.conf")) + .andReturn(defs); + + expect(ConfigFactory.parseResources(Server.class, "server.conf")) + .andReturn(serverLookup); + }) + .run(unit -> { + assertEquals(unit.get(Config.class), new ServerLookup().config()); + }); + } +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/ServerSessionManagerTest.java b/jooby/src/test/java-excluded/org/jooby/internal/ServerSessionManagerTest.java new file mode 100644 index 00000000..1e658228 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/ServerSessionManagerTest.java @@ -0,0 +1,482 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.expectLastCall; +import static org.junit.Assert.assertEquals; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import org.jooby.Cookie; +import org.jooby.Mutant; +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Session; +import org.jooby.Session.Definition; +import org.jooby.Session.Store; +import org.jooby.internal.parser.ParserExecutor; +import org.jooby.test.MockUnit; +import org.jooby.test.MockUnit.Block; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +import com.typesafe.config.Config; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({ServerSessionManager.class, SessionImpl.class, Cookie.class }) +public class ServerSessionManagerTest { + + private Block noSecret = unit -> { + Config config = unit.get(Config.class); + expect(config.hasPath("application.secret")).andReturn(false); + }; + + private Block cookie = unit -> { + Definition session = unit.get(Session.Definition.class); + expect(session.cookie()).andReturn(unit.get(Cookie.Definition.class)); + }; + + private Block storeGet = unit -> { + Store store = unit.get(Store.class); + expect(store.get(unit.get(Session.Builder.class))) + .andReturn(unit.get(SessionImpl.class)); + }; + + @Test + public void newServerSessionManager() throws Exception { + new MockUnit(Config.class, Session.Definition.class, Cookie.Definition.class, + Session.Store.class, ParserExecutor.class) + .expect(noSecret) + .expect(cookie) + .expect(saveInterval(-1L)) + .expect(maxAge(-1)) + .run(unit -> { + new ServerSessionManager(unit.get(Config.class), unit.get(Session.Definition.class), + unit.get(Session.Store.class), unit.get(ParserExecutor.class)); + }); + } + + @Test + public void destroy() throws Exception { + new MockUnit(Config.class, Session.Definition.class, Cookie.Definition.class, + Session.Store.class, ParserExecutor.class, Session.class) + .expect(noSecret) + .expect(cookie) + .expect(saveInterval(-1L)) + .expect(maxAge(-1)) + .expect(unit -> { + Session session = unit.get(Session.class); + expect(session.id()).andReturn("sid"); + + Store store = unit.get(Session.Store.class); + store.delete("sid"); + }) + .run(unit -> { + new ServerSessionManager(unit.get(Config.class), unit.get(Session.Definition.class), + unit.get(Session.Store.class), unit.get(ParserExecutor.class)) + .destroy(unit.get(Session.class)); + }); + } + + @Test + public void storeCreateSession() throws Exception { + new MockUnit(Config.class, Session.Definition.class, Cookie.Definition.class, + Session.Store.class, ParserExecutor.class, RequestScopedSession.class, SessionImpl.class) + .expect(noSecret) + .expect(cookie) + .expect(saveInterval(-1L)) + .expect(maxAge(-1)) + .expect(reqSession()) + .expect(unit -> { + SessionImpl session = unit.get(SessionImpl.class); + session.touch(); + expect(session.isNew()).andReturn(true); + session.aboutToSave(); + + Store store = unit.get(Store.class); + store.create(session); + + session.markAsSaved(); + }) + .run(unit -> { + new ServerSessionManager(unit.get(Config.class), unit.get(Session.Definition.class), + unit.get(Session.Store.class), unit.get(ParserExecutor.class)) + .requestDone(unit.get(RequestScopedSession.class)); + }); + } + + @Test + public void storeDirtySession() throws Exception { + new MockUnit(Config.class, Session.Definition.class, Cookie.Definition.class, + Session.Store.class, ParserExecutor.class, RequestScopedSession.class, SessionImpl.class) + .expect(noSecret) + .expect(cookie) + .expect(saveInterval(-1L)) + .expect(maxAge(-1)) + .expect(reqSession()) + .expect(unit -> { + SessionImpl session = unit.get(SessionImpl.class); + session.touch(); + expect(session.isNew()).andReturn(false); + expect(session.isDirty()).andReturn(true); + session.aboutToSave(); + + Store store = unit.get(Store.class); + store.save(session); + + session.markAsSaved(); + }) + .run(unit -> { + new ServerSessionManager(unit.get(Config.class), unit.get(Session.Definition.class), + unit.get(Session.Store.class), unit.get(ParserExecutor.class)) + .requestDone(unit.get(RequestScopedSession.class)); + }); + } + + @Test + public void storeSaveIntervalSession() throws Exception { + new MockUnit(Config.class, Session.Definition.class, Cookie.Definition.class, + Session.Store.class, ParserExecutor.class, RequestScopedSession.class, SessionImpl.class) + .expect(noSecret) + .expect(cookie) + .expect(saveInterval(-1L)) + .expect(maxAge(-1)) + .expect(reqSession()) + .expect(unit -> { + SessionImpl session = unit.get(SessionImpl.class); + session.touch(); + expect(session.isNew()).andReturn(false); + expect(session.isDirty()).andReturn(false); + expect(session.savedAt()).andReturn(0L); + session.aboutToSave(); + + Store store = unit.get(Store.class); + store.save(session); + + session.markAsSaved(); + }) + .run(unit -> { + new ServerSessionManager(unit.get(Config.class), unit.get(Session.Definition.class), + unit.get(Session.Store.class), unit.get(ParserExecutor.class)) + .requestDone(unit.get(RequestScopedSession.class)); + }); + } + + @Test + public void storeSkipSaveIntervalSession() throws Exception { + new MockUnit(Config.class, Session.Definition.class, Cookie.Definition.class, + Session.Store.class, ParserExecutor.class, RequestScopedSession.class, SessionImpl.class) + .expect(noSecret) + .expect(cookie) + .expect(saveInterval(-1L)) + .expect(maxAge(-1)) + .expect(reqSession()) + .expect(unit -> { + SessionImpl session = unit.get(SessionImpl.class); + session.touch(); + expect(session.isNew()).andReturn(false); + expect(session.isDirty()).andReturn(false); + expect(session.savedAt()).andReturn(Long.MAX_VALUE); + session.markAsSaved(); + }) + .run(unit -> { + new ServerSessionManager(unit.get(Config.class), unit.get(Session.Definition.class), + unit.get(Session.Store.class), unit.get(ParserExecutor.class)) + .requestDone(unit.get(RequestScopedSession.class)); + }); + } + + @Test + public void storeFailure() throws Exception { + new MockUnit(Config.class, Session.Definition.class, Cookie.Definition.class, + Session.Store.class, ParserExecutor.class, RequestScopedSession.class, SessionImpl.class) + .expect(noSecret) + .expect(cookie) + .expect(saveInterval(-1L)) + .expect(maxAge(-1)) + .expect(reqSession()) + .expect(unit -> { + SessionImpl session = unit.get(SessionImpl.class); + session.touch(); + expect(session.isNew()).andReturn(true); + session.aboutToSave(); + Store store = unit.get(Store.class); + store.create(session); + expectLastCall().andThrow(new IllegalStateException("intentional err")); + }) + .run(unit -> { + new ServerSessionManager(unit.get(Config.class), unit.get(Session.Definition.class), + unit.get(Session.Store.class), unit.get(ParserExecutor.class)) + .requestDone(unit.get(RequestScopedSession.class)); + }); + } + + private Block reqSession() { + return unit -> { + RequestScopedSession req = unit.get(RequestScopedSession.class); + expect(req.session()).andReturn(unit.get(SessionImpl.class)); + }; + } + + @Test + public void noSession() throws Exception { + new MockUnit(Config.class, Session.Definition.class, Cookie.Definition.class, + Session.Store.class, ParserExecutor.class, Request.class, Response.class) + .expect(noSecret) + .expect(cookie) + .expect(saveInterval(-1L)) + .expect(maxAge(-1)) + .expect(unit -> { + Cookie.Definition cookie = unit.get(Cookie.Definition.class); + expect(cookie.name()).andReturn(Optional.of("sid")); + + Mutant mutant = unit.mock(Mutant.class); + expect(mutant.toOptional()).andReturn(Optional.empty()); + + Request req = unit.get(Request.class); + expect(req.cookie("sid")).andReturn(mutant); + }) + .run(unit -> { + Session session = new ServerSessionManager(unit.get(Config.class), + unit.get(Session.Definition.class), + unit.get(Session.Store.class), unit.get(ParserExecutor.class)) + .get(unit.get(Request.class), unit.get(Response.class)); + assertEquals(null, session); + }); + } + + @Test + public void getSession() throws Exception { + String id = "xyz"; + new MockUnit(Config.class, Session.Definition.class, Cookie.Definition.class, + Session.Store.class, ParserExecutor.class, Request.class, Response.class, SessionImpl.class) + .expect(noSecret) + .expect(cookie) + .expect(saveInterval(-1L)) + .expect(maxAge(-1)) + .expect(unit -> { + Cookie.Definition cookie = unit.get(Cookie.Definition.class); + expect(cookie.name()).andReturn(Optional.of("sid")); + + Mutant mutant = unit.mock(Mutant.class); + expect(mutant.toOptional()).andReturn(Optional.of(id)); + + Request req = unit.get(Request.class); + expect(req.cookie("sid")).andReturn(mutant); + }) + .expect(sessionBuilder(id, false, -1)) + .expect(storeGet) + .run(unit -> { + Session session = new ServerSessionManager(unit.get(Config.class), + unit.get(Session.Definition.class), + unit.get(Session.Store.class), unit.get(ParserExecutor.class)) + .get(unit.get(Request.class), unit.get(Response.class)); + assertEquals(unit.get(SessionImpl.class), session); + }); + } + + @Test + public void getTouchSessionCookie() throws Exception { + String id = "xyz"; + new MockUnit(Config.class, Session.Definition.class, Cookie.Definition.class, + Session.Store.class, ParserExecutor.class, Request.class, Response.class, SessionImpl.class) + .expect(noSecret) + .expect(cookie) + .expect(saveInterval(-1L)) + .expect(maxAge(30)) + .expect(unit -> { + Cookie.Definition cookie = unit.get(Cookie.Definition.class); + expect(cookie.name()).andReturn(Optional.of("sid")); + + Mutant mutant = unit.mock(Mutant.class); + expect(mutant.toOptional()).andReturn(Optional.of(id)); + + Request req = unit.get(Request.class); + expect(req.cookie("sid")).andReturn(mutant); + }) + .expect(sessionBuilder(id, false, TimeUnit.SECONDS.toMillis(30))) + .expect(storeGet) + .expect(unsignedCookie(id)) + .expect(session(id)) + .expect(sendCookie()) + .run(unit -> { + Session session = new ServerSessionManager(unit.get(Config.class), + unit.get(Session.Definition.class), + unit.get(Session.Store.class), unit.get(ParserExecutor.class)) + .get(unit.get(Request.class), unit.get(Response.class)); + assertEquals(unit.get(SessionImpl.class), session); + }); + } + + @Test + public void getSignedSession() throws Exception { + String id = "xyz"; + new MockUnit(Config.class, Session.Definition.class, Cookie.Definition.class, + Session.Store.class, ParserExecutor.class, Request.class, Response.class, SessionImpl.class) + .expect(secret("querty")) + .expect(cookie) + .expect(saveInterval(-1L)) + .expect(maxAge(-1)) + .expect(unit -> { + Cookie.Definition cookie = unit.get(Cookie.Definition.class); + expect(cookie.name()).andReturn(Optional.of("sid")); + + Mutant mutant = unit.mock(Mutant.class); + expect(mutant.toOptional()).andReturn(Optional.of(id)); + + Request req = unit.get(Request.class); + expect(req.cookie("sid")).andReturn(mutant); + }) + .expect(unit -> { + unit.mockStatic(Cookie.Signature.class); + expect(Cookie.Signature.unsign(id, "querty")).andReturn("unsigned"); + }) + .expect(sessionBuilder("unsigned", false, -1)) + .expect(storeGet) + .run(unit -> { + Session session = new ServerSessionManager(unit.get(Config.class), + unit.get(Session.Definition.class), + unit.get(Session.Store.class), unit.get(ParserExecutor.class)) + .get(unit.get(Request.class), unit.get(Response.class)); + assertEquals(unit.get(SessionImpl.class), session); + }); + } + + @Test + public void createSession() throws Exception { + new MockUnit(Config.class, Session.Definition.class, Cookie.Definition.class, + Session.Store.class, ParserExecutor.class, Request.class, Response.class, SessionImpl.class) + .expect(noSecret) + .expect(cookie) + .expect(saveInterval(-1L)) + .expect(maxAge(-1)) + .expect(genID("123")) + .expect(sessionBuilder("123", true, -1)) + .expect(session("123")) + .expect(unsignedCookie("123")) + .expect(sendCookie()) + .run(unit -> { + new ServerSessionManager(unit.get(Config.class), unit.get(Session.Definition.class), + unit.get(Session.Store.class), unit.get(ParserExecutor.class)) + .create(unit.get(Request.class), unit.get(Response.class)); + }); + } + + @Test + public void createSignedCookieSession() throws Exception { + new MockUnit(Config.class, Session.Definition.class, Cookie.Definition.class, + Session.Store.class, ParserExecutor.class, Request.class, Response.class, SessionImpl.class) + .expect(secret("querty")) + .expect(cookie) + .expect(saveInterval(-1L)) + .expect(maxAge(-1)) + .expect(genID("123")) + .expect(sessionBuilder("123", true, -1)) + .expect(session("123")) + .expect(signCookie("querty", "123", "signed")) + .expect(sendCookie()) + .run(unit -> { + new ServerSessionManager(unit.get(Config.class), unit.get(Session.Definition.class), + unit.get(Session.Store.class), unit.get(ParserExecutor.class)) + .create(unit.get(Request.class), unit.get(Response.class)); + }); + } + + private Block signCookie(final String secret, final String value, final String signed) { + return unit -> { + unit.mockStatic(Cookie.Signature.class); + expect(Cookie.Signature.sign(value, secret)).andReturn(signed); + + Cookie.Definition cookie = unit.get(Cookie.Definition.class); + Cookie.Definition newCookie = unit.constructor(Cookie.Definition.class) + .build(cookie); + + expect(newCookie.value(signed)).andReturn(newCookie); + unit.registerMock(Cookie.Definition.class, newCookie); + }; + } + + private Block secret(final String secret) { + return unit -> { + Config config = unit.get(Config.class); + expect(config.hasPath("application.secret")).andReturn(true); + expect(config.getString("application.secret")).andReturn(secret); + }; + } + + private Block unsignedCookie(final String id) { + return unit -> { + Cookie.Definition cookie = unit.get(Cookie.Definition.class); + Cookie.Definition newCookie = unit.constructor(Cookie.Definition.class) + .build(cookie); + + expect(newCookie.value(id)).andReturn(newCookie); + unit.registerMock(Cookie.Definition.class, newCookie); + }; + } + + private Block sendCookie() { + return unit -> { + Cookie.Definition cookie = unit.get(Cookie.Definition.class); + Response rsp = unit.get(Response.class); + expect(rsp.cookie(cookie)).andReturn(rsp); + }; + } + + private Block session(final String sid) { + return unit -> { + SessionImpl session = unit.get(SessionImpl.class); + expect(session.id()).andReturn(sid); + }; + } + + private Block sessionBuilder(final String id, final boolean isNew, final long timeout) { + return unit -> { + SessionImpl.Builder builder = unit.constructor(SessionImpl.Builder.class) + .build(unit.get(ParserExecutor.class), isNew, id, timeout); + if (isNew) { + expect(builder.build()).andReturn(unit.get(SessionImpl.class)); + } + + unit.registerMock(Session.Builder.class, builder); + }; + } + + private Block genID(final String id) { + return unit -> { + Store store = unit.get(Session.Store.class); + expect(store.generateID()).andReturn(id); + }; + } + + private Block saveInterval(final Long saveInterval) { + return unit -> { + Definition session = unit.get(Session.Definition.class); + expect(session.saveInterval()).andReturn(Optional.of(saveInterval)); + }; + } + + private Block maxAge(final Integer maxAge) { + return unit -> { + Cookie.Definition session = unit.get(Cookie.Definition.class); + expect(session.maxAge()).andReturn(Optional.of(maxAge)); + }; + } +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/SessionImplTest.java b/jooby/src/test/java-excluded/org/jooby/internal/SessionImplTest.java new file mode 100644 index 00000000..ec36bc89 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/SessionImplTest.java @@ -0,0 +1,35 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import org.jooby.internal.parser.ParserExecutor; +import org.jooby.test.MockUnit; +import org.junit.Test; + +/** + * TODO: complete unit tests. + */ +public class SessionImplTest { + + @Test + public void renewIdShouldDoNothing() throws Exception { + new MockUnit(ParserExecutor.class) + .run(unit -> { + new SessionImpl(unit.get(ParserExecutor.class), true, "sid", 0L) + .renewId(); + }); + } +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/StaticMethodTypeConverterTest.java b/jooby/src/test/java-excluded/org/jooby/internal/StaticMethodTypeConverterTest.java new file mode 100644 index 00000000..39695ec7 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/StaticMethodTypeConverterTest.java @@ -0,0 +1,88 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.easymock.EasyMock.eq; +import static org.easymock.EasyMock.expect; +import static org.junit.Assert.assertEquals; + +import org.jooby.internal.parser.LocaleParser; +import org.jooby.internal.parser.StaticMethodParser; +import org.jooby.test.MockUnit; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +import com.google.inject.TypeLiteral; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({StaticMethodTypeConverter.class, LocaleParser.class, + StaticMethodParser.class }) +public class StaticMethodTypeConverterTest { + + @Test + public void toAnythingElse() throws Exception { + TypeLiteral type = TypeLiteral.get(Object.class); + new MockUnit() + .expect(unit -> { + StaticMethodParser converter = unit + .mockConstructor(StaticMethodParser.class, new Class[]{String.class }, + "valueOf"); + expect(converter.parse(eq(type), eq("y"))).andReturn("x"); + }) + .run(unit -> { + assertEquals("x", new StaticMethodTypeConverter("valueOf").convert("y", type)); + }); + } + + @Test(expected = IllegalStateException.class) + public void runtimeError() throws Exception { + TypeLiteral type = TypeLiteral.get(Object.class); + new MockUnit() + .expect(unit -> { + StaticMethodParser converter = unit + .mockConstructor(StaticMethodParser.class, new Class[]{String.class }, + "valueOf"); + expect(converter.parse(eq(type), eq("y"))) + .andThrow(new IllegalArgumentException("intentional err")); + }) + .run(unit -> { + new StaticMethodTypeConverter("valueOf").convert("y", type); + }); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked" }) + public void shouldNotMatchEnums() throws Exception { + TypeLiteral type = TypeLiteral.get(Enum.class); + new MockUnit() + .run(unit -> { + assertEquals(false, new StaticMethodTypeConverter("valueOf").matches(type)); + }); + } + + @Test + public void shouldStaticMethod() throws Exception { + TypeLiteral type = TypeLiteral.get(Package.class); + assertEquals(true, new StaticMethodTypeConverter("getPackage").matches(type)); + } + + @Test + public void describe() throws Exception { + assertEquals("forName(String)", new StaticMethodTypeConverter("forName").toString()); + } +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/StringConstructorTypeConverterTest.java b/jooby/src/test/java-excluded/org/jooby/internal/StringConstructorTypeConverterTest.java new file mode 100644 index 00000000..256bee3a --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/StringConstructorTypeConverterTest.java @@ -0,0 +1,83 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.easymock.EasyMock.expect; +import static org.junit.Assert.assertEquals; + +import java.util.Locale; + +import org.jooby.internal.parser.LocaleParser; +import org.jooby.internal.parser.StringConstructorParser; +import org.jooby.test.MockUnit; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +import com.google.inject.TypeLiteral; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({StringConstructTypeConverter.class, LocaleParser.class, + StringConstructorParser.class }) +public class StringConstructorTypeConverterTest { + + @Test + public void toLocale() throws Exception { + TypeLiteral type = TypeLiteral.get(Locale.class); + new MockUnit() + .run(unit -> { + assertEquals(LocaleUtils.parseOne("x"), + new StringConstructTypeConverter().convert("x", type)); + }); + } + + @Test(expected = IllegalStateException.class) + public void runtimeError() throws Exception { + TypeLiteral type = TypeLiteral.get(Object.class); + new MockUnit() + .expect(unit -> { + unit.mockStatic(StringConstructorParser.class); + expect(StringConstructorParser.parse(type, "y")).andThrow( + new IllegalArgumentException("intentional err")); + }) + .run(unit -> { + new StringConstructTypeConverter().convert("y", type); + }); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked" }) + public void shouldNotMatchMissingStringConstructor() throws Exception { + TypeLiteral type = TypeLiteral.get(StringConstructorTypeConverterTest.class); + new MockUnit() + .run(unit -> { + assertEquals(false, new StringConstructTypeConverter().matches(type)); + }); + } + + @Test + public void shouldMatchStringConstructor() throws Exception { + TypeLiteral type = TypeLiteral.get(Locale.class); + assertEquals(true, new StringConstructTypeConverter().matches(type)); + } + + @Test + public void describe() throws Exception { + assertEquals("TypeConverter init(java.lang.String)", + new StringConstructTypeConverter().toString()); + } +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/ToStringRendererTest.java b/jooby/src/test/java-excluded/org/jooby/internal/ToStringRendererTest.java new file mode 100644 index 00000000..7fb75271 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/ToStringRendererTest.java @@ -0,0 +1,64 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.easymock.EasyMock.expect; + +import org.jooby.MediaType; +import org.jooby.Renderer; +import org.jooby.Results; +import org.jooby.test.MockUnit; +import org.jooby.test.MockUnit.Block; +import org.junit.Test; + +public class ToStringRendererTest { + + private Block defaultType = unit -> { + Renderer.Context ctx = unit.get(Renderer.Context.class); + expect(ctx.type(MediaType.html)).andReturn(ctx); + }; + + @Test + public void render() throws Exception { + Object value = new Object() { + @Override + public String toString() { + return "toString"; + } + }; + new MockUnit(Renderer.Context.class, Object.class) + .expect(defaultType) + .expect(unit -> { + Renderer.Context ctx = unit.get(Renderer.Context.class); + ctx.send("toString"); + }) + .run(unit -> { + BuiltinRenderer.text + .render(value, unit.get(Renderer.Context.class)); + }); + + } + + @Test + public void renderIgnored() throws Exception { + new MockUnit(Renderer.Context.class) + .run(unit -> { + BuiltinRenderer.text + .render(Results.html("v"), unit.get(Renderer.Context.class)); + }); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/URLAssetTest.java b/jooby/src/test/java-excluded/org/jooby/internal/URLAssetTest.java new file mode 100644 index 00000000..bd64c148 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/URLAssetTest.java @@ -0,0 +1,210 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.expectLastCall; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLConnection; + +import org.jooby.MediaType; +import org.jooby.test.MockUnit; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +import com.google.common.io.ByteStreams; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({URLAsset.class, URL.class }) +public class URLAssetTest { + + @Test + public void name() throws Exception { + assertEquals("pom.xml", + new URLAsset(file("pom.xml").toURI().toURL(), "pom.xml", MediaType.js) + .name()); + } + + @Test + public void toStr() throws Exception { + assertEquals("URLAssetTest.js(application/javascript)", + new URLAsset(file("src/test/resources/org/jooby/internal/URLAssetTest.js").toURI().toURL(), + "URLAssetTest.js", MediaType.js) + .toString()); + } + + @Test + public void path() throws Exception { + assertEquals("/path/URLAssetTest.js", new URLAsset(getClass().getResource("URLAssetTest.js"), + "/path/URLAssetTest.js", MediaType.js).path()); + } + + @Test + public void lastModified() throws Exception { + assertTrue(new URLAsset(file("src/test/resources/org/jooby/internal/URLAssetTest.js").toURI() + .toURL(), "URLAssetTest.js", MediaType.js) + .lastModified() > 0); + } + + @Test + public void lastModifiedFileNotFound() throws Exception { + assertTrue(new URLAsset(file("src/test/resources/org/jooby/internal/URLAssetTest.missing") + .toURI().toURL(), "URLAssetTest.missing", MediaType.js) + .lastModified() == -1); + } + + @Test(expected = Exception.class) + public void headerFailNoConnection() throws Exception { + new MockUnit(URL.class) + .expect(unit -> { + URL url = unit.get(URL.class); + expect(url.openConnection()).andThrow(new Exception("intentional err")); + }) + .run(unit -> { + new URLAsset(unit.get(URL.class), "path.js", MediaType.js); + }); + } + + @Test(expected = IllegalStateException.class) + public void headerFailWithConnection() throws Exception { + new MockUnit(URL.class) + .expect(unit -> { + InputStream stream = unit.mock(InputStream.class); + stream.close(); + + URLConnection conn = unit.mock(URLConnection.class); + conn.setUseCaches(false); + expect(conn.getContentLengthLong()).andThrow( + new IllegalStateException("intentional err")); + expect(conn.getInputStream()).andReturn(stream); + + URL url = unit.get(URL.class); + expect(url.getProtocol()).andReturn("http"); + expect(url.openConnection()).andReturn(conn); + }) + .run(unit -> { + new URLAsset(unit.get(URL.class), "pa.ks", MediaType.js); + }); + } + + @Test + public void noLastModifiednoLen() throws Exception { + new MockUnit(URL.class) + .expect(unit -> { + InputStream stream = unit.mock(InputStream.class); + stream.close(); + + URLConnection conn = unit.mock(URLConnection.class); + conn.setUseCaches(false); + expect(conn.getContentLengthLong()).andReturn(0L); + expect(conn.getLastModified()).andReturn(0L); + expect(conn.getInputStream()).andReturn(stream); + + URL url = unit.get(URL.class); + expect(url.getProtocol()).andReturn("http"); + expect(url.openConnection()).andReturn(conn); + }) + .run(unit -> { + URLAsset asset = new URLAsset(unit.get(URL.class), "pa.ks", MediaType.js); + assertEquals(0, asset.length()); + assertEquals(-1, asset.lastModified()); + }); + } + + @Test(expected = IllegalStateException.class) + public void headersStreamCloseFails() throws Exception { + new MockUnit(URL.class) + .expect(unit -> { + InputStream stream = unit.mock(InputStream.class); + stream.close(); + expectLastCall().andThrow(new IOException("ignored")); + + URLConnection conn = unit.mock(URLConnection.class); + conn.setUseCaches(false); + expect(conn.getContentLengthLong()).andThrow( + new IllegalStateException("intentional err")); + expect(conn.getInputStream()).andReturn(stream); + + URL url = unit.get(URL.class); + expect(url.getProtocol()).andReturn("http"); + expect(url.openConnection()).andReturn(conn); + }) + .run(unit -> { + new URLAsset(unit.get(URL.class), "ala.la", MediaType.js); + }); + } + + @Test + public void length() throws Exception { + assertEquals(15, new URLAsset(file("src/test/resources/org/jooby/internal/URLAssetTest.js") + .toURI().toURL(), "URLAssetTest.js", + MediaType.js).length()); + } + + @Test + public void type() throws Exception { + assertEquals(MediaType.js, + new URLAsset(file("src/test/resources/org/jooby/internal/URLAssetTest.js").toURI().toURL(), + "URLAssetTest.js", MediaType.js) + .type()); + } + + @Test + public void stream() throws Exception { + InputStream stream = new URLAsset( + file("src/test/resources/org/jooby/internal/URLAssetTest.js").toURI().toURL(), + "URLAssetTest.js", MediaType.js) + .stream(); + assertEquals("function () {}\n", new String(ByteStreams.toByteArray(stream))); + stream.close(); + } + + @Test(expected = NullPointerException.class) + public void nullFile() throws Exception { + new URLAsset((URL) null, "", MediaType.js); + } + + @Test(expected = NullPointerException.class) + public void nullType() throws Exception { + new URLAsset(file("src/test/resources/org/jooby/internal/URLAssetTest.js").toURI().toURL(), + "", null); + } + + /** + * Attempt to load a file from multiple location. required by unit and integration tests. + * + * @param location + * @return + */ + private File file(final String location) { + for (String candidate : new String[]{location, "jooby/" + location, + "../../jooby/" + location }) { + File file = new File(candidate); + if (file.exists()) { + return file; + } + } + return new File(location); + } +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/UploadImplTest.java b/jooby/src/test/java-excluded/org/jooby/internal/UploadImplTest.java new file mode 100644 index 00000000..4ed3a2f9 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/UploadImplTest.java @@ -0,0 +1,190 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.easymock.EasyMock.expect; +import static org.junit.Assert.assertEquals; + +import java.io.File; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import org.jooby.MediaType; +import org.jooby.Mutant; +import org.jooby.internal.parser.ParserExecutor; +import org.jooby.spi.NativeUpload; +import org.jooby.test.MockUnit; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +import com.google.inject.Injector; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({UploadImpl.class, MutantImpl.class }) +public class UploadImplTest { + + @Test + public void close() throws Exception { + new MockUnit(Injector.class, NativeUpload.class) + .expect(unit -> { + unit.get(NativeUpload.class).close(); + }) + .run(unit -> { + new UploadImpl(unit.get(Injector.class), unit.get(NativeUpload.class)).close(); + }); + } + + @Test + public void name() throws Exception { + new MockUnit(Injector.class, NativeUpload.class) + .expect(unit -> { + expect(unit.get(NativeUpload.class).name()).andReturn("x"); + }) + .run(unit -> { + assertEquals("x", + new UploadImpl(unit.get(Injector.class), unit.get(NativeUpload.class)).name()); + }); + } + + @Test + public void describe() throws Exception { + new MockUnit(Injector.class, NativeUpload.class) + .expect(unit -> { + expect(unit.get(NativeUpload.class).name()).andReturn("x"); + }) + .run(unit -> { + assertEquals("x", + new UploadImpl(unit.get(Injector.class), unit.get(NativeUpload.class)).toString()); + }); + } + + @Test + public void file() throws Exception { + File f = new File("x"); + new MockUnit(Injector.class, NativeUpload.class) + .expect(unit -> { + expect(unit.get(NativeUpload.class).file()).andReturn(f); + }) + .run(unit -> { + assertEquals(f, + new UploadImpl(unit.get(Injector.class), unit.get(NativeUpload.class)).file()); + }); + } + + @Test + public void type() throws Exception { + new MockUnit(Injector.class, NativeUpload.class, ParserExecutor.class) + .expect(unit -> { + expect(unit.get(Injector.class).getInstance(ParserExecutor.class)).andReturn( + unit.get(ParserExecutor.class)); + }) + .expect( + unit -> { + NativeUpload upload = unit.get(NativeUpload.class); + + List headers = Arrays.asList("application/json"); + expect(upload.headers("Content-Type")).andReturn(headers); + + StrParamReferenceImpl pref = unit.mockConstructor(StrParamReferenceImpl.class, + new Class[]{ + String.class, String.class, List.class }, + "header", "Content-Type", headers); + + Mutant mutant = unit.mockConstructor(MutantImpl.class, + new Class[]{ParserExecutor.class, Object.class }, + unit.get(ParserExecutor.class), pref); + + expect(mutant.toOptional(MediaType.class)) + .andReturn(Optional.ofNullable(MediaType.json)); + }) + .run(unit -> { + assertEquals(MediaType.json, + new UploadImpl(unit.get(Injector.class), unit.get(NativeUpload.class)).type()); + }); + } + + @Test + public void deftype() throws Exception { + new MockUnit(Injector.class, NativeUpload.class, ParserExecutor.class) + .expect(unit -> { + expect(unit.get(Injector.class).getInstance(ParserExecutor.class)).andReturn( + unit.get(ParserExecutor.class)); + }) + .expect(unit -> { + expect(unit.get(NativeUpload.class).name()).andReturn("x"); + }) + .expect(unit -> { + NativeUpload upload = unit.get(NativeUpload.class); + + List headers = Arrays.asList(); + expect(upload.headers("Content-Type")).andReturn(headers); + + StrParamReferenceImpl pref = unit.mockConstructor(StrParamReferenceImpl.class, + new Class[]{ + String.class, String.class, List.class }, + "header", "Content-Type", headers); + + Mutant mutant = unit.mockConstructor(MutantImpl.class, + new Class[]{ParserExecutor.class, Object.class }, + unit.get(ParserExecutor.class), pref); + + expect(mutant.toOptional(MediaType.class)) + .andReturn(Optional.ofNullable(null)); + }) + .run(unit -> { + assertEquals(MediaType.octetstream, + new UploadImpl(unit.get(Injector.class), unit.get(NativeUpload.class)).type()); + }); + } + + @Test + public void typeFromName() throws Exception { + new MockUnit(Injector.class, NativeUpload.class, ParserExecutor.class) + .expect(unit -> { + expect(unit.get(Injector.class).getInstance(ParserExecutor.class)).andReturn( + unit.get(ParserExecutor.class)); + }) + .expect(unit -> { + expect(unit.get(NativeUpload.class).name()).andReturn("x.js"); + }) + .expect(unit -> { + NativeUpload upload = unit.get(NativeUpload.class); + + List headers = Arrays.asList(); + expect(upload.headers("Content-Type")).andReturn(headers); + + StrParamReferenceImpl pref = unit.mockConstructor(StrParamReferenceImpl.class, + new Class[]{ + String.class, String.class, List.class }, + "header", "Content-Type", headers); + + Mutant mutant = unit.mockConstructor(MutantImpl.class, + new Class[]{ParserExecutor.class, Object.class }, + unit.get(ParserExecutor.class), pref); + + expect(mutant.toOptional(MediaType.class)) + .andReturn(Optional.ofNullable(null)); + }) + .run(unit -> { + assertEquals(MediaType.js, + new UploadImpl(unit.get(Injector.class), unit.get(NativeUpload.class)).type()); + }); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/WebSocketImplTest.java b/jooby/src/test/java-excluded/org/jooby/internal/WebSocketImplTest.java new file mode 100644 index 00000000..e770beb0 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/WebSocketImplTest.java @@ -0,0 +1,757 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import com.google.common.collect.ImmutableMap; +import com.google.inject.Injector; +import com.google.inject.Key; +import com.google.inject.TypeLiteral; +import static org.easymock.EasyMock.eq; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.expectLastCall; +import static org.easymock.EasyMock.isA; +import org.jooby.Err; +import org.jooby.MediaType; +import org.jooby.Mutant; +import org.jooby.Renderer; +import org.jooby.Request; +import org.jooby.WebSocket; +import org.jooby.WebSocket.CloseStatus; +import org.jooby.WebSocket.OnClose; +import org.jooby.WebSocket.OnMessage; +import org.jooby.internal.parser.ParserExecutor; +import org.jooby.spi.NativeWebSocket; +import org.jooby.test.MockUnit; +import org.jooby.test.MockUnit.Block; +import org.junit.After; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +import java.lang.reflect.Field; +import java.nio.channels.ClosedChannelException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +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.function.BiConsumer; +import java.util.function.Consumer; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({WebSocketImpl.class, WebSocketRendererContext.class}) +public class WebSocketImplTest { + + private Block connect = unit -> { + WebSocket.OnOpen1 handler = unit.get(WebSocket.OnOpen1.class); + handler.onOpen(eq(unit.get(Request.class)), isA(WebSocketImpl.class)); + + Injector injector = unit.get(Injector.class); + + expect(injector.getInstance(Key.get(new TypeLiteral>() { + }))).andReturn(Collections.emptySet()); + + }; + + @SuppressWarnings("unchecked") + private Block callbacks = unit -> { + NativeWebSocket nws = unit.get(NativeWebSocket.class); + nws.onBinaryMessage(isA(Consumer.class)); + nws.onTextMessage(isA(Consumer.class)); + nws.onErrorMessage(isA(Consumer.class)); + nws.onCloseMessage(isA(BiConsumer.class)); + }; + + private Block locale = unit -> { + Request req = unit.get(Request.class); + expect(req.locale()).andReturn(Locale.CANADA); + }; + + @SuppressWarnings({"resource"}) + @Test + public void sendString() throws Exception { + Object data = "String"; + String path = "/"; + String pattern = "/pattern"; + Map vars = new HashMap<>(); + MediaType consumes = MediaType.all; + MediaType produces = MediaType.all; + new MockUnit(WebSocket.OnOpen1.class, WebSocket.SuccessCallback.class, + WebSocket.OnError.class, Injector.class, Request.class, NativeWebSocket.class) + .expect(connect) + .expect(callbacks) + .expect(locale) + .expect(unit -> { + List renderers = Collections.emptyList(); + + NativeWebSocket ws = unit.get(NativeWebSocket.class); + expect(ws.isOpen()).andReturn(true); + + WebSocketRendererContext ctx = unit.mockConstructor(WebSocketRendererContext.class, + new Class[]{List.class, NativeWebSocket.class, MediaType.class, Charset.class, + Locale.class, + WebSocket.SuccessCallback.class, + WebSocket.OnError.class}, + renderers, ws, + produces, StandardCharsets.UTF_8, + Locale.CANADA, + unit.get(WebSocket.SuccessCallback.class), + unit.get(WebSocket.OnError.class)); + ctx.render(data); + }) + .run(unit -> { + WebSocketImpl ws = new WebSocketImpl( + unit.get(WebSocket.OnOpen1.class), path, pattern, vars, consumes, produces); + ws.connect(unit.get(Injector.class), unit.get(Request.class), + unit.get(NativeWebSocket.class)); + + ws.send(data, unit.get(WebSocket.SuccessCallback.class), + unit.get(WebSocket.OnError.class)); + }); + } + + @SuppressWarnings("unchecked") + @Before + @After + public void resetSessions() throws Exception { + Field field = WebSocketImpl.class.getDeclaredField("sessions"); + field.setAccessible(true); + Map> sessions = (Map>) field.get(null); + sessions.clear(); + } + + @SuppressWarnings({"resource"}) + @Test + public void sendBroadcast() throws Exception { + Object data = "String"; + String path = "/"; + String pattern = "/pattern"; + Map vars = new HashMap<>(); + MediaType consumes = MediaType.all; + MediaType produces = MediaType.all; + new MockUnit(WebSocket.OnOpen1.class, WebSocket.SuccessCallback.class, + WebSocket.OnError.class, Injector.class, Request.class, NativeWebSocket.class) + .expect(connect) + .expect(callbacks) + .expect(locale) + .expect(unit -> { + List renderers = Collections.emptyList(); + + NativeWebSocket ws = unit.get(NativeWebSocket.class); + expect(ws.isOpen()).andReturn(true); + + WebSocketRendererContext ctx = unit.mockConstructor(WebSocketRendererContext.class, + new Class[]{List.class, NativeWebSocket.class, MediaType.class, Charset.class, + Locale.class, + WebSocket.SuccessCallback.class, + WebSocket.OnError.class}, + renderers, ws, + produces, StandardCharsets.UTF_8, + Locale.CANADA, + unit.get(WebSocket.SuccessCallback.class), + unit.get(WebSocket.OnError.class)); + ctx.render(data); + }) + .run(unit -> { + WebSocketImpl ws = new WebSocketImpl( + unit.get(WebSocket.OnOpen1.class), path, pattern, vars, consumes, produces); + ws.connect(unit.get(Injector.class), unit.get(Request.class), + unit.get(NativeWebSocket.class)); + + ws.broadcast(data, unit.get(WebSocket.SuccessCallback.class), + unit.get(WebSocket.OnError.class)); + }); + } + + @SuppressWarnings({"resource"}) + @Test + public void sendBroadcastErr() throws Exception { + Object data = "String"; + String path = "/"; + String pattern = "/pattern"; + Map vars = new HashMap<>(); + MediaType consumes = MediaType.all; + MediaType produces = MediaType.all; + new MockUnit(WebSocket.OnOpen1.class, WebSocket.SuccessCallback.class, + WebSocket.OnError.class, Injector.class, Request.class, NativeWebSocket.class) + .expect(connect) + .expect(callbacks) + .expect(locale) + .expect(unit -> { + List renderers = Collections.emptyList(); + + NativeWebSocket ws = unit.get(NativeWebSocket.class); + expect(ws.isOpen()).andReturn(true); + + WebSocketRendererContext ctx = unit.mockConstructor(WebSocketRendererContext.class, + new Class[]{List.class, NativeWebSocket.class, MediaType.class, Charset.class, + Locale.class, + WebSocket.SuccessCallback.class, + WebSocket.OnError.class}, + renderers, ws, + produces, StandardCharsets.UTF_8, + Locale.CANADA, + unit.get(WebSocket.SuccessCallback.class), + unit.get(WebSocket.OnError.class)); + ctx.render(data); + IllegalStateException x = new IllegalStateException("intentional err"); + expectLastCall().andThrow(x); + unit.get(WebSocket.OnError.class).onError(x); + }) + .run(unit -> { + WebSocketImpl ws = new WebSocketImpl( + unit.get(WebSocket.OnOpen1.class), path, pattern, vars, consumes, produces); + ws.connect(unit.get(Injector.class), unit.get(Request.class), + unit.get(NativeWebSocket.class)); + + ws.broadcast(data, unit.get(WebSocket.SuccessCallback.class), + unit.get(WebSocket.OnError.class)); + }); + } + + @SuppressWarnings({"resource"}) + @Test(expected = Err.class) + public void sendClose() throws Exception { + Object data = "String"; + String path = "/"; + String pattern = "/pattern"; + Map vars = new HashMap<>(); + MediaType consumes = MediaType.all; + MediaType produces = MediaType.all; + new MockUnit(WebSocket.OnOpen1.class, WebSocket.SuccessCallback.class, + WebSocket.OnError.class, Injector.class, Request.class, NativeWebSocket.class) + .expect(connect) + .expect(callbacks) + .expect(locale) + .expect(unit -> { + NativeWebSocket ws = unit.get(NativeWebSocket.class); + expect(ws.isOpen()).andReturn(false); + }) + .run(unit -> { + WebSocketImpl ws = new WebSocketImpl( + unit.get(WebSocket.OnOpen1.class), path, pattern, vars, consumes, produces); + ws.connect(unit.get(Injector.class), unit.get(Request.class), + unit.get(NativeWebSocket.class)); + + ws.send(data, unit.get(WebSocket.SuccessCallback.class), + unit.get(WebSocket.OnError.class)); + }); + } + + @SuppressWarnings("resource") + @Test + public void toStr() throws Exception { + String path = "/"; + String pattern = "/pattern"; + Map vars = new HashMap<>(); + MediaType consumes = MediaType.all; + MediaType produces = MediaType.all; + + new MockUnit(WebSocket.OnOpen1.class) + .run(unit -> { + WebSocketImpl ws = new WebSocketImpl( + unit.get(WebSocket.OnOpen1.class), path, pattern, vars, consumes, produces); + assertEquals("WS /\n" + + " pattern: /pattern\n" + + " vars: {}\n" + + " consumes: */*\n" + + " produces: */*\n" + + "", ws.toString()); + }); + } + + @SuppressWarnings("resource") + @Test + public void attributes() throws Exception { + String path = "/"; + String pattern = "/pattern"; + Map vars = new HashMap<>(); + MediaType consumes = MediaType.all; + MediaType produces = MediaType.all; + + new MockUnit(WebSocket.OnOpen1.class) + .run(unit -> { + WebSocketImpl ws = new WebSocketImpl( + unit.get(WebSocket.OnOpen1.class), path, pattern, vars, consumes, produces); + assertEquals(ImmutableMap.of(), ws.attributes()); + + ws.set("foo", "bar"); + assertEquals("bar", ws.get("foo")); + assertEquals(Optional.empty(), ws.ifGet("bar")); + assertEquals(Optional.of("bar"), ws.unset("foo")); + assertEquals(ImmutableMap.of(), ws.attributes()); + ws.set("foo", "bar"); + ws.unset(); + assertEquals(ImmutableMap.of(), ws.attributes()); + + try { + ws.get("foo"); + fail(); + } catch (NullPointerException x) { + + } + }); + } + + @SuppressWarnings({"resource"}) + @Test + public void isOpen() throws Exception { + String path = "/"; + String pattern = "/pattern"; + Map vars = new HashMap<>(); + MediaType consumes = MediaType.all; + MediaType produces = MediaType.all; + new MockUnit(WebSocket.OnOpen1.class, WebSocket.SuccessCallback.class, + WebSocket.OnError.class, Injector.class, Request.class, NativeWebSocket.class) + .expect(connect) + .expect(callbacks) + .expect(locale) + .expect(unit -> { + NativeWebSocket ws = unit.get(NativeWebSocket.class); + expect(ws.isOpen()).andReturn(true); + }) + .run(unit -> { + WebSocketImpl ws = new WebSocketImpl( + unit.get(WebSocket.OnOpen1.class), path, pattern, vars, consumes, produces); + ws.connect(unit.get(Injector.class), unit.get(Request.class), + unit.get(NativeWebSocket.class)); + + assertTrue(ws.isOpen()); + }); + } + + @SuppressWarnings("resource") + @Test + public void pauseAndResume() throws Exception { + String path = "/"; + String pattern = "/pattern"; + Map vars = new HashMap<>(); + MediaType consumes = MediaType.all; + MediaType produces = MediaType.all; + + new MockUnit(WebSocket.OnOpen1.class, Injector.class, Request.class, NativeWebSocket.class) + .expect(connect) + .expect(callbacks) + .expect(locale) + .expect(unit -> { + NativeWebSocket channel = unit.get(NativeWebSocket.class); + channel.pause(); + + channel.resume(); + }) + .run(unit -> { + WebSocketImpl ws = new WebSocketImpl( + unit.get(WebSocket.OnOpen1.class), path, pattern, vars, consumes, produces); + ws.connect(unit.get(Injector.class), unit.get(Request.class), + unit.get(NativeWebSocket.class)); + ws.pause(); + + ws.pause(); + + ws.resume(); + + ws.resume(); + }); + } + + @Test + public void close() throws Exception { + String path = "/"; + String pattern = "/pattern"; + Map vars = new HashMap<>(); + MediaType consumes = MediaType.all; + MediaType produces = MediaType.all; + + new MockUnit(WebSocket.OnOpen1.class, Injector.class, Request.class, NativeWebSocket.class) + .expect(connect) + .expect(callbacks) + .expect(locale) + .expect(unit -> { + NativeWebSocket ws = unit.get(NativeWebSocket.class); + ws.close(WebSocket.NORMAL.code(), WebSocket.NORMAL.reason()); + }).run(unit -> { + WebSocketImpl ws = new WebSocketImpl( + unit.get(WebSocket.OnOpen1.class), path, pattern, vars, consumes, produces); + ws.connect(unit.get(Injector.class), unit.get(Request.class), + unit.get(NativeWebSocket.class)); + ws.close(WebSocket.NORMAL); + }); + } + + @SuppressWarnings("resource") + @Test + public void terminate() throws Exception { + String path = "/"; + String pattern = "/pattern"; + Map vars = new HashMap<>(); + MediaType consumes = MediaType.all; + MediaType produces = MediaType.all; + + new MockUnit(WebSocket.OnOpen1.class, Injector.class, Request.class, NativeWebSocket.class) + .expect(connect) + .expect(callbacks) + .expect(locale) + .expect(unit -> { + NativeWebSocket ws = unit.get(NativeWebSocket.class); + ws.terminate(); + }) + .run(unit -> { + WebSocketImpl ws = new WebSocketImpl( + unit.get(WebSocket.OnOpen1.class), path, pattern, vars, consumes, produces); + ws.connect(unit.get(Injector.class), unit.get(Request.class), + unit.get(NativeWebSocket.class)); + ws.terminate(); + }); + } + + @SuppressWarnings("resource") + @Test + public void props() throws Exception { + String path = "/"; + String pattern = "/pattern"; + Map vars = new HashMap<>(); + MediaType consumes = MediaType.all; + MediaType produces = MediaType.all; + + new MockUnit(WebSocket.OnOpen1.class) + .run(unit -> { + WebSocketImpl ws = new WebSocketImpl( + unit.get(WebSocket.OnOpen1.class), path, pattern, vars, consumes, produces); + assertEquals(pattern, ws.pattern()); + assertEquals(path, ws.path()); + assertEquals(consumes, ws.consumes()); + assertEquals(produces, ws.produces()); + }); + } + + @SuppressWarnings({"resource", "unchecked"}) + @Test + public void require() throws Exception { + String path = "/"; + String pattern = "/pattern"; + Map vars = new HashMap<>(); + MediaType consumes = MediaType.all; + MediaType produces = MediaType.all; + Object instance = new Object(); + + new MockUnit(WebSocket.OnOpen1.class, Injector.class, Request.class, NativeWebSocket.class) + .expect(connect) + .expect(locale) + .expect(unit -> { + NativeWebSocket nws = unit.get(NativeWebSocket.class); + nws.onBinaryMessage(isA(Consumer.class)); + nws.onTextMessage(isA(Consumer.class)); + nws.onErrorMessage(isA(Consumer.class)); + nws.onCloseMessage(isA(BiConsumer.class)); + }) + .expect(unit -> { + Injector injector = unit.get(Injector.class); + expect(injector.getInstance(Key.get(Object.class))).andReturn(instance); + }) + .run(unit -> { + WebSocketImpl ws = new WebSocketImpl( + unit.get(WebSocket.OnOpen1.class), path, pattern, vars, consumes, produces); + ws.connect(unit.get(Injector.class), unit.get(Request.class), + unit.get(NativeWebSocket.class)); + assertEquals(instance, ws.require(Object.class)); + }); + } + + @SuppressWarnings({"resource", "unchecked"}) + @Test + public void onMessage() throws Exception { + String path = "/"; + String pattern = "/pattern"; + Map vars = new HashMap<>(); + MediaType consumes = MediaType.all; + MediaType produces = MediaType.all; + + new MockUnit(WebSocket.OnOpen1.class, Injector.class, OnMessage.class, Request.class, + NativeWebSocket.class, + Mutant.class) + .expect(connect) + .expect(locale) + .expect(unit -> { + NativeWebSocket nws = unit.get(NativeWebSocket.class); + nws.onBinaryMessage(isA(Consumer.class)); + nws.onTextMessage(unit.capture(Consumer.class)); + nws.onErrorMessage(isA(Consumer.class)); + nws.onCloseMessage(isA(BiConsumer.class)); + }) + .expect(unit -> { + OnMessage callback = unit.get(OnMessage.class); + callback.onMessage(isA(Mutant.class)); + }) + .expect(unit -> { + Injector injector = unit.get(Injector.class); + expect(injector.getInstance(ParserExecutor.class)).andReturn( + unit.mock(ParserExecutor.class)); + }) + .run(unit -> { + WebSocketImpl ws = new WebSocketImpl( + unit.get(WebSocket.OnOpen1.class), path, pattern, vars, consumes, produces); + ws.connect(unit.get(Injector.class), unit.get(Request.class), + unit.get(NativeWebSocket.class)); + ws.onMessage(unit.get(OnMessage.class)); + }, unit -> { + unit.captured(Consumer.class).iterator().next().accept("something"); + }); + } + + @SuppressWarnings({"resource", "unchecked"}) + @Test + public void onErr() throws Exception { + String path = "/"; + String pattern = "/pattern"; + Map vars = new HashMap<>(); + MediaType consumes = MediaType.all; + MediaType produces = MediaType.all; + Exception ex = new Exception(); + + new MockUnit(WebSocket.OnOpen1.class, Injector.class, Request.class, NativeWebSocket.class, + WebSocket.OnError.class) + .expect(connect) + .expect(locale) + .expect(unit -> { + NativeWebSocket nws = unit.get(NativeWebSocket.class); + nws.onBinaryMessage(isA(Consumer.class)); + nws.onTextMessage(isA(Consumer.class)); + nws.onErrorMessage(unit.capture(Consumer.class)); + nws.onCloseMessage(isA(BiConsumer.class)); + + expect(nws.isOpen()).andReturn(false); + }) + .expect(unit -> { + WebSocket.OnError callback = unit.get(WebSocket.OnError.class); + callback.onError(ex); + }) + .run(unit -> { + WebSocketImpl ws = new WebSocketImpl( + unit.get(WebSocket.OnOpen1.class), path, pattern, vars, consumes, produces); + ws.connect(unit.get(Injector.class), unit.get(Request.class), + unit.get(NativeWebSocket.class)); + ws.onError(unit.get(WebSocket.OnError.class)); + }, unit -> { + unit.captured(Consumer.class).iterator().next().accept(ex); + }); + } + + @SuppressWarnings({"resource", "unchecked"}) + @Test + public void onSilentErr() throws Exception { + String path = "/"; + String pattern = "/pattern"; + Map vars = new HashMap<>(); + MediaType consumes = MediaType.all; + MediaType produces = MediaType.all; + Exception ex = new ClosedChannelException(); + + new MockUnit(WebSocket.OnOpen1.class, Injector.class, Request.class, NativeWebSocket.class, + WebSocket.OnError.class) + .expect(connect) + .expect(locale) + .expect(unit -> { + NativeWebSocket nws = unit.get(NativeWebSocket.class); + nws.onBinaryMessage(isA(Consumer.class)); + nws.onTextMessage(isA(Consumer.class)); + nws.onErrorMessage(unit.capture(Consumer.class)); + nws.onCloseMessage(isA(BiConsumer.class)); + + expect(nws.isOpen()).andReturn(false); + }) + .run(unit -> { + WebSocketImpl ws = new WebSocketImpl( + unit.get(WebSocket.OnOpen1.class), path, pattern, vars, consumes, produces); + ws.connect(unit.get(Injector.class), unit.get(Request.class), + unit.get(NativeWebSocket.class)); + ws.onError(unit.get(WebSocket.OnError.class)); + }, unit -> { + unit.captured(Consumer.class).iterator().next().accept(ex); + }); + } + + @SuppressWarnings({"resource", "unchecked"}) + @Test + public void onErrAndWsOpen() throws Exception { + String path = "/"; + String pattern = "/pattern"; + Map vars = new HashMap<>(); + MediaType consumes = MediaType.all; + MediaType produces = MediaType.all; + Exception ex = new Exception(); + + new MockUnit(WebSocket.OnOpen1.class, Injector.class, Request.class, NativeWebSocket.class, + WebSocket.OnError.class) + .expect(connect) + .expect(locale) + .expect(unit -> { + NativeWebSocket nws = unit.get(NativeWebSocket.class); + nws.onBinaryMessage(isA(Consumer.class)); + nws.onTextMessage(isA(Consumer.class)); + nws.onErrorMessage(unit.capture(Consumer.class)); + nws.onCloseMessage(isA(BiConsumer.class)); + + expect(nws.isOpen()).andReturn(true); + nws.close(1011, "Server error"); + }) + .expect(unit -> { + WebSocket.OnError callback = unit.get(WebSocket.OnError.class); + callback.onError(ex); + }) + .run(unit -> { + WebSocketImpl ws = new WebSocketImpl( + unit.get(WebSocket.OnOpen1.class), path, pattern, vars, consumes, produces); + ws.connect(unit.get(Injector.class), unit.get(Request.class), + unit.get(NativeWebSocket.class)); + ws.onError(unit.get(WebSocket.OnError.class)); + }, unit -> { + unit.captured(Consumer.class).iterator().next().accept(ex); + }); + } + + @SuppressWarnings({"resource", "unchecked"}) + @Test + public void onClose() throws Exception { + String path = "/"; + String pattern = "/pattern"; + Map vars = new HashMap<>(); + MediaType consumes = MediaType.all; + MediaType produces = MediaType.all; + WebSocket.CloseStatus status = WebSocket.NORMAL; + + new MockUnit(WebSocket.OnOpen1.class, OnMessage.class, OnClose.class, Request.class, + NativeWebSocket.class, Injector.class) + .expect(connect) + .expect(locale) + .expect(unit -> { + NativeWebSocket nws = unit.get(NativeWebSocket.class); + nws.onBinaryMessage(isA(Consumer.class)); + nws.onTextMessage(isA(Consumer.class)); + nws.onErrorMessage(isA(Consumer.class)); + nws.onCloseMessage(unit.capture(BiConsumer.class)); + }) + .expect(unit -> { + OnClose callback = unit.get(OnClose.class); + callback.onClose(unit.capture(WebSocket.CloseStatus.class)); + }) + .run(unit -> { + WebSocketImpl ws = new WebSocketImpl( + unit.get(WebSocket.OnOpen1.class), path, pattern, vars, consumes, produces); + ws.connect(unit.get(Injector.class), unit.get(Request.class), + unit.get(NativeWebSocket.class)); + ws.onClose(unit.get(WebSocket.OnClose.class)); + }, unit -> { + unit.captured(BiConsumer.class).iterator().next() + .accept(status.code(), Optional.of(status.reason())); + }, unit -> { + CloseStatus captured = unit.captured(WebSocket.CloseStatus.class).iterator().next(); + assertEquals(status.code(), captured.code()); + assertEquals(status.reason(), captured.reason()); + }); + } + + @SuppressWarnings({"resource", "unchecked"}) + @Test + public void onCloseNullReason() throws Exception { + String path = "/"; + String pattern = "/pattern"; + Map vars = new HashMap<>(); + MediaType consumes = MediaType.all; + MediaType produces = MediaType.all; + WebSocket.CloseStatus status = WebSocket.CloseStatus.of(1000); + + new MockUnit(WebSocket.OnOpen1.class, OnMessage.class, OnClose.class, NativeWebSocket.class, + Request.class, Injector.class) + .expect(connect) + .expect(locale) + .expect(unit -> { + NativeWebSocket nws = unit.get(NativeWebSocket.class); + nws.onBinaryMessage(isA(Consumer.class)); + nws.onTextMessage(isA(Consumer.class)); + nws.onErrorMessage(isA(Consumer.class)); + nws.onCloseMessage(unit.capture(BiConsumer.class)); + }) + .expect(unit -> { + OnClose callback = unit.get(OnClose.class); + callback.onClose(unit.capture(WebSocket.CloseStatus.class)); + }) + .run(unit -> { + WebSocketImpl ws = new WebSocketImpl( + unit.get(WebSocket.OnOpen1.class), path, pattern, vars, consumes, produces); + ws.connect(unit.get(Injector.class), unit.get(Request.class), + unit.get(NativeWebSocket.class)); + ws.onClose(unit.get(OnClose.class)); + }, unit -> { + unit.captured(BiConsumer.class).iterator().next() + .accept(status.code(), Optional.empty()); + }, unit -> { + CloseStatus captured = unit.captured(WebSocket.CloseStatus.class).iterator().next(); + assertEquals(status.code(), captured.code()); + assertEquals(null, captured.reason()); + }); + } + + @SuppressWarnings({"resource", "unchecked"}) + @Test + public void onCloseEmptyReason() throws Exception { + String path = "/"; + String pattern = "/pattern"; + Map vars = new HashMap<>(); + MediaType consumes = MediaType.all; + MediaType produces = MediaType.all; + WebSocket.CloseStatus status = WebSocket.CloseStatus.of(1000, ""); + + new MockUnit(WebSocket.OnOpen1.class, OnMessage.class, NativeWebSocket.class, Request.class, + Injector.class, OnClose.class) + .expect(connect) + .expect(locale) + .expect(unit -> { + NativeWebSocket nws = unit.get(NativeWebSocket.class); + nws.onBinaryMessage(isA(Consumer.class)); + nws.onTextMessage(isA(Consumer.class)); + nws.onErrorMessage(isA(Consumer.class)); + nws.onCloseMessage(unit.capture(BiConsumer.class)); + }) + .expect(unit -> { + OnClose callback = unit.get(OnClose.class); + callback.onClose(unit.capture(WebSocket.CloseStatus.class)); + }) + .run(unit -> { + WebSocketImpl ws = new WebSocketImpl( + unit.get(WebSocket.OnOpen1.class), path, pattern, vars, consumes, produces); + ws.connect(unit.get(Injector.class), unit.get(Request.class), + unit.get(NativeWebSocket.class)); + ws.onClose(unit.get(OnClose.class)); + }, unit -> { + unit.captured(BiConsumer.class).iterator().next() + .accept(status.code(), Optional.of("")); + }, unit -> { + CloseStatus captured = unit.captured(WebSocket.CloseStatus.class).iterator().next(); + assertEquals(status.code(), captured.code()); + assertEquals(null, captured.reason()); + }); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/WebSocketRendererContextTest.java b/jooby/src/test/java-excluded/org/jooby/internal/WebSocketRendererContextTest.java new file mode 100644 index 00000000..743416b7 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/WebSocketRendererContextTest.java @@ -0,0 +1,171 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.MappedByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.channels.ReadableByteChannel; +import java.nio.channels.WritableByteChannel; +import java.nio.charset.StandardCharsets; +import java.util.Locale; + +import org.jooby.MediaType; +import org.jooby.Renderer; +import org.jooby.WebSocket; +import org.jooby.spi.NativeWebSocket; +import org.jooby.test.MockUnit; +import org.junit.Test; + +import com.google.common.collect.Lists; + +public class WebSocketRendererContextTest { + + @Test(expected = UnsupportedOperationException.class) + public void fileChannel() throws Exception { + MediaType produces = MediaType.json; + new MockUnit(Renderer.class, NativeWebSocket.class, WebSocket.SuccessCallback.class, + WebSocket.OnError.class) + .run(unit -> { + WebSocketRendererContext ctx = new WebSocketRendererContext( + Lists.newArrayList(unit.get(Renderer.class)), + unit.get(NativeWebSocket.class), + produces, + StandardCharsets.UTF_8, + Locale.US, + unit.get(WebSocket.SuccessCallback.class), + unit.get(WebSocket.OnError.class)); + ctx.send(newFileChannel()); + }); + } + + @Test(expected = UnsupportedOperationException.class) + public void inputStream() throws Exception { + MediaType produces = MediaType.json; + new MockUnit(Renderer.class, NativeWebSocket.class, WebSocket.SuccessCallback.class, + WebSocket.OnError.class, InputStream.class) + .run(unit -> { + WebSocketRendererContext ctx = new WebSocketRendererContext( + Lists.newArrayList(unit.get(Renderer.class)), + unit.get(NativeWebSocket.class), + produces, + StandardCharsets.UTF_8, + Locale.US, + unit.get(WebSocket.SuccessCallback.class), + unit.get(WebSocket.OnError.class)); + ctx.send(unit.get(InputStream.class)); + }); + } + + private FileChannel newFileChannel() { + return new FileChannel() { + @Override + public int read(final ByteBuffer dst) throws IOException { + return 0; + } + + @Override + public long read(final ByteBuffer[] dsts, final int offset, final int length) + throws IOException { + return 0; + } + + @Override + public int write(final ByteBuffer src) throws IOException { + return 0; + } + + @Override + public long write(final ByteBuffer[] srcs, final int offset, final int length) + throws IOException { + return 0; + } + + @Override + public long position() throws IOException { + return 0; + } + + @Override + public FileChannel position(final long newPosition) throws IOException { + return null; + } + + @Override + public long size() throws IOException { + return 0; + } + + @Override + public FileChannel truncate(final long size) throws IOException { + return null; + } + + @Override + public void force(final boolean metaData) throws IOException { + } + + @Override + public long transferTo(final long position, final long count, final WritableByteChannel target) + throws IOException { + return 0; + } + + @Override + public long transferFrom(final ReadableByteChannel src, final long position, final long count) + throws IOException { + return 0; + } + + @Override + public int read(final ByteBuffer dst, final long position) throws IOException { + return 0; + } + + @Override + public int write(final ByteBuffer src, final long position) throws IOException { + return 0; + } + + @Override + public MappedByteBuffer map(final MapMode mode, final long position, final long size) + throws IOException { + return null; + } + + @Override + public FileLock lock(final long position, final long size, final boolean shared) + throws IOException { + return null; + } + + @Override + public FileLock tryLock(final long position, final long size, final boolean shared) + throws IOException { + return null; + } + + @Override + protected void implCloseChannel() throws IOException { + } + + }; + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/WsBinaryMessageTest.java b/jooby/src/test/java-excluded/org/jooby/internal/WsBinaryMessageTest.java new file mode 100644 index 00000000..83e9902a --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/WsBinaryMessageTest.java @@ -0,0 +1,183 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.util.List; +import java.util.Map; + +import org.jooby.Err; +import org.jooby.Mutant; +import org.jooby.test.MockUnit; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +import com.google.common.base.Charsets; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({WsBinaryMessage.class, ByteArrayInputStream.class, InputStreamReader.class }) +public class WsBinaryMessageTest { + + @Test + public void toByteArray() { + byte[] bytes = "bytes".getBytes(); + ByteBuffer buffer = ByteBuffer.wrap(bytes); + assertArrayEquals(bytes, new WsBinaryMessage(buffer).to(byte[].class)); + } + + @Test + public void toByteBuffer() { + byte[] bytes = "bytes".getBytes(); + ByteBuffer buffer = ByteBuffer.wrap(bytes); + assertEquals(buffer, new WsBinaryMessage(buffer).to(ByteBuffer.class)); + } + + @Test + public void toInputStream() throws Exception { + byte[] bytes = "bytes".getBytes(); + ByteBuffer buffer = ByteBuffer.wrap(bytes); + + new MockUnit() + .expect(unit -> { + InputStream stream = unit.mockConstructor(ByteArrayInputStream.class, + new Class[]{byte[].class }, bytes); + unit.registerMock(InputStream.class, stream); + }) + .run(unit -> { + assertEquals(unit.get(InputStream.class), + new WsBinaryMessage(buffer).to(InputStream.class)); + }); + } + + @Test + public void toReader() throws Exception { + byte[] bytes = "bytes".getBytes(); + ByteBuffer buffer = ByteBuffer.wrap(bytes); + + new MockUnit() + .expect( + unit -> { + InputStream stream = unit.mockConstructor(ByteArrayInputStream.class, + new Class[]{byte[].class }, bytes); + + Reader reader = unit.mockConstructor(InputStreamReader.class, new Class[]{ + InputStream.class, Charset.class }, stream, Charsets.UTF_8); + + unit.registerMock(Reader.class, reader); + }) + .run(unit -> { + assertEquals(unit.get(Reader.class), + new WsBinaryMessage(buffer).to(Reader.class)); + }); + } + + @Test(expected = Err.class) + public void toUnsupportedType() throws Exception { + byte[] bytes = "bytes".getBytes(); + ByteBuffer buffer = ByteBuffer.wrap(bytes); + + new WsBinaryMessage(buffer).to(List.class); + } + + @Test(expected = Err.class) + public void booleanValue() throws Exception { + new WsBinaryMessage(ByteBuffer.wrap("bytes".getBytes())).booleanValue(); + } + + @Test(expected = Err.class) + public void byteValue() throws Exception { + new WsBinaryMessage(ByteBuffer.wrap("bytes".getBytes())).byteValue(); + } + + @Test(expected = Err.class) + public void shortValue() throws Exception { + new WsBinaryMessage(ByteBuffer.wrap("bytes".getBytes())).shortValue(); + } + + @Test(expected = Err.class) + public void intValue() throws Exception { + new WsBinaryMessage(ByteBuffer.wrap("bytes".getBytes())).intValue(); + } + + @Test(expected = Err.class) + public void longValue() throws Exception { + new WsBinaryMessage(ByteBuffer.wrap("bytes".getBytes())).longValue(); + } + + @Test(expected = Err.class) + public void value() throws Exception { + new WsBinaryMessage(ByteBuffer.wrap("bytes".getBytes())).value(); + } + + @Test(expected = Err.class) + public void floatValue() throws Exception { + new WsBinaryMessage(ByteBuffer.wrap("bytes".getBytes())).floatValue(); + } + + @Test(expected = Err.class) + public void doubleValue() throws Exception { + new WsBinaryMessage(ByteBuffer.wrap("bytes".getBytes())).doubleValue(); + } + + @SuppressWarnings("unchecked") + @Test(expected = Err.class) + public void enumValue() throws Exception { + new WsBinaryMessage(ByteBuffer.wrap("bytes".getBytes())).toEnum(Enum.class); + } + + @Test(expected = Err.class) + public void toList() throws Exception { + new WsBinaryMessage(ByteBuffer.wrap("bytes".getBytes())).toList(String.class); + } + + @Test(expected = Err.class) + public void toSet() throws Exception { + new WsBinaryMessage(ByteBuffer.wrap("bytes".getBytes())).toSet(String.class); + } + + @Test(expected = Err.class) + public void toSortedSet() throws Exception { + new WsBinaryMessage(ByteBuffer.wrap("bytes".getBytes())).toSortedSet(String.class); + } + + @Test(expected = Err.class) + public void toOptional() throws Exception { + new WsBinaryMessage(ByteBuffer.wrap("bytes".getBytes())).toOptional(String.class); + } + + @Test + public void isSet() throws Exception { + assertEquals(true, new WsBinaryMessage(ByteBuffer.wrap("bytes".getBytes())).isSet()); + } + + @Test + public void toMap() throws Exception { + WsBinaryMessage msg = new WsBinaryMessage(ByteBuffer.wrap("bytes".getBytes())); + Map map = msg.toMap(); + assertEquals(msg, map.get("message")); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/handlers/HeadHandlerTest.java b/jooby/src/test/java-excluded/org/jooby/internal/handlers/HeadHandlerTest.java new file mode 100644 index 00000000..0d12d27b --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/handlers/HeadHandlerTest.java @@ -0,0 +1,129 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.handlers; + +import static org.easymock.EasyMock.expect; + +import java.util.Optional; +import java.util.Set; + +import org.jooby.MediaType; +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Route; +import org.jooby.Route.Chain; +import org.jooby.Route.Definition; +import org.jooby.internal.RouteImpl; +import org.jooby.test.MockUnit; +import org.jooby.test.MockUnit.Block; +import org.junit.Test; + +import com.google.common.collect.Sets; + +public class HeadHandlerTest { + + private Block path = unit -> { + Request req = unit.get(Request.class); + expect(req.path()).andReturn("/"); + }; + + private Block len = unit -> { + Response rsp = unit.get(Response.class); + expect(rsp.length(0)).andReturn(rsp); + }; + + private Block next = unit -> { + Chain chain = unit.get(Route.Chain.class); + chain.next(unit.get(Request.class), unit.get(Response.class)); + }; + + @Test + public void handle() throws Exception { + new MockUnit(Request.class, Response.class, Route.Chain.class, Route.Definition.class) + .expect(path) + .expect(unit -> { + Route.Definition routeDef = unit.get(Route.Definition.class); + expect(routeDef.glob()).andReturn(false); + + RouteImpl route = unit.mock(RouteImpl.class); + route.handle(unit.get(Request.class), unit.get(Response.class), + unit.get(Route.Chain.class)); + + Optional ifRoute = Optional.of(route); + + expect(routeDef.matches(Route.GET, "/", MediaType.all, MediaType.ALL)).andReturn(ifRoute); + }) + .expect(len) + .run(unit -> { + Set routes = Sets.newHashSet(unit.get(Route.Definition.class)); + new HeadHandler(routes) + .handle(unit.get(Request.class), unit.get(Response.class), + unit.get(Route.Chain.class)); + }); + } + + @Test + public void noRoute() throws Exception { + new MockUnit(Request.class, Response.class, Route.Chain.class, Route.Definition.class) + .expect(path) + .expect(unit -> { + Route.Definition routeDef = unit.get(Route.Definition.class); + expect(routeDef.glob()).andReturn(false); + + Optional ifRoute = Optional.empty(); + + expect(routeDef.matches(Route.GET, "/", MediaType.all, MediaType.ALL)).andReturn(ifRoute); + }) + .expect(next) + .run(unit -> { + Set routes = Sets.newHashSet(unit.get(Route.Definition.class)); + new HeadHandler(routes) + .handle(unit.get(Request.class), unit.get(Response.class), + unit.get(Route.Chain.class)); + }); + } + + @Test + public void ignoreGlob() throws Exception { + new MockUnit(Request.class, Response.class, Route.Chain.class, Route.Definition.class) + .expect(path) + .expect(unit -> { + Route.Definition routeDef = unit.get(Route.Definition.class); + expect(routeDef.glob()).andReturn(true); + }) + .expect(next) + .run(unit -> { + Set routes = Sets.newHashSet(unit.get(Route.Definition.class)); + new HeadHandler(routes) + .handle(unit.get(Request.class), unit.get(Response.class), + unit.get(Route.Chain.class)); + }); + } + + @Test + public void noroutes() throws Exception { + new MockUnit(Request.class, Response.class, Route.Chain.class, Route.Definition.class) + .expect(path) + .expect(next) + .run(unit -> { + Set routes = Sets.newHashSet(); + new HeadHandler(routes) + .handle(unit.get(Request.class), unit.get(Response.class), + unit.get(Route.Chain.class)); + }); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/handlers/OptionsHandlerTest.java b/jooby/src/test/java-excluded/org/jooby/internal/handlers/OptionsHandlerTest.java new file mode 100644 index 00000000..7145fd7e --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/handlers/OptionsHandlerTest.java @@ -0,0 +1,155 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.handlers; + +import static org.easymock.EasyMock.expect; + +import java.util.Optional; +import java.util.Set; + +import org.jooby.MediaType; +import org.jooby.Mutant; +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Route; +import org.jooby.Route.Chain; +import org.jooby.Route.Definition; +import org.jooby.Status; +import org.jooby.test.MockUnit; +import org.jooby.test.MockUnit.Block; +import org.junit.Test; + +import com.google.common.collect.Sets; + +public class OptionsHandlerTest { + + private Block path = unit -> { + Request req = unit.get(Request.class); + expect(req.path()).andReturn("/"); + }; + + private Block next = unit -> { + Chain chain = unit.get(Route.Chain.class); + chain.next(unit.get(Request.class), unit.get(Response.class)); + }; + + @Test + public void handle() throws Exception { + new MockUnit(Request.class, Response.class, Route.Chain.class, Route.Definition.class) + .expect(next) + .expect(allow(false)) + .expect(path) + .expect(method("GET")) + .expect(matches("POST", false)) + .expect(matches("PUT", false)) + .expect(matches("DELETE", false)) + .expect(matches("PATCH", false)) + .expect(matches("HEAD", false)) + .expect(matches("CONNECT", false)) + .expect(matches("OPTIONS", false)) + .expect(matches("TRACE", false)) + .expect(unit -> { + Response rsp = unit.get(Response.class); + expect(rsp.header("Allow", "")).andReturn(rsp); + expect(rsp.length(0)).andReturn(rsp); + expect(rsp.status(Status.OK)).andReturn(rsp); + }) + .run(unit -> { + Set routes = Sets.newHashSet(unit.get(Route.Definition.class)); + new OptionsHandler(routes) + .handle(unit.get(Request.class), unit.get(Response.class), + unit.get(Route.Chain.class)); + }); + } + + @Test + public void handleSome() throws Exception { + new MockUnit(Request.class, Response.class, Route.Chain.class, Route.Definition.class) + .expect(next) + .expect(allow(false)) + .expect(path) + .expect(method("GET")) + .expect(matches("POST", true)) + .expect(routeMethod("POST")) + .expect(matches("PUT", false)) + .expect(matches("DELETE", false)) + .expect(matches("PATCH", true)) + .expect(routeMethod("PATCH")) + .expect(matches("HEAD", false)) + .expect(matches("CONNECT", false)) + .expect(matches("OPTIONS", false)) + .expect(matches("TRACE", false)) + .expect(unit -> { + Response rsp = unit.get(Response.class); + expect(rsp.header("Allow", "POST, PATCH")).andReturn(rsp); + expect(rsp.length(0)).andReturn(rsp); + expect(rsp.status(Status.OK)).andReturn(rsp); + }) + .run(unit -> { + Set routes = Sets.newHashSet(unit.get(Route.Definition.class)); + new OptionsHandler(routes) + .handle(unit.get(Request.class), unit.get(Response.class), + unit.get(Route.Chain.class)); + }); + } + + @Test + public void handleNone() throws Exception { + new MockUnit(Request.class, Response.class, Route.Chain.class, Route.Definition.class) + .expect(next) + .expect(allow(true)) + .run(unit -> { + Set routes = Sets.newHashSet(unit.get(Route.Definition.class)); + new OptionsHandler(routes) + .handle(unit.get(Request.class), unit.get(Response.class), + unit.get(Route.Chain.class)); + }); + } + + private Block matches(final String method, final boolean matches) { + return unit -> { + Route route = unit.mock(Route.class); + Optional ifRoute = matches ? Optional.of(route) : Optional.empty(); + Definition def = unit.get(Route.Definition.class); + expect(def.matches(method, "/", MediaType.all, MediaType.ALL)).andReturn(ifRoute); + }; + } + + private Block method(final String method) { + return unit -> { + Request req = unit.get(Request.class); + expect(req.method()).andReturn(method); + }; + } + + private Block routeMethod(final String method) { + return unit -> { + Route.Definition req = unit.get(Route.Definition.class); + expect(req.method()).andReturn(method); + }; + } + + private Block allow(final boolean set) { + return unit -> { + Mutant mutant = unit.mock(Mutant.class); + expect(mutant.isSet()).andReturn(set); + + Response rsp = unit.get(Response.class); + expect(rsp.header("Allow")).andReturn(mutant); + }; + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/jetty/JettyHandlerTest.java b/jooby/src/test/java-excluded/org/jooby/internal/jetty/JettyHandlerTest.java new file mode 100644 index 00000000..af7e511c --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/jetty/JettyHandlerTest.java @@ -0,0 +1,472 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.jetty; + +import static org.easymock.EasyMock.eq; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.isA; + +import java.io.IOException; + +import javax.servlet.MultipartConfigElement; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.websocket.server.WebSocketServerFactory; +import org.jooby.servlet.ServletServletRequest; +import org.jooby.servlet.ServletServletResponse; +import org.jooby.spi.HttpHandler; +import org.jooby.spi.NativeWebSocket; +import org.jooby.test.MockUnit; +import org.jooby.test.MockUnit.Block; +import org.junit.Test; + +public class JettyHandlerTest { + + private Block wsStopTimeout = unit -> { + WebSocketServerFactory ws = unit.get(WebSocketServerFactory.class); + ws.setStopTimeout(30000L); + }; + + @Test + public void handleShouldSetMultipartConfig() throws Exception { + new MockUnit(Request.class, HttpHandler.class, WebSocketServerFactory.class, + HttpServletRequest.class, HttpServletResponse.class) + .expect(unit -> { + Request request = unit.get(Request.class); + + request.setHandled(true); + + expect(request.getContentType()).andReturn("Multipart/Form-Data"); + + request.setAttribute(eq(Request.MULTIPART_CONFIG_ELEMENT), + isA(MultipartConfigElement.class)); + }) + .expect(unit -> { + HttpServletRequest request = unit.get(HttpServletRequest.class); + + expect(request.getPathInfo()).andReturn("/"); + expect(request.getContextPath()).andReturn(""); + + }) + .expect(unit -> { + HttpHandler dispatcher = unit.get(HttpHandler.class); + dispatcher.handle(isA(ServletServletRequest.class), + isA(ServletServletResponse.class)); + }) + .expect(wsStopTimeout) + .run(unit -> { + new JettyHandler(unit.get(HttpHandler.class), unit.get(WebSocketServerFactory.class), + "target", -1) + .handle("/", unit.get(Request.class), + unit.get(HttpServletRequest.class), + unit.get(HttpServletResponse.class)); + }); + } + + @Test + public void handleShouldIgnoreMultipartConfig() throws Exception { + new MockUnit(Request.class, HttpHandler.class, WebSocketServerFactory.class, + HttpServletRequest.class, HttpServletResponse.class) + .expect(unit -> { + Request request = unit.get(Request.class); + + request.setHandled(true); + + expect(request.getContentType()).andReturn("application/json"); + }) + .expect(unit -> { + HttpServletRequest request = unit.get(HttpServletRequest.class); + + expect(request.getPathInfo()).andReturn("/"); + expect(request.getContextPath()).andReturn(""); + }) + .expect(unit -> { + HttpHandler dispatcher = unit.get(HttpHandler.class); + dispatcher.handle(isA(ServletServletRequest.class), + isA(ServletServletResponse.class)); + }) + .expect(wsStopTimeout) + .run(unit -> { + new JettyHandler(unit.get(HttpHandler.class), unit.get(WebSocketServerFactory.class), + "target", -1) + .handle("/", unit.get(Request.class), + unit.get(HttpServletRequest.class), + unit.get(HttpServletResponse.class)); + }); + } + + @Test + public void handleWsUpgrade() throws Exception { + new MockUnit(Request.class, HttpHandler.class, WebSocketServerFactory.class, + HttpServletRequest.class, HttpServletResponse.class, NativeWebSocket.class) + .expect(unit -> { + Request request = unit.get(Request.class); + + request.setHandled(true); + + expect(request.getContentType()).andReturn("application/json"); + }) + .expect(unit -> { + HttpServletRequest request = unit.get(HttpServletRequest.class); + + expect(request.getPathInfo()).andReturn("/"); + expect(request.getContextPath()).andReturn(""); + }) + .expect(unit -> { + HttpServletRequest req = unit.get(HttpServletRequest.class); + HttpServletResponse rsp = unit.get(HttpServletResponse.class); + NativeWebSocket ws = unit.get(NativeWebSocket.class); + + WebSocketServerFactory factory = unit.get(WebSocketServerFactory.class); + + expect(factory.isUpgradeRequest(req, rsp)).andReturn(true); + + expect(factory.acceptWebSocket(req, rsp)).andReturn(true); + + expect(req.getAttribute(JettyWebSocket.class.getName())).andReturn(ws); + req.removeAttribute(JettyWebSocket.class.getName()); + }) + .expect(unit -> { + HttpHandler dispatcher = unit.get(HttpHandler.class); + dispatcher.handle(unit.capture(ServletServletRequest.class), + unit.capture(ServletServletResponse.class)); + }) + .expect(wsStopTimeout) + .run(unit -> { + new JettyHandler(unit.get(HttpHandler.class), unit.get(WebSocketServerFactory.class), + "target", -1) + .handle("/", unit.get(Request.class), + unit.get(HttpServletRequest.class), + unit.get(HttpServletResponse.class)); + }, unit -> { + ServletServletRequest req = unit.captured(ServletServletRequest.class).get(0); + req.upgrade(NativeWebSocket.class); + }); + } + + @Test(expected = UnsupportedOperationException.class) + public void handleThrowUnsupportedOperationExceptionWhenWsIsMissing() throws Exception { + new MockUnit(Request.class, HttpHandler.class, WebSocketServerFactory.class, + HttpServletRequest.class, HttpServletResponse.class, NativeWebSocket.class) + .expect(unit -> { + Request request = unit.get(Request.class); + + request.setHandled(true); + + expect(request.getContentType()).andReturn("application/json"); + }) + .expect(unit -> { + HttpServletRequest request = unit.get(HttpServletRequest.class); + + expect(request.getPathInfo()).andReturn("/"); + expect(request.getContextPath()).andReturn(""); + }) + .expect(unit -> { + HttpServletRequest req = unit.get(HttpServletRequest.class); + HttpServletResponse rsp = unit.get(HttpServletResponse.class); + + WebSocketServerFactory factory = unit.get(WebSocketServerFactory.class); + + expect(factory.isUpgradeRequest(req, rsp)).andReturn(true); + + expect(factory.acceptWebSocket(req, rsp)).andReturn(true); + + expect(req.getAttribute(JettyWebSocket.class.getName())).andReturn(null); + }) + .expect(unit -> { + HttpHandler dispatcher = unit.get(HttpHandler.class); + dispatcher.handle(unit.capture(ServletServletRequest.class), + unit.capture(ServletServletResponse.class)); + }) + .expect(wsStopTimeout) + .run(unit -> { + new JettyHandler(unit.get(HttpHandler.class), unit.get(WebSocketServerFactory.class), + "target", -1) + .handle("/", unit.get(Request.class), + unit.get(HttpServletRequest.class), + unit.get(HttpServletResponse.class)); + }, unit -> { + ServletServletRequest req = unit.captured(ServletServletRequest.class).get(0); + req.upgrade(NativeWebSocket.class); + }); + } + + @Test(expected = UnsupportedOperationException.class) + public void handleThrowUnsupportedOperationExceptionOnNoWebSocketRequest() throws Exception { + new MockUnit(Request.class, HttpHandler.class, WebSocketServerFactory.class, + HttpServletRequest.class, HttpServletResponse.class, NativeWebSocket.class) + .expect(unit -> { + Request request = unit.get(Request.class); + + request.setHandled(true); + + expect(request.getContentType()).andReturn("application/json"); + }) + .expect(unit -> { + HttpServletRequest request = unit.get(HttpServletRequest.class); + + expect(request.getPathInfo()).andReturn("/"); + expect(request.getContextPath()).andReturn(""); + }) + .expect(unit -> { + HttpServletRequest req = unit.get(HttpServletRequest.class); + HttpServletResponse rsp = unit.get(HttpServletResponse.class); + + WebSocketServerFactory factory = unit.get(WebSocketServerFactory.class); + + expect(factory.isUpgradeRequest(req, rsp)).andReturn(false); + }) + .expect(unit -> { + HttpHandler dispatcher = unit.get(HttpHandler.class); + dispatcher.handle(unit.capture(ServletServletRequest.class), + unit.capture(ServletServletResponse.class)); + }) + .expect(wsStopTimeout) + .run(unit -> { + new JettyHandler(unit.get(HttpHandler.class), unit.get(WebSocketServerFactory.class), + "target", -1) + .handle("/", unit.get(Request.class), + unit.get(HttpServletRequest.class), + unit.get(HttpServletResponse.class)); + }, unit -> { + ServletServletRequest req = unit.captured(ServletServletRequest.class).get(0); + req.upgrade(NativeWebSocket.class); + }); + } + + @Test(expected = UnsupportedOperationException.class) + public void handleThrowUnsupportedOperationExceptionOnHankshakeRejection() throws Exception { + new MockUnit(Request.class, HttpHandler.class, WebSocketServerFactory.class, + HttpServletRequest.class, HttpServletResponse.class, NativeWebSocket.class) + .expect(unit -> { + Request request = unit.get(Request.class); + + request.setHandled(true); + + expect(request.getContentType()).andReturn("application/json"); + }) + .expect(unit -> { + HttpServletRequest request = unit.get(HttpServletRequest.class); + + expect(request.getPathInfo()).andReturn("/"); + expect(request.getContextPath()).andReturn(""); + }) + .expect(unit -> { + HttpServletRequest req = unit.get(HttpServletRequest.class); + HttpServletResponse rsp = unit.get(HttpServletResponse.class); + + WebSocketServerFactory factory = unit.get(WebSocketServerFactory.class); + + expect(factory.isUpgradeRequest(req, rsp)).andReturn(true); + + expect(factory.acceptWebSocket(req, rsp)).andReturn(false); + }) + .expect(unit -> { + HttpHandler dispatcher = unit.get(HttpHandler.class); + dispatcher.handle(unit.capture(ServletServletRequest.class), + unit.capture(ServletServletResponse.class)); + }) + .expect(wsStopTimeout) + .run(unit -> { + new JettyHandler(unit.get(HttpHandler.class), unit.get(WebSocketServerFactory.class), + "target", -1) + .handle("/", unit.get(Request.class), + unit.get(HttpServletRequest.class), + unit.get(HttpServletResponse.class)); + }, unit -> { + ServletServletRequest req = unit.captured(ServletServletRequest.class).get(0); + req.upgrade(NativeWebSocket.class); + }); + } + + @Test(expected = UnsupportedOperationException.class) + public void handleThrowUnsupportedOperationExceptionOnWrongType() throws Exception { + new MockUnit(Request.class, HttpHandler.class, WebSocketServerFactory.class, + HttpServletRequest.class, HttpServletResponse.class, NativeWebSocket.class) + .expect(unit -> { + Request request = unit.get(Request.class); + + request.setHandled(true); + + expect(request.getContentType()).andReturn("application/json"); + }) + .expect(unit -> { + HttpServletRequest request = unit.get(HttpServletRequest.class); + + expect(request.getPathInfo()).andReturn("/"); + expect(request.getContextPath()).andReturn(""); + }) + .expect(unit -> { + HttpHandler dispatcher = unit.get(HttpHandler.class); + dispatcher.handle(unit.capture(ServletServletRequest.class), + unit.capture(ServletServletResponse.class)); + }) + .expect(wsStopTimeout) + .run(unit -> { + new JettyHandler(unit.get(HttpHandler.class), unit.get(WebSocketServerFactory.class), + "target", -1) + .handle("/", unit.get(Request.class), + unit.get(HttpServletRequest.class), + unit.get(HttpServletResponse.class)); + }, unit -> { + ServletServletRequest req = unit.captured(ServletServletRequest.class).get(0); + req.upgrade(JettyHandlerTest.class); + }); + } + + @Test(expected = ServletException.class) + public void handleShouldReThrowServletException() throws Exception { + HttpHandler dispatcher = (request, response) -> { + throw new ServletException("intentional err"); + }; + new MockUnit(Request.class, WebSocketServerFactory.class, + HttpServletRequest.class, HttpServletResponse.class) + .expect(unit -> { + Request request = unit.get(Request.class); + + request.setHandled(true); + + expect(request.getContentType()).andReturn("application/json"); + }) + .expect(unit -> { + HttpServletRequest request = unit.get(HttpServletRequest.class); + + expect(request.getPathInfo()).andReturn("/"); + expect(request.getContextPath()).andReturn(""); + }) + .expect(unit -> { + Request request = unit.get(Request.class); + + request.setHandled(false); + }) + .expect(wsStopTimeout) + .run(unit -> { + new JettyHandler(dispatcher, unit.get(WebSocketServerFactory.class), + "target", -1) + .handle("/", unit.get(Request.class), + unit.get(HttpServletRequest.class), + unit.get(HttpServletResponse.class)); + }); + } + + @Test(expected = IOException.class) + public void handleShouldReThrowIOException() throws Exception { + HttpHandler dispatcher = (request, response) -> { + throw new IOException("intentional err"); + }; + new MockUnit(Request.class, WebSocketServerFactory.class, + HttpServletRequest.class, HttpServletResponse.class) + .expect(unit -> { + Request request = unit.get(Request.class); + + request.setHandled(true); + + expect(request.getContentType()).andReturn("application/json"); + }) + .expect(unit -> { + HttpServletRequest request = unit.get(HttpServletRequest.class); + + expect(request.getPathInfo()).andReturn("/"); + expect(request.getContextPath()).andReturn(""); + }) + .expect(unit -> { + Request request = unit.get(Request.class); + + request.setHandled(false); + }) + .expect(wsStopTimeout) + .run(unit -> { + new JettyHandler(dispatcher, unit.get(WebSocketServerFactory.class), + "target", -1) + .handle("/", unit.get(Request.class), + unit.get(HttpServletRequest.class), + unit.get(HttpServletResponse.class)); + }); + } + + @Test(expected = IllegalArgumentException.class) + public void handleShouldReThrowIllegalArgumentException() throws Exception { + HttpHandler dispatcher = (request, response) -> { + throw new IllegalArgumentException("intentional err"); + }; + new MockUnit(Request.class, WebSocketServerFactory.class, + HttpServletRequest.class, HttpServletResponse.class) + .expect(unit -> { + Request request = unit.get(Request.class); + + request.setHandled(true); + + expect(request.getContentType()).andReturn("application/json"); + }) + .expect(unit -> { + HttpServletRequest request = unit.get(HttpServletRequest.class); + + expect(request.getPathInfo()).andReturn("/"); + expect(request.getContextPath()).andReturn(""); + }) + .expect(unit -> { + Request request = unit.get(Request.class); + + request.setHandled(false); + }) + .expect(wsStopTimeout) + .run(unit -> { + new JettyHandler(dispatcher, unit.get(WebSocketServerFactory.class), + "target", -1) + .handle("/", unit.get(Request.class), + unit.get(HttpServletRequest.class), + unit.get(HttpServletResponse.class)); + }); + } + + @Test(expected = IllegalStateException.class) + public void handleShouldReThrowIllegalStateException() throws Exception { + HttpHandler dispatcher = (request, response) -> { + throw new Exception("intentional err"); + }; + new MockUnit(Request.class, WebSocketServerFactory.class, + HttpServletRequest.class, HttpServletResponse.class) + .expect(unit -> { + Request request = unit.get(Request.class); + + request.setHandled(true); + + expect(request.getContentType()).andReturn("application/json"); + }) + .expect(unit -> { + HttpServletRequest request = unit.get(HttpServletRequest.class); + + expect(request.getPathInfo()).andReturn("/"); + expect(request.getContextPath()).andReturn(""); + }) + .expect(unit -> { + Request request = unit.get(Request.class); + + request.setHandled(false); + }) + .expect(wsStopTimeout) + .run(unit -> { + new JettyHandler(dispatcher, unit.get(WebSocketServerFactory.class), + "target", -1) + .handle("/", unit.get(Request.class), + unit.get(HttpServletRequest.class), + unit.get(HttpServletResponse.class)); + }); + } +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/jetty/JettyResponseTest.java b/jooby/src/test/java-excluded/org/jooby/internal/jetty/JettyResponseTest.java new file mode 100644 index 00000000..b4d4dc4f --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/jetty/JettyResponseTest.java @@ -0,0 +1,418 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.jetty; + +import static org.easymock.EasyMock.eq; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.isA; +import static org.junit.Assert.assertArrayEquals; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.MappedByteBuffer; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.channels.ReadableByteChannel; +import java.nio.channels.WritableByteChannel; + +import javax.servlet.AsyncContext; +import javax.servlet.http.HttpServletRequest; + +import org.eclipse.jetty.server.HttpOutput; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.jooby.servlet.ServletServletRequest; +import org.jooby.test.MockUnit; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({JettyResponse.class, Channels.class, LoggerFactory.class }) +public class JettyResponseTest { + + private MockUnit.Block servletRequest = unit -> { + Request req = unit.get(Request.class); + ServletServletRequest request = unit.get(ServletServletRequest.class); + expect(request.servletRequest()).andReturn(req); + }; + + private MockUnit.Block startAsync = unit -> { + ServletServletRequest request = unit.get(ServletServletRequest.class); + HttpServletRequest req = unit.mock(HttpServletRequest.class); + expect(req.isAsyncStarted()).andReturn(false); + expect(req.startAsync()).andReturn(unit.get(AsyncContext.class)); + expect(request.servletRequest()).andReturn(req); + }; + + private MockUnit.Block asyncStarted = unit -> { + Request request = unit.get(Request.class); + expect(request.isAsyncStarted()).andReturn(true); + }; + + private MockUnit.Block noAsyncStarted = unit -> { + Request request = unit.get(Request.class); + expect(request.isAsyncStarted()).andReturn(false); + }; + + @Test + public void defaults() throws Exception { + new MockUnit(ServletServletRequest.class, Request.class, Response.class) + .expect(servletRequest) + .run(unit -> { + new JettyResponse(unit.get(ServletServletRequest.class), unit.get(Response.class)); + }); + } + + @Test + public void sendBytes() throws Exception { + byte[] bytes = "bytes".getBytes(); + new MockUnit(ServletServletRequest.class, Request.class, Response.class, HttpOutput.class) + .expect(servletRequest) + .expect(unit -> { + HttpOutput output = unit.get(HttpOutput.class); + output.sendContent(unit.capture(ByteBuffer.class)); + + Response rsp = unit.get(Response.class); + rsp.setHeader("Transfer-Encoding", null); + expect(rsp.getHttpOutput()).andReturn(output); + }) + .run(unit -> { + new JettyResponse(unit.get(ServletServletRequest.class), unit.get(Response.class)) + .send(bytes); + }, unit -> { + assertArrayEquals(bytes, unit.captured(ByteBuffer.class).iterator().next().array()); + }); + } + + @Test + public void sendBuffer() throws Exception { + byte[] bytes = "bytes".getBytes(); + ByteBuffer buffer = ByteBuffer.wrap(bytes); + new MockUnit(ServletServletRequest.class, Request.class, Response.class, HttpOutput.class) + .expect(servletRequest) + .expect(unit -> { + HttpOutput output = unit.get(HttpOutput.class); + output.sendContent(eq(buffer)); + + Response rsp = unit.get(Response.class); + expect(rsp.getHttpOutput()).andReturn(output); + }) + .run(unit -> { + new JettyResponse(unit.get(ServletServletRequest.class), unit.get(Response.class)) + .send(buffer); + }); + } + + @Test + public void sendInputStream() throws Exception { + new MockUnit(ServletServletRequest.class, Request.class, Response.class, HttpOutput.class, + InputStream.class, AsyncContext.class) + .expect(servletRequest) + .expect(unit -> { + unit.mockStatic(Channels.class); + ReadableByteChannel channel = unit.mock(ReadableByteChannel.class); + expect(Channels.newChannel(unit.get(InputStream.class))).andReturn(channel); + + HttpOutput output = unit.get(HttpOutput.class); + output.sendContent(eq(channel), isA(JettyResponse.class)); + + Response rsp = unit.get(Response.class); + expect(rsp.getHttpOutput()).andReturn(output); + }) + .expect(startAsync) + .run(unit -> { + new JettyResponse(unit.get(ServletServletRequest.class), unit.get(Response.class)) + .send(unit.get(InputStream.class)); + }); + } + + @Test + public void sendInputStreamAsyncStarted() throws Exception { + new MockUnit(ServletServletRequest.class, Request.class, Response.class, HttpOutput.class, + InputStream.class, AsyncContext.class) + .expect(servletRequest) + .expect(unit -> { + unit.mockStatic(Channels.class); + ReadableByteChannel channel = unit.mock(ReadableByteChannel.class); + expect(Channels.newChannel(unit.get(InputStream.class))).andReturn(channel); + + HttpOutput output = unit.get(HttpOutput.class); + output.sendContent(eq(channel), isA(JettyResponse.class)); + + Response rsp = unit.get(Response.class); + expect(rsp.getHttpOutput()).andReturn(output); + }) + .expect(unit -> { + ServletServletRequest request = unit.get(ServletServletRequest.class); + HttpServletRequest req = unit.mock(HttpServletRequest.class); + expect(req.isAsyncStarted()).andReturn(true); + expect(request.servletRequest()).andReturn(req); + }) + .run(unit -> { + JettyResponse rsp = new JettyResponse(unit.get(ServletServletRequest.class), + unit.get(Response.class)); + rsp.send(unit.get(InputStream.class)); + rsp.end(); + }); + } + + @Test + public void sendSmallFileChannel() throws Exception { + FileChannel channel = newFileChannel(1); + new MockUnit(ServletServletRequest.class, Request.class, Response.class, HttpOutput.class, + AsyncContext.class) + .expect(servletRequest) + .expect(unit -> { + HttpOutput output = unit.get(HttpOutput.class); + output.sendContent(eq(channel)); + + Response rsp = unit.get(Response.class); + expect(rsp.getBufferSize()).andReturn(2); + expect(rsp.getHttpOutput()).andReturn(output); + }) + .run(unit -> { + new JettyResponse(unit.get(ServletServletRequest.class), unit.get(Response.class)) + .send(channel); + }); + } + + @Test + public void sendLargeFileChannel() throws Exception { + FileChannel channel = newFileChannel(10); + new MockUnit(ServletServletRequest.class, Request.class, Response.class, HttpOutput.class, + AsyncContext.class) + .expect(servletRequest) + .expect(unit -> { + HttpOutput output = unit.get(HttpOutput.class); + output.sendContent(eq(channel), isA(JettyResponse.class)); + + Response rsp = unit.get(Response.class); + expect(rsp.getBufferSize()).andReturn(5); + expect(rsp.getHttpOutput()).andReturn(output); + }) + .expect(startAsync) + .run(unit -> { + new JettyResponse(unit.get(ServletServletRequest.class), unit.get(Response.class)) + .send(channel); + }); + } + + @Test + public void succeeded() throws Exception { + byte[] bytes = "bytes".getBytes(); + new MockUnit(ServletServletRequest.class, Request.class, Response.class, HttpOutput.class, + AsyncContext.class) + .expect(servletRequest) + .expect(unit -> { + HttpOutput output = unit.get(HttpOutput.class); + output.sendContent(unit.capture(ByteBuffer.class)); + output.close(); + + Response rsp = unit.get(Response.class); + rsp.setHeader("Transfer-Encoding", null); + expect(rsp.getHttpOutput()).andReturn(output).times(2); + }) + .expect(noAsyncStarted) + .run(unit -> { + JettyResponse rsp = new JettyResponse(unit.get(ServletServletRequest.class), + unit.get(Response.class)); + rsp.send(bytes); + rsp.succeeded(); + }); + } + + @Test + public void succeededAsync() throws Exception { + FileChannel channel = newFileChannel(10); + new MockUnit(ServletServletRequest.class, Request.class, Response.class, HttpOutput.class, + AsyncContext.class) + .expect(servletRequest) + .expect(unit -> { + HttpOutput output = unit.get(HttpOutput.class); + output.sendContent(eq(channel), isA(JettyResponse.class)); + + Response rsp = unit.get(Response.class); + expect(rsp.getBufferSize()).andReturn(5); + expect(rsp.getHttpOutput()).andReturn(output); + }) + .expect(startAsync) + .expect(asyncStarted) + .expect(unit -> { + Request req = unit.get(Request.class); + + AsyncContext ctx = unit.get(AsyncContext.class); + ctx.complete(); + + expect(req.getAsyncContext()).andReturn(ctx); + }) + .run(unit -> { + JettyResponse rsp = new JettyResponse(unit.get(ServletServletRequest.class), + unit.get(Response.class)); + rsp.send(channel); + rsp.succeeded(); + }); + } + + @Test + public void end() throws Exception { + new MockUnit(ServletServletRequest.class, Request.class, Response.class, HttpOutput.class) + .expect(servletRequest) + .expect(unit -> { + HttpOutput output = unit.get(HttpOutput.class); + output.close(); + + Response rsp = unit.get(Response.class); + expect(rsp.getHttpOutput()).andReturn(output); + }) + .expect(noAsyncStarted) + .run(unit -> { + new JettyResponse(unit.get(ServletServletRequest.class), unit.get(Response.class)) + .end(); + }); + } + + @Test + public void failed() throws Exception { + IOException cause = new IOException(); + new MockUnit(ServletServletRequest.class, Request.class, Response.class, HttpOutput.class) + .expect(servletRequest) + .expect(unit -> { + Logger log = unit.mock(Logger.class); + log.error("execution of /path resulted in exception", cause); + + unit.mockStatic(LoggerFactory.class); + expect(LoggerFactory.getLogger(org.jooby.Response.class)).andReturn(log); + }) + .expect(unit -> { + HttpOutput output = unit.get(HttpOutput.class); + output.close(); + + Response rsp = unit.get(Response.class); + expect(rsp.getHttpOutput()).andReturn(output); + }) + .expect(noAsyncStarted) + .expect(unit -> { + ServletServletRequest req = unit.get(ServletServletRequest.class); + expect(req.path()).andReturn("/path"); + }) + .run(unit -> { + new JettyResponse(unit.get(ServletServletRequest.class), unit.get(Response.class)) + .failed(cause); + }); + } + + private FileChannel newFileChannel(final int size) { + return new FileChannel() { + @Override + public int read(final ByteBuffer dst) throws IOException { + return 0; + } + + @Override + public long read(final ByteBuffer[] dsts, final int offset, final int length) + throws IOException { + return 0; + } + + @Override + public int write(final ByteBuffer src) throws IOException { + return 0; + } + + @Override + public long write(final ByteBuffer[] srcs, final int offset, final int length) + throws IOException { + return 0; + } + + @Override + public long position() throws IOException { + return 0; + } + + @Override + public FileChannel position(final long newPosition) throws IOException { + return null; + } + + @Override + public long size() throws IOException { + return size; + } + + @Override + public FileChannel truncate(final long size) throws IOException { + return null; + } + + @Override + public void force(final boolean metaData) throws IOException { + } + + @Override + public long transferTo(final long position, final long count, + final WritableByteChannel target) + throws IOException { + return 0; + } + + @Override + public long transferFrom(final ReadableByteChannel src, final long position, final long count) + throws IOException { + return 0; + } + + @Override + public int read(final ByteBuffer dst, final long position) throws IOException { + return 0; + } + + @Override + public int write(final ByteBuffer src, final long position) throws IOException { + return 0; + } + + @Override + public MappedByteBuffer map(final MapMode mode, final long position, final long size) + throws IOException { + return null; + } + + @Override + public FileLock lock(final long position, final long size, final boolean shared) + throws IOException { + return null; + } + + @Override + public FileLock tryLock(final long position, final long size, final boolean shared) + throws IOException { + return null; + } + + @Override + protected void implCloseChannel() throws IOException { + } + + }; + } +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/jetty/JettyServerTest.java b/jooby/src/test/java-excluded/org/jooby/internal/jetty/JettyServerTest.java new file mode 100644 index 00000000..5a324e7e --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/jetty/JettyServerTest.java @@ -0,0 +1,259 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.jetty; + +import static org.easymock.EasyMock.eq; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.expectLastCall; +import static org.easymock.EasyMock.isA; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.util.Map; + +import javax.inject.Provider; +import javax.servlet.ServletContext; + +import org.eclipse.jetty.server.ConnectionFactory; +import org.eclipse.jetty.server.HttpConfiguration; +import org.eclipse.jetty.server.HttpConnectionFactory; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.handler.ContextHandler; +import org.eclipse.jetty.util.DecoratedObjectFactory; +import org.eclipse.jetty.util.thread.QueuedThreadPool; +import org.eclipse.jetty.util.thread.ThreadPool; +import org.eclipse.jetty.websocket.api.WebSocketBehavior; +import org.eclipse.jetty.websocket.api.WebSocketPolicy; +import org.eclipse.jetty.websocket.server.WebSocketServerFactory; +import org.eclipse.jetty.websocket.servlet.WebSocketCreator; +import org.jooby.spi.HttpHandler; +import org.jooby.test.MockUnit; +import org.jooby.test.MockUnit.Block; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +import com.google.common.collect.ImmutableMap; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigException; +import com.typesafe.config.ConfigFactory; +import com.typesafe.config.ConfigValueFactory; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({JettyServer.class, Server.class, QueuedThreadPool.class, ServerConnector.class, + HttpConfiguration.class, HttpConnectionFactory.class, WebSocketPolicy.class, + WebSocketServerFactory.class }) +public class JettyServerTest { + + Map httpConfig = ImmutableMap. builder() + .put("HeaderCacheSize", "8k") + .put("RequestHeaderSize", "8k") + .put("ResponseHeaderSize", "8k") + .put("FileSizeThreshold", "16k") + .put("SendServerVersion", false) + .put("SendXPoweredBy", false) + .put("SendDateHeader", false) + .put("OutputBufferSize", "32k") + .put("BadOption", "bad") + .put("connector", ImmutableMap. builder() + .put("AcceptQueueSize", 0) + .put("SoLingerTime", -1) + .put("StopTimeout", "3s") + .put("IdleTimeout", "3s") + .build()) + .build(); + + Map ws = ImmutableMap. builder() + .put("MaxTextMessageSize", "64k") + .put("MaxTextMessageBufferSize", "32k") + .put("MaxBinaryMessageSize", "64k") + .put("MaxBinaryMessageBufferSize", "32kB") + .put("AsyncWriteTimeout", 60000) + .put("IdleTimeout", "5minutes") + .put("InputBufferSize", "4k") + .build(); + + Config config = ConfigFactory.empty() + .withValue("jetty.threads.MinThreads", ConfigValueFactory.fromAnyRef("1")) + .withValue("jetty.threads.MaxThreads", ConfigValueFactory.fromAnyRef("10")) + .withValue("jetty.threads.IdleTimeout", ConfigValueFactory.fromAnyRef("3s")) + .withValue("jetty.threads.Name", ConfigValueFactory.fromAnyRef("jetty task")) + .withValue("jetty.FileSizeThreshold", ConfigValueFactory.fromAnyRef(1024)) + .withValue("jetty.url.charset", ConfigValueFactory.fromAnyRef("UTF-8")) + .withValue("jetty.http", ConfigValueFactory.fromAnyRef(httpConfig)) + .withValue("jetty.ws", ConfigValueFactory.fromAnyRef(ws)) + .withValue("server.http.MaxRequestSize", ConfigValueFactory.fromAnyRef("200k")) + .withValue("server.http2.enabled", ConfigValueFactory.fromAnyRef(false)) + .withValue("application.port", ConfigValueFactory.fromAnyRef(6789)) + .withValue("application.host", ConfigValueFactory.fromAnyRef("0.0.0.0")) + .withValue("application.tmpdir", ConfigValueFactory.fromAnyRef("target")); + + private MockUnit.Block pool = unit -> { + QueuedThreadPool pool = unit.mockConstructor(QueuedThreadPool.class); + unit.registerMock(QueuedThreadPool.class, pool); + + pool.setMaxThreads(10); + pool.setMinThreads(1); + pool.setIdleTimeout(3000); + pool.setName("jetty task"); + }; + + private MockUnit.Block server = unit -> { + Server server = unit.constructor(Server.class) + .args(ThreadPool.class) + .build(unit.get(QueuedThreadPool.class)); + + ContextHandler ctx = unit.constructor(ContextHandler.class) + .build(); + ctx.setContextPath("/"); + ctx.setHandler(isA(JettyHandler.class)); + ctx.setAttribute(eq(DecoratedObjectFactory.ATTR), isA(DecoratedObjectFactory.class)); + expect(ctx.getServletContext()).andReturn(unit.get(ContextHandler.Context.class)); + + server.setStopAtShutdown(false); + server.setHandler(ctx); + server.start(); + server.join(); + server.stop(); + + unit.registerMock(Server.class, server); + + expect(server.getThreadPool()).andReturn(unit.get(QueuedThreadPool.class)).anyTimes(); + }; + + private MockUnit.Block httpConf = unit -> { + HttpConfiguration conf = unit.mockConstructor(HttpConfiguration.class); + conf.setOutputBufferSize(32768); + conf.setRequestHeaderSize(8192); + conf.setSendXPoweredBy(false); + conf.setHeaderCacheSize(8192); + conf.setSendServerVersion(false); + conf.setSendDateHeader(false); + conf.setResponseHeaderSize(8192); + + unit.registerMock(HttpConfiguration.class, conf); + }; + + private MockUnit.Block httpFactory = unit -> { + HttpConnectionFactory factory = unit.constructor(HttpConnectionFactory.class) + .args(HttpConfiguration.class) + .build(unit.get(HttpConfiguration.class)); + + unit.registerMock(HttpConnectionFactory.class, factory); + }; + + private MockUnit.Block connector = unit -> { + ServerConnector connector = unit.constructor(ServerConnector.class) + .args(Server.class, ConnectionFactory[].class) + .build(unit.get(HttpConnectionFactory.class)); + + connector.setSoLingerTime(-1); + connector.setIdleTimeout(3000); + connector.setStopTimeout(3000); + connector.setAcceptQueueSize(0); + connector.setPort(6789); + connector.setHost("0.0.0.0"); + + unit.registerMock(ServerConnector.class, connector); + + Server server = unit.get(Server.class); + server.addConnector(connector); + }; + + private Block wsPolicy = unit -> { + WebSocketPolicy policy = unit.constructor(WebSocketPolicy.class) + .args(WebSocketBehavior.class) + .build(WebSocketBehavior.SERVER); + + policy.setAsyncWriteTimeout(60000L); + policy.setMaxBinaryMessageSize(65536); + policy.setMaxBinaryMessageBufferSize(32000); + policy.setIdleTimeout(300000L); + policy.setMaxTextMessageSize(65536); + policy.setMaxTextMessageBufferSize(32768); + policy.setInputBufferSize(4096); + + unit.registerMock(WebSocketPolicy.class, policy); + }; + + private Block wsFactory = unit -> { + WebSocketServerFactory factory = unit.constructor(WebSocketServerFactory.class) + .args(ServletContext.class, WebSocketPolicy.class) + .build(unit.get(ContextHandler.Context.class), unit.get(WebSocketPolicy.class)); + + factory.setCreator(isA(WebSocketCreator.class)); + + factory.setStopTimeout(30000L); + + unit.registerMock(WebSocketServerFactory.class, factory); + }; + + @SuppressWarnings("unchecked") + @Test + public void startStopServer() throws Exception { + + new MockUnit(HttpHandler.class, Provider.class, ContextHandler.Context.class) + .expect(pool) + .expect(server) + .expect(httpConf) + .expect(httpFactory) + .expect(connector) + .expect(wsPolicy) + .expect(wsFactory) + .run(unit -> { + JettyServer server = new JettyServer(unit.get(HttpHandler.class), config, + unit.get(Provider.class)); + + assertNotNull(server.executor()); + server.start(); + assertTrue(server.executor().isPresent()); + server.join(); + server.stop(); + }); + } + + @SuppressWarnings("unchecked") + @Test(expected = IllegalArgumentException.class) + public void badOption() throws Exception { + + new MockUnit(HttpHandler.class, Provider.class) + .expect(unit -> { + QueuedThreadPool pool = unit.mockConstructor(QueuedThreadPool.class); + unit.registerMock(QueuedThreadPool.class, pool); + + pool.setMaxThreads(10); + expectLastCall().andThrow(new IllegalArgumentException("10")); + }) + .run(unit -> { + new JettyServer(unit.get(HttpHandler.class), config, unit.get(Provider.class)); + }); + } + + @SuppressWarnings("unchecked") + @Test(expected = ConfigException.BadValue.class) + public void badConfOption() throws Exception { + + new MockUnit(HttpHandler.class, Provider.class) + .run(unit -> { + new JettyServer(unit.get(HttpHandler.class), + config.withValue("jetty.threads.MinThreads", ConfigValueFactory.fromAnyRef("x")), + unit.get(Provider.class)); + }); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/jetty/JettySseTest.java b/jooby/src/test/java-excluded/org/jooby/internal/jetty/JettySseTest.java new file mode 100644 index 00000000..efcfd48c --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/jetty/JettySseTest.java @@ -0,0 +1,216 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.jetty; + +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.expectLastCall; +import org.eclipse.jetty.io.EofException; +import org.eclipse.jetty.server.Connector; +import org.eclipse.jetty.server.HttpChannel; +import org.eclipse.jetty.server.HttpOutput; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.jooby.test.MockUnit; +import org.jooby.test.MockUnit.Block; +import static org.junit.Assert.assertEquals; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +import javax.servlet.AsyncContext; +import java.io.IOException; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({JettySse.class, Executors.class}) +public class JettySseTest { + + private Block httpOutput = unit -> { + Response rsp = unit.get(Response.class); + expect(rsp.getHttpOutput()).andReturn(unit.get(HttpOutput.class)); + }; + + @Test + public void defaults() throws Exception { + new MockUnit(Request.class, Response.class, HttpOutput.class) + .expect(httpOutput) + .run(unit -> { + new JettySse(unit.get(Request.class), unit.get(Response.class)); + }); + } + + @Test + public void send() throws Exception { + byte[] bytes = {0}; + CountDownLatch latch = new CountDownLatch(1); + new MockUnit(Request.class, Response.class, HttpOutput.class) + .expect(httpOutput) + .expect(write(bytes)) + .run(unit -> { + new JettySse(unit.get(Request.class), + unit.get(Response.class)) + .send(Optional.of("1"), bytes).whenComplete((id, x) -> { + if (x == null) { + assertEquals("1", id.get()); + latch.countDown(); + } + }); + latch.await(); + }); + } + + @Test + public void sendFailure() throws Exception { + byte[] bytes = {0}; + IOException cause = new IOException("intentional error"); + CountDownLatch latch = new CountDownLatch(1); + new MockUnit(Request.class, Response.class, HttpOutput.class) + .expect(httpOutput) + .expect(unit -> { + HttpOutput output = unit.get(HttpOutput.class); + output.write(bytes); + expectLastCall().andThrow(cause); + }) + .run(unit -> { + new JettySse(unit.get(Request.class), + unit.get(Response.class)) + .send(Optional.of("1"), bytes).whenComplete((id, x) -> { + if (x != null) { + assertEquals(cause, x); + latch.countDown(); + } + }); + latch.await(); + }); + } + + @Test + public void handshake() throws Exception { + new MockUnit(Request.class, Response.class, HttpOutput.class, Runnable.class, + AsyncContext.class, HttpChannel.class, Connector.class, Executor.class) + .expect(httpOutput) + .expect(unit -> { + AsyncContext async = unit.get(AsyncContext.class); + async.setTimeout(0L); + + Request req = unit.get(Request.class); + expect(req.getAsyncContext()).andReturn(async); + }) + .expect(unit -> { + Response rsp = unit.get(Response.class); + rsp.setStatus(200); + rsp.setHeader("Connection", "Close"); + rsp.setContentType("text/event-stream; charset=utf-8"); + rsp.flushBuffer(); + + HttpChannel channel = unit.get(HttpChannel.class); + expect(rsp.getHttpChannel()).andReturn(channel); + + Connector connector = unit.get(Connector.class); + expect(channel.getConnector()).andReturn(connector); + + Executor executor = unit.get(Executor.class); + expect(connector.getExecutor()).andReturn(executor); + + executor.execute(unit.get(Runnable.class)); + }) + .run(unit -> { + new JettySse(unit.get(Request.class), unit.get(Response.class)) + .handshake(unit.get(Runnable.class)); + }); + } + + @SuppressWarnings("resource") + @Test + public void shouldCloseEof() throws Exception { + + new MockUnit(Request.class, Response.class, HttpOutput.class) + .expect(httpOutput) + .run(unit -> { + JettySse sse = new JettySse(unit.get(Request.class), unit.get(Response.class)); + assertEquals(true, sse.shouldClose(new EofException())); + }); + } + + @SuppressWarnings("resource") + @Test + public void shouldCloseBrokenPipe() throws Exception { + + new MockUnit(Request.class, Response.class, HttpOutput.class) + .expect(httpOutput) + .run(unit -> { + JettySse sse = new JettySse(unit.get(Request.class), unit.get(Response.class)); + assertEquals(true, sse.shouldClose(new IOException("broken pipe"))); + }); + } + + @Test + public void close() throws Exception { + new MockUnit(Request.class, Response.class, HttpOutput.class) + .expect(httpOutput) + .expect(unit -> { + Response rsp = unit.get(Response.class); + rsp.closeOutput(); + }) + .run(unit -> { + JettySse sse = new JettySse(unit.get(Request.class), unit.get(Response.class)); + sse.close(); + }); + } + + @Test + public void ignoreClosedStream() throws Exception { + new MockUnit(Request.class, Response.class, HttpOutput.class) + .expect(httpOutput) + .expect(unit -> { + Response rsp = unit.get(Response.class); + rsp.closeOutput(); + }) + .run(unit -> { + JettySse sse = new JettySse(unit.get(Request.class), unit.get(Response.class)); + sse.close(); + sse.close(); + }); + } + + @Test + public void closeFailure() throws Exception { + new MockUnit(Request.class, Response.class, HttpOutput.class) + .expect(httpOutput) + .expect(unit -> { + Response rsp = unit.get(Response.class); + rsp.closeOutput(); + expectLastCall().andThrow(new EofException("intentional err")); + }) + .run(unit -> { + JettySse sse = new JettySse(unit.get(Request.class), unit.get(Response.class)); + sse.close(); + }); + } + + private Block write(final byte[] bytes) { + return unit -> { + HttpOutput output = unit.get(HttpOutput.class); + output.write(bytes); + output.flush(); + }; + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/jetty/JettyWebSocketTest.java b/jooby/src/test/java-excluded/org/jooby/internal/jetty/JettyWebSocketTest.java new file mode 100644 index 00000000..b2078c58 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/jetty/JettyWebSocketTest.java @@ -0,0 +1,160 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.jetty; + +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.expectLastCall; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.SuspendToken; +import org.eclipse.jetty.websocket.api.WriteCallback; +import org.jooby.WebSocket; +import org.jooby.WebSocket.OnError; +import org.jooby.WebSocket.SuccessCallback; +import org.jooby.test.MockUnit; +import org.junit.Test; +import org.slf4j.Logger; + +import java.util.function.Consumer; + +public class JettyWebSocketTest { + + @Test + public void newObject() throws Exception { + new MockUnit() + .run(unit -> { + new JettyWebSocket(); + }); + } + + @Test + public void resume() throws Exception { + new MockUnit() + .run(unit -> { + new JettyWebSocket().resume(); + }); + } + + @Test + public void pause() throws Exception { + JettyWebSocket ws = new JettyWebSocket(); + new MockUnit(Session.class, Runnable.class, SuspendToken.class) + .expect(unit -> { + Runnable connect = unit.get(Runnable.class); + connect.run(); + }) + .expect(unit -> { + SuspendToken token = unit.get(SuspendToken.class); + token.resume(); + + Session session = unit.get(Session.class); + expect(session.suspend()).andReturn(token); + }) + .run(unit -> { + ws.onConnect(unit.get(Runnable.class)); + ws.onWebSocketConnect(unit.get(Session.class)); + ws.pause(); + ws.pause(); + ws.resume();; + }); + } + + @SuppressWarnings({"unchecked", "rawtypes" }) + @Test + public void onWebSocketError() throws Exception { + Throwable cause = new Throwable(); + JettyWebSocket ws = new JettyWebSocket(); + new MockUnit(Consumer.class) + .expect(unit -> { + Consumer callback = unit.get(Consumer.class); + ws.onErrorMessage(callback); + callback.accept(cause); + }) + .run(unit -> { + ws.onWebSocketError(cause); + }); + } + + @Test + public void successCallback() throws Exception { + new MockUnit(Consumer.class, Logger.class, WebSocket.SuccessCallback.class, + WebSocket.OnError.class) + .expect(unit -> { + SuccessCallback callback = unit.get(WebSocket.SuccessCallback.class); + callback.invoke(); + }) + .run(unit -> { + WriteCallback callback = JettyWebSocket.callback(unit.get(Logger.class), + unit.get(WebSocket.SuccessCallback.class), unit.get(WebSocket.OnError.class)); + callback.writeSuccess(); + }); + } + + @Test + public void successCallbackErr() throws Exception { + IllegalStateException cause = new IllegalStateException("intentional err"); + new MockUnit(Consumer.class, Logger.class, WebSocket.SuccessCallback.class, + WebSocket.OnError.class) + .expect(unit -> { + SuccessCallback callback = unit.get(WebSocket.SuccessCallback.class); + callback.invoke(); + expectLastCall().andThrow(cause); + + Logger logger = unit.get(Logger.class); + logger.error("Error while invoking success callback", cause); + }) + .run(unit -> { + WriteCallback callback = JettyWebSocket.callback(unit.get(Logger.class), + unit.get(WebSocket.SuccessCallback.class), unit.get(WebSocket.OnError.class)); + callback.writeSuccess(); + }); + } + + @Test + public void errCallback() throws Exception { + IllegalStateException cause = new IllegalStateException("intentional err"); + new MockUnit(Consumer.class, Logger.class, WebSocket.SuccessCallback.class, + WebSocket.OnError.class) + .expect(unit -> { + OnError callback = unit.get(WebSocket.OnError.class); + callback.onError(cause); + }) + .run(unit -> { + WriteCallback callback = JettyWebSocket.callback(unit.get(Logger.class), + unit.get(WebSocket.SuccessCallback.class), unit.get(WebSocket.OnError.class)); + callback.writeFailed(cause); + }); + } + + @Test + public void errCallbackFailure() throws Exception { + IllegalStateException cause = new IllegalStateException("intentional err"); + new MockUnit(Consumer.class, Logger.class, WebSocket.SuccessCallback.class, + WebSocket.OnError.class) + .expect(unit -> { + OnError callback = unit.get(WebSocket.OnError.class); + callback.onError(cause); + expectLastCall().andThrow(cause); + + Logger logger = unit.get(Logger.class); + logger.error("Error while invoking err callback", cause); + }) + .run(unit -> { + WriteCallback callback = JettyWebSocket.callback(unit.get(Logger.class), + unit.get(WebSocket.SuccessCallback.class), unit.get(WebSocket.OnError.class)); + callback.writeFailed(cause); + }); + } +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/mapper/CallableMapperTest.java b/jooby/src/test/java-excluded/org/jooby/internal/mapper/CallableMapperTest.java new file mode 100644 index 00000000..b32bc0bc --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/mapper/CallableMapperTest.java @@ -0,0 +1,86 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.mapper; + +import static org.easymock.EasyMock.expect; + +import java.util.concurrent.Callable; + +import org.jooby.Deferred; +import org.jooby.Deferred.Initializer0; +import org.jooby.test.MockUnit; +import org.jooby.test.MockUnit.Block; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({CallableMapper.class, Deferred.class }) +public class CallableMapperTest { + + private Block deferred = unit -> { + Deferred deferred = unit.constructor(Deferred.class) + .args(Deferred.Initializer0.class) + .build(unit.capture(Deferred.Initializer0.class)); + unit.registerMock(Deferred.class, deferred); + }; + + private Block init0 = unit -> { + Initializer0 next = unit.captured(Deferred.Initializer0.class).iterator().next(); + next.run(unit.get(Deferred.class)); + }; + + @SuppressWarnings("rawtypes") + @Test + public void resolve() throws Exception { + Object value = new Object(); + new MockUnit(Callable.class) + .expect(deferred) + .expect(unit -> { + Callable callable = unit.get(Callable.class); + expect(callable.call()).andReturn(value); + }) + .expect(unit -> { + Deferred deferred = unit.get(Deferred.class); + deferred.resolve(value); + }) + .run(unit -> { + new CallableMapper() + .map(unit.get(Callable.class)); + }, init0); + } + + @SuppressWarnings("rawtypes") + @Test + public void reject() throws Exception { + Exception value = new Exception(); + new MockUnit(Callable.class) + .expect(deferred) + .expect(unit -> { + Callable callable = unit.get(Callable.class); + expect(callable.call()).andThrow(value); + }) + .expect(unit -> { + Deferred deferred = unit.get(Deferred.class); + deferred.reject(value); + }) + .run(unit -> { + new CallableMapper() + .map(unit.get(Callable.class)); + }, init0); + } +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/mapper/CompletableFutureMapperTest.java b/jooby/src/test/java-excluded/org/jooby/internal/mapper/CompletableFutureMapperTest.java new file mode 100644 index 00000000..0ef73fe2 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/mapper/CompletableFutureMapperTest.java @@ -0,0 +1,93 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.mapper; + +import static org.easymock.EasyMock.expect; + +import java.util.concurrent.CompletableFuture; +import java.util.function.BiConsumer; + +import org.jooby.Deferred; +import org.jooby.Deferred.Initializer0; +import org.jooby.test.MockUnit; +import org.jooby.test.MockUnit.Block; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({CompletableFutureMapper.class, Deferred.class }) +public class CompletableFutureMapperTest { + + private Block deferred = unit -> { + Deferred deferred = unit.constructor(Deferred.class) + .args(Deferred.Initializer0.class) + .build(unit.capture(Deferred.Initializer0.class)); + unit.registerMock(Deferred.class, deferred); + }; + + @SuppressWarnings({"unchecked", "rawtypes" }) + private Block future = unit -> { + CompletableFuture future = unit.get(CompletableFuture.class); + expect(future.whenComplete(unit.capture(BiConsumer.class))).andReturn(future); + }; + + private Block init0 = unit -> { + Initializer0 next = unit.captured(Deferred.Initializer0.class).iterator().next(); + next.run(unit.get(Deferred.class)); + }; + + @SuppressWarnings({"rawtypes", "unchecked" }) + @Test + public void resolve() throws Exception { + Object value = new Object(); + new MockUnit(CompletableFuture.class) + .expect(deferred) + .expect(future) + .expect(unit -> { + Deferred deferred = unit.get(Deferred.class); + deferred.resolve(value); + }) + .run(unit -> { + new CompletableFutureMapper() + .map(unit.get(CompletableFuture.class)); + }, init0, unit -> { + BiConsumer next = unit.captured(BiConsumer.class).iterator().next(); + next.accept(value, null); + }); + } + + @SuppressWarnings({"rawtypes", "unchecked" }) + @Test + public void reject() throws Exception { + Throwable value = new Throwable(); + new MockUnit(CompletableFuture.class) + .expect(deferred) + .expect(future) + .expect(unit -> { + Deferred deferred = unit.get(Deferred.class); + deferred.reject(value); + }) + .run(unit -> { + new CompletableFutureMapper() + .map(unit.get(CompletableFuture.class)); + }, init0, unit -> { + BiConsumer next = unit.captured(BiConsumer.class).iterator().next(); + next.accept(null, value); + }); + } +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/mvc/MvcHandlerTest.java b/jooby/src/test/java-excluded/org/jooby/internal/mvc/MvcHandlerTest.java new file mode 100644 index 00000000..b76de20c --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/mvc/MvcHandlerTest.java @@ -0,0 +1,180 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.mvc; + +import static org.easymock.EasyMock.expect; +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Route; +import org.jooby.Status; +import org.jooby.test.MockUnit; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.List; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({MvcHandler.class }) +public class MvcHandlerTest { + + @Test + public void defaults() throws Exception { + new MockUnit(Method.class, Object.class, RequestParamProvider.class) + .run(unit -> { + new MvcHandler(unit.get(Method.class), unit.get(Object.class).getClass(), unit.get(RequestParamProvider.class)); + }); + } + + @Test + public void handleNOOP() throws Exception { + new MockUnit(Method.class, Object.class, RequestParamProvider.class, Request.class, Response.class) + .run(unit -> { + new MvcHandler(unit.get(Method.class), unit.get(Object.class).getClass(), unit.get(RequestParamProvider.class)) + .handle(unit.get(Request.class), unit.get(Response.class)); + }); + } + + @SuppressWarnings({"rawtypes", "unchecked" }) + @Test + public void handle() throws Exception { + Class handlerClass = MvcHandlerTest.class; + MvcHandlerTest handler = new MvcHandlerTest(); + Method method = handlerClass.getDeclaredMethod("strhandle"); + new MockUnit(RequestParamProvider.class, Request.class, Response.class, Route.Chain.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.require(MvcHandlerTest.class)).andReturn(handler); + }) + .expect(unit -> { + Response rsp = unit.get(Response.class); + expect(rsp.committed()).andReturn(false); + expect(rsp.status(Status.OK)).andReturn(rsp); + rsp.send("strhandle"); + unit.get(Route.Chain.class).next(unit.get(Request.class), rsp); + }) + .expect(unit -> { + List params = Collections.emptyList(); + RequestParamProvider paramProvider = unit.get(RequestParamProvider.class); + expect(paramProvider.parameters(method)).andReturn(params); + }) + .run(unit -> { + new MvcHandler(method, handlerClass, unit.get(RequestParamProvider.class)) + .handle(unit.get(Request.class), unit.get(Response.class), unit.get(Route.Chain.class)); + }); + } + + @SuppressWarnings({"rawtypes", "unchecked" }) + @Test + public void handleAbstractHandlers() throws Exception { + Class handlerClass = FinalMvcHandler.class; + Class abstractHandlerClass = AbstractMvcHandler.class; + FinalMvcHandler handler = new FinalMvcHandler(); + Method method = abstractHandlerClass.getDeclaredMethod("abstrStrHandle"); + new MockUnit(RequestParamProvider.class, Request.class, Response.class, Route.Chain.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.require(FinalMvcHandler.class)).andReturn(handler); + }) + .expect(unit -> { + Response rsp = unit.get(Response.class); + expect(rsp.committed()).andReturn(false); + expect(rsp.status(Status.OK)).andReturn(rsp); + rsp.send("abstrStrHandle"); + unit.get(Route.Chain.class).next(unit.get(Request.class), rsp); + }) + .expect(unit -> { + List params = Collections.emptyList(); + RequestParamProvider paramProvider = unit.get(RequestParamProvider.class); + expect(paramProvider.parameters(method)).andReturn(params); + }) + .run(unit -> { + new MvcHandler(method, handlerClass, unit.get(RequestParamProvider.class)) + .handle(unit.get(Request.class), unit.get(Response.class), unit.get(Route.Chain.class)); + }); + } + + @SuppressWarnings({"rawtypes", "unchecked" }) + @Test(expected = IOException.class) + public void handleException() throws Exception { + Class handlerClass = MvcHandlerTest.class; + MvcHandlerTest handler = new MvcHandlerTest(); + Method method = handlerClass.getDeclaredMethod("errhandle"); + new MockUnit(RequestParamProvider.class, Request.class, Response.class, Route.Chain.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.require(MvcHandlerTest.class)).andReturn(handler); + }) + .expect(unit -> { + List params = Collections.emptyList(); + RequestParamProvider paramProvider = unit.get(RequestParamProvider.class); + expect(paramProvider.parameters(method)).andReturn(params); + }) + .run(unit -> { + new MvcHandler(method, handlerClass, unit.get(RequestParamProvider.class)) + .handle(unit.get(Request.class), unit.get(Response.class), unit.get(Route.Chain.class)); + }); + } + + @SuppressWarnings({"rawtypes", "unchecked" }) + @Test(expected = Throwable.class) + public void throwableException() throws Exception { + Class handlerClass = MvcHandlerTest.class; + MvcHandlerTest handler = new MvcHandlerTest(); + Method method = handlerClass.getDeclaredMethod("throwablehandle"); + new MockUnit(RequestParamProvider.class, Request.class, Response.class, Route.Chain.class) + .expect(unit -> { + Request req = unit.get(Request.class); + expect(req.require(MvcHandlerTest.class)).andReturn(handler); + }) + .expect(unit -> { + List params = Collections.emptyList(); + RequestParamProvider paramProvider = unit.get(RequestParamProvider.class); + expect(paramProvider.parameters(method)).andReturn(params); + }) + .run(unit -> { + new MvcHandler(method, handlerClass, unit.get(RequestParamProvider.class)) + .handle(unit.get(Request.class), unit.get(Response.class), unit.get(Route.Chain.class)); + }); + } + + public String strhandle() throws Exception { + return "strhandle"; + } + + public String errhandle() throws Exception { + throw new IOException("intentional err"); + } + + public String throwablehandle() throws Throwable { + throw new Throwable("intentional err"); + } +} + +abstract class AbstractMvcHandler { + public abstract String abstrStrHandle() throws Exception; + +} + +final class FinalMvcHandler extends AbstractMvcHandler { + public String abstrStrHandle() throws Exception { + return "abstrStrHandle"; + } +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/mvc/MvcRoutesTest.java b/jooby/src/test/java-excluded/org/jooby/internal/mvc/MvcRoutesTest.java new file mode 100644 index 00000000..3038b473 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/mvc/MvcRoutesTest.java @@ -0,0 +1,60 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.mvc; + +import static org.easymock.EasyMock.expect; + +import java.util.List; + +import org.jooby.Env; +import org.jooby.Route.Definition; +import org.jooby.internal.RouteMetadata; +import org.jooby.mvc.GET; +import org.jooby.test.MockUnit; +import org.junit.Test; + +public class MvcRoutesTest { + + public static class NoPath { + + @GET + public void nopath() { + + } + + } + + @Test + public void emptyConstructor() throws Exception { + new MvcRoutes(); + + } + + @Test(expected = IllegalArgumentException.class) + public void nopath() throws Exception { + new MockUnit(Env.class) + .expect(unit -> { + Env env = unit.get(Env.class); + expect(env.name()).andReturn("dev").times(2); + }) + .run(unit -> { + Env env = unit.get(Env.class); + List routes = MvcRoutes.routes(env, new RouteMetadata(env), "", + true, NoPath.class); + System.out.println(routes); + }); + } +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/mvc/MvcWebSocketTest.java b/jooby/src/test/java-excluded/org/jooby/internal/mvc/MvcWebSocketTest.java new file mode 100644 index 00000000..3ad9d669 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/mvc/MvcWebSocketTest.java @@ -0,0 +1,285 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.mvc; + +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.isA; + +import java.util.List; + +import org.jooby.Mutant; +import org.jooby.Request; +import org.jooby.WebSocket; +import org.jooby.WebSocket.CloseStatus; +import org.jooby.test.MockUnit; +import org.jooby.test.MockUnit.Block; +import org.junit.Test; + +import com.google.common.collect.ImmutableList; +import com.google.inject.Binder; +import com.google.inject.Injector; +import com.google.inject.Module; +import com.google.inject.TypeLiteral; +import com.google.inject.binder.AnnotatedBindingBuilder; +import com.google.inject.util.Types; + +public class MvcWebSocketTest { + + public static class ImNotACallback { + } + + @SuppressWarnings("rawtypes") + public static class CallbackWithoutType implements WebSocket.OnMessage { + @Override + public void onMessage(final Object message) throws Exception { + } + } + + public static class StringSocket implements WebSocket.OnMessage { + @Override + public void onMessage(final String message) throws Exception { + } + } + + public static class Pojo { + + } + + public static class PojoSocket implements WebSocket.OnMessage { + @Override + public void onMessage(final Pojo message) throws Exception { + } + } + + public static class PojoListSocket implements WebSocket.OnMessage> { + @Override + public void onMessage(final List message) throws Exception { + } + } + + public static class MySocket + implements WebSocket.OnMessage, WebSocket.OnClose, WebSocket.OnError, + WebSocket.OnOpen { + + @Override + public void onMessage(final String data) throws Exception { + } + + @Override + public void onOpen(final Request req, final WebSocket ws) throws Exception { + } + + @Override + public void onError(final Throwable err) { + } + + @Override + public void onClose(final CloseStatus status) throws Exception { + } + + } + + @Test + public void newInstance() throws Exception { + new MockUnit(WebSocket.class, Injector.class, MySocket.class, Binder.class) + .expect(childInjector(MySocket.class)) + .run(unit -> { + new MvcWebSocket(unit.get(WebSocket.class), MySocket.class); + }, unit -> { + unit.captured(Module.class).iterator().next().configure(unit.get(Binder.class)); + }); + } + + @Test + public void onClose() throws Exception { + new MockUnit(WebSocket.class, Injector.class, MySocket.class, Binder.class) + .expect(childInjector(MySocket.class)) + .expect(unit -> { + unit.get(MySocket.class).onClose(WebSocket.NORMAL); + }) + .run(unit -> { + new MvcWebSocket(unit.get(WebSocket.class), MySocket.class).onClose(WebSocket.NORMAL); + }, unit -> { + unit.captured(Module.class).iterator().next().configure(unit.get(Binder.class)); + }); + } + + @Test + public void shouldIgnoreOnClose() throws Exception { + new MockUnit(WebSocket.class, Injector.class, StringSocket.class, Binder.class) + .expect(childInjector(StringSocket.class)) + .run(unit -> { + new MvcWebSocket(unit.get(WebSocket.class), StringSocket.class).onClose(WebSocket.NORMAL); + }, unit -> { + unit.captured(Module.class).iterator().next().configure(unit.get(Binder.class)); + }); + } + + @Test + public void onStringMessage() throws Exception { + new MockUnit(WebSocket.class, Injector.class, StringSocket.class, Binder.class, Mutant.class) + .expect(childInjector(StringSocket.class)) + .expect(mutant(TypeLiteral.get(String.class), "string")) + .expect(unit -> { + StringSocket socket = unit.get(StringSocket.class); + socket.onMessage("string"); + }) + .run(unit -> { + new MvcWebSocket(unit.get(WebSocket.class), StringSocket.class) + .onMessage(unit.get(Mutant.class)); + }, unit -> { + unit.captured(Module.class).iterator().next().configure(unit.get(Binder.class)); + }); + } + + @Test + public void onPojoMessage() throws Exception { + Pojo pojo = new Pojo(); + new MockUnit(WebSocket.class, Injector.class, PojoSocket.class, Binder.class, Mutant.class) + .expect(childInjector(PojoSocket.class)) + .expect(mutant(TypeLiteral.get(Pojo.class), pojo)) + .expect(unit -> { + PojoSocket socket = unit.get(PojoSocket.class); + socket.onMessage(pojo); + }) + .run(unit -> { + new MvcWebSocket(unit.get(WebSocket.class), PojoSocket.class) + .onMessage(unit.get(Mutant.class)); + }, unit -> { + unit.captured(Module.class).iterator().next().configure(unit.get(Binder.class)); + }); + } + + @Test + public void onListPojoMessage() throws Exception { + Pojo pojo = new Pojo(); + new MockUnit(WebSocket.class, Injector.class, PojoListSocket.class, Binder.class, Mutant.class) + .expect(childInjector(PojoListSocket.class)) + .expect(mutant(TypeLiteral.get(Types.listOf(Pojo.class)), ImmutableList.of(pojo))) + .expect(unit -> { + PojoListSocket socket = unit.get(PojoListSocket.class); + socket.onMessage(ImmutableList.of(pojo)); + }) + .run(unit -> { + new MvcWebSocket(unit.get(WebSocket.class), PojoListSocket.class) + .onMessage(unit.get(Mutant.class)); + }, unit -> { + unit.captured(Module.class).iterator().next().configure(unit.get(Binder.class)); + }); + } + + @Test(expected = IllegalArgumentException.class) + public void messageTypeShouldFailOnWrongCallback() throws Exception { + MvcWebSocket.messageType(ImNotACallback.class); + } + + @Test(expected = IllegalArgumentException.class) + public void messageTypeShouldFailOnCallbackWithoutType() throws Exception { + MvcWebSocket.messageType(CallbackWithoutType.class); + } + + @SuppressWarnings({"rawtypes", "unchecked" }) + private Block mutant(final TypeLiteral type, final T value) { + return unit -> { + Mutant mutant = unit.get(Mutant.class); + expect(mutant. to(type)).andReturn(value); + }; + } + + @Test + public void onOpen() throws Exception { + new MockUnit(Request.class, WebSocket.class, Injector.class, MySocket.class, Binder.class) + .expect(childInjector(MySocket.class)) + .expect(unit -> { + unit.get(MySocket.class).onOpen(unit.get(Request.class), unit.get(WebSocket.class)); + }) + .run(unit -> { + new MvcWebSocket(unit.get(WebSocket.class), MySocket.class) + .onOpen(unit.get(Request.class), unit.get(WebSocket.class)); + }, unit -> { + unit.captured(Module.class).iterator().next().configure(unit.get(Binder.class)); + }); + } + + @Test + public void onError() throws Exception { + new MockUnit(Throwable.class, WebSocket.class, Injector.class, MySocket.class, Binder.class) + .expect(childInjector(MySocket.class)) + .expect(unit -> { + unit.get(MySocket.class).onError(unit.get(Throwable.class)); + }) + .run(unit -> { + new MvcWebSocket(unit.get(WebSocket.class), MySocket.class) + .onError(unit.get(Throwable.class)); + }, unit -> { + unit.captured(Module.class).iterator().next().configure(unit.get(Binder.class)); + }); + } + + @Test + public void shouldIgnoreOnError() throws Exception { + new MockUnit(Throwable.class, WebSocket.class, Injector.class, StringSocket.class, Binder.class) + .expect(childInjector(StringSocket.class)) + .run(unit -> { + new MvcWebSocket(unit.get(WebSocket.class), StringSocket.class) + .onError(unit.get(Throwable.class)); + }, unit -> { + unit.captured(Module.class).iterator().next().configure(unit.get(Binder.class)); + }); + } + + @SuppressWarnings("unchecked") + @Test + public void newWebSocket() throws Exception { + new MockUnit(Request.class, WebSocket.class, Injector.class, MySocket.class, Binder.class) + .expect(unit -> { + WebSocket ws = unit.get(WebSocket.class); + MySocket mvc = unit.get(MySocket.class); + mvc.onOpen(unit.get(Request.class), unit.get(WebSocket.class)); + ws.onClose(isA(WebSocket.OnClose.class)); + ws.onError(isA(WebSocket.OnError.class)); + ws.onMessage(isA(WebSocket.OnMessage.class)); + }) + .expect(childInjector(MySocket.class)) + .run(unit -> { + MvcWebSocket.newWebSocket(MySocket.class) + .onOpen(unit.get(Request.class), unit.get(WebSocket.class)); + }, unit -> { + unit.captured(Module.class).iterator().next().configure(unit.get(Binder.class)); + }); + } + + @SuppressWarnings({"rawtypes", "unchecked" }) + private Block childInjector(final Class class1) { + return unit -> { + Injector childInjector = unit.mock(Injector.class); + T socket = unit.get(class1); + expect(childInjector.getInstance(class1)).andReturn(socket); + + Injector injector = unit.get(Injector.class); + expect(injector.createChildInjector(unit.capture(Module.class))).andReturn(childInjector); + + WebSocket ws = unit.get(WebSocket.class); + expect(ws.require(Injector.class)).andReturn(injector); + + AnnotatedBindingBuilder aabbws = unit.mock(AnnotatedBindingBuilder.class); + aabbws.toInstance(ws); + + Binder binder = unit.get(Binder.class); + expect(binder.bind(WebSocket.class)).andReturn(aabbws); + }; + } +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/mvc/RequestParamNameProviderTest.java b/jooby/src/test/java-excluded/org/jooby/internal/mvc/RequestParamNameProviderTest.java new file mode 100644 index 00000000..93d55a3f --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/mvc/RequestParamNameProviderTest.java @@ -0,0 +1,59 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.mvc; + +import static org.easymock.EasyMock.expect; +import static org.junit.Assert.assertEquals; + +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; + +import org.jooby.Env; +import org.jooby.internal.RouteMetadata; +import org.jooby.test.MockUnit; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +@RunWith(PowerMockRunner.class) +@PrepareForTest(RequestParam.class ) +public class RequestParamNameProviderTest { + + public void dummy(final String dummyparam) { + + } + + @Test + public void asmname() throws Exception { + Method m = RequestParamNameProviderTest.class.getDeclaredMethod("dummy", String.class); + Parameter param = m.getParameters()[0]; + new MockUnit(Env.class) + .expect(unit -> { + Env env = unit.get(Env.class); + expect(env.name()).andReturn("dev"); + }) + .expect(unit -> { + unit.mockStatic(RequestParam.class); + expect(RequestParam.nameFor(param)).andReturn(null); + }) + .run(unit -> { + assertEquals("dummyparam", + new RequestParamNameProviderImpl(new RouteMetadata(unit.get(Env.class))).name(param)); + }); + + } +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/mvc/RequestParamTest.java b/jooby/src/test/java-excluded/org/jooby/internal/mvc/RequestParamTest.java new file mode 100644 index 00000000..9153e455 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/mvc/RequestParamTest.java @@ -0,0 +1,125 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.mvc; + +import org.jooby.Request; +import org.jooby.Response; +import org.jooby.Route; +import org.jooby.mvc.Header; +import org.jooby.mvc.Local; +import org.jooby.test.MockUnit; +import org.junit.Test; + +import javax.inject.Named; +import java.lang.reflect.Parameter; +import java.util.Optional; + +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.verify; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class RequestParamTest { + + public void javax(@Named("javax") final String s) { + + } + + public void ejavax(@Named final String s) { + } + + public void guice(@com.google.inject.name.Named("guice") final String s) { + } + + public void header(@Header("H-1") final String s) { + } + + public void namedheader(@Named("x") @Header final String s) { + } + + public void eheader(@Header final String s) { + } + + public void local(@Local String myLocal) { + + } + + @Test + public void name() throws Exception { + assertEquals("javax", RequestParam.nameFor(param("javax"))); + + assertTrue(RequestParam.nameFor(param("ejavax")) == null + || "s".equals(RequestParam.nameFor(param("ejavax")))); + + assertEquals("guice", RequestParam.nameFor(param("guice"))); + + assertEquals("H-1", RequestParam.nameFor(param("header"))); + + assertEquals("x", RequestParam.nameFor(param("namedheader"))); + + assertTrue(RequestParam.nameFor(param("eheader")) == null + || "s".equals(RequestParam.nameFor(param("eheader")))); + + } + + @Test + public void requestParam_mvcLocal_valuePresent() throws Throwable { + Parameter param = param("local"); + RequestParam requestParam = new RequestParam(param, "myLocal", param.getParameterizedType()); + + // verify that with a mock request we can indeed retrieve the 'myLocal' value + new MockUnit(Request.class) + .expect(unit -> { + Request request = unit.get(Request.class); + expect(request.ifGet("myLocal")).andReturn(Optional.of("myCustomValue")); + verify(); + }) + .run((unit) -> { + Object output = requestParam.value(unit.get(Request.class), null, null); + assertEquals("myCustomValue", output); + }); + } + + @Test + public void requestParam_mvcLocal_valueAbsent() throws Throwable { + Parameter param = param("local"); + RequestParam requestParam = new RequestParam(param, "myLocal", param.getParameterizedType()); + + // verify that we return a descriptive error when myLocal could not be located + new MockUnit(Request.class) + .expect(unit -> { + Request request = unit.get(Request.class); + expect(request.path()).andReturn("/mypath"); + expect(request.ifGet("myLocal")).andReturn(Optional.empty()); + verify(); + }) + .run((unit) -> { + RuntimeException exception = null; + try { + requestParam.value(unit.get(Request.class), null, null); + } catch(RuntimeException e) { + exception = e; + } + assertNotNull("Should have thrown an exception because the myLocal is not present", exception); + assertEquals("Server Error(500): Could not find required local 'myLocal', which was required on /mypath", exception.getMessage()); + }); + } + + private Parameter param(final String name) throws Exception { + return RequestParamTest.class.getDeclaredMethod(name, String.class).getParameters()[0]; + } +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/parser/BeanPlanTest.java b/jooby/src/test/java-excluded/org/jooby/internal/parser/BeanPlanTest.java new file mode 100644 index 00000000..cc7cd7e2 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/parser/BeanPlanTest.java @@ -0,0 +1,144 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.parser; + +import static org.junit.Assert.assertEquals; + +import javax.inject.Inject; + +import org.jooby.internal.ParameterNameProvider; +import org.jooby.internal.parser.bean.BeanPlan; +import org.jooby.test.MockUnit; +import org.junit.Test; + +import com.google.common.collect.Sets; + +public class BeanPlanTest { + + public static class TwoInject { + + @Inject + public TwoInject(final String foo) { + } + + @Inject + public TwoInject(final int foo) { + } + + } + + public static class UnknownCons { + public UnknownCons(final String foo) { + } + + public UnknownCons(final int foo) { + } + } + + public static class Base { + String foo; + } + + public static class GraphMethod { + Base base; + + public Base base() { + return base; + } + + public void base(final Base base) { + this.base = base; + } + } + + public static class Ext extends Base { + String bar; + } + + public static class SetterLike { + String bar; + + public SetterLike bar(final String bar) { + this.bar = "^" + bar; + return this; + } + } + + public static class BadSetter { + String bar; + + public void setBar() { + } + } + + @Test(expected = IllegalStateException.class) + public void shouldRejectClassWithTwoConsWithInject() throws Exception { + new MockUnit(ParameterNameProvider.class) + .run(unit -> { + new BeanPlan(unit.get(ParameterNameProvider.class), TwoInject.class); + }); + } + + @Test(expected = IllegalStateException.class) + public void shouldRejectClassWithTwoCons() throws Exception { + new MockUnit(ParameterNameProvider.class) + .run(unit -> { + new BeanPlan(unit.get(ParameterNameProvider.class), UnknownCons.class); + }); + } + + @Test + public void shouldFindMemberOnSuperclass() throws Exception { + new MockUnit(ParameterNameProvider.class) + .run(unit -> { + BeanPlan plan = new BeanPlan(unit.get(ParameterNameProvider.class), Ext.class); + Ext bean = (Ext) plan.newBean(p -> p.name, Sets.newHashSet("foo", "bar")); + assertEquals("foo", bean.foo); + assertEquals("bar", bean.bar); + }); + } + + @Test + public void shouldFavorSetterLikeMethod() throws Exception { + new MockUnit(ParameterNameProvider.class) + .run(unit -> { + BeanPlan plan = new BeanPlan(unit.get(ParameterNameProvider.class), SetterLike.class); + SetterLike bean = (SetterLike) plan.newBean(p -> p.name, Sets.newHashSet("bar")); + assertEquals("^bar", bean.bar); + }); + } + + @Test + public void shouldIgnoreSetterMethodWithZeroOrMoreArg() throws Exception { + new MockUnit(ParameterNameProvider.class) + .run(unit -> { + BeanPlan plan = new BeanPlan(unit.get(ParameterNameProvider.class), BadSetter.class); + BadSetter bean = (BadSetter) plan.newBean(p -> p.name, Sets.newHashSet("bar")); + assertEquals("bar", bean.bar); + }); + } + + @Test + public void shouldTraverseGraphMethod() throws Exception { + new MockUnit(ParameterNameProvider.class) + .run(unit -> { + BeanPlan plan = new BeanPlan(unit.get(ParameterNameProvider.class), GraphMethod.class); + GraphMethod bean = (GraphMethod) plan.newBean(p -> p.name, Sets.newHashSet("base[foo]")); + assertEquals("base[foo]", bean.base.foo); + }); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/parser/bean/BeanComplexPathTest.java b/jooby/src/test/java-excluded/org/jooby/internal/parser/bean/BeanComplexPathTest.java new file mode 100644 index 00000000..7c34c1de --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/parser/bean/BeanComplexPathTest.java @@ -0,0 +1,41 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.parser.bean; + +import static org.easymock.EasyMock.expect; +import static org.junit.Assert.assertEquals; + +import java.lang.reflect.Type; +import java.util.Arrays; + +import org.jooby.test.MockUnit; +import org.junit.Test; + +public class BeanComplexPathTest { + + @Test + public void complexPath() throws Exception { + new MockUnit(BeanPath.class, Type.class) + .expect(unit -> { + expect(unit.get(BeanPath.class).type()).andReturn(unit.get(Type.class)); + }) + .run(unit -> { + BeanComplexPath path = new BeanComplexPath(Arrays.asList(), unit.get(BeanPath.class), + "path"); + assertEquals(unit.get(Type.class), path.type()); + }); + } +} diff --git a/jooby/src/test/java-excluded/org/jooby/internal/reqparam/ParserExecutorTest.java b/jooby/src/test/java-excluded/org/jooby/internal/reqparam/ParserExecutorTest.java new file mode 100644 index 00000000..5dc6f7ce --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/internal/reqparam/ParserExecutorTest.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.reqparam; + +import static org.junit.Assert.assertEquals; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import org.jooby.Parser; +import org.jooby.internal.StatusCodeProvider; +import org.jooby.internal.parser.ParserExecutor; +import org.jooby.test.MockUnit; +import org.junit.Test; + +import com.google.common.collect.Sets; +import com.google.inject.Injector; +import com.google.inject.TypeLiteral; +import com.typesafe.config.ConfigFactory; + +public class ParserExecutorTest { + + @Test + public void params() throws Exception { + new MockUnit(Injector.class) + .run(unit -> { + Set parsers = Sets.newHashSet((Parser) (type, ctx) -> ctx.params(up -> "p")); + Object converted = new ParserExecutor(unit.get(Injector.class), parsers, + new StatusCodeProvider(ConfigFactory.empty())) + .convert(TypeLiteral.get(Map.class), new HashMap<>()); + assertEquals("p", converted); + }); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/issues/Issue372.java b/jooby/src/test/java-excluded/org/jooby/issues/Issue372.java new file mode 100644 index 00000000..9fdb4cbc --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/issues/Issue372.java @@ -0,0 +1,69 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.issues; + +import static org.easymock.EasyMock.expect; + +import org.jooby.Env; +import org.jooby.Result; +import org.jooby.Results; +import org.jooby.internal.RouteMetadata; +import org.jooby.internal.mvc.MvcRoutes; +import org.jooby.mvc.GET; +import org.jooby.mvc.Path; +import org.jooby.test.MockUnit; +import org.junit.Test; + +public class Issue372 { + + public static class PingRoute { + @Path("/ping") + @GET + private Result ping() { + return Results.ok(); + } + } + + public static class Ext extends PingRoute { + } + + @Test(expected = IllegalArgumentException.class) + public void shouldFailFastOnPrivateMvcRoutes() throws Exception { + new MockUnit(Env.class) + .expect(unit -> { + Env env = unit.get(Env.class); + expect(env.name()).andReturn("dev").times(2); + }) + .run(unit -> { + Env env = unit.get(Env.class); + MvcRoutes.routes(env, new RouteMetadata(env), "", true, PingRoute.class); + }); + } + + @Test(expected = IllegalArgumentException.class) + public void shouldFailFastOnPrivateMvcRoutesExt() throws Exception { + new MockUnit(Env.class) + .expect(unit -> { + Env env = unit.get(Env.class); + expect(env.name()).andReturn("dev").times(2); + }) + .run(unit -> { + Env env = unit.get(Env.class); + MvcRoutes.routes(env, new RouteMetadata(env), "", true, Ext.class); + }); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/json/Issue1087.java b/jooby/src/test/java-excluded/org/jooby/json/Issue1087.java new file mode 100644 index 00000000..f49d0ea5 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/json/Issue1087.java @@ -0,0 +1,95 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.json; + +import com.fasterxml.jackson.annotation.JsonView; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.easymock.EasyMock; +import static org.easymock.EasyMock.expect; +import org.jooby.MediaType; +import org.jooby.Renderer.Context; +import org.jooby.test.MockUnit; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; + +public class Issue1087 { + + public static class Item { + @JsonView(Views.Public.class) + public int id = 1; + + @JsonView(Views.Public.class) + public String itemName = "name"; + + @JsonView(Views.Internal.class) + public String ownerName = "owner"; + } + + public static class Views { + public static class Public { + } + + public static class Internal extends Public { + } + } + + @Test + public void rendererNoView() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + String json = "{\"id\":1,\"itemName\":\"name\",\"ownerName\":\"owner\"}"; + new MockUnit(Context.class, MediaType.class) + .expect(json(json)) + .run(unit -> { + new JacksonRenderer(mapper, MediaType.json) + .render(new Item(), unit.get(Context.class)); + }); + } + + @Test + public void rendererPublicView() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + String json = "{\"id\":1,\"itemName\":\"name\"}"; + new MockUnit(Context.class, MediaType.class) + .expect(json(json)) + .run(unit -> { + new JacksonRenderer(mapper, MediaType.json) + .render(new JacksonView<>(Views.Public.class, new Item()), unit.get(Context.class)); + }); + } + + @Test + public void rendererInternalView() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + String json = "{\"id\":1,\"itemName\":\"name\",\"ownerName\":\"owner\"}"; + new MockUnit(Context.class, MediaType.class) + .expect(json(json)) + .run(unit -> { + new JacksonRenderer(mapper, MediaType.json) + .render(new JacksonView<>(Views.Internal.class, new Item()), unit.get(Context.class)); + }); + } + + private MockUnit.Block json(String json) { + return unit-> { + Context ctx = unit.get(Context.class); + expect(ctx.accepts(MediaType.json)).andReturn(true); + expect(ctx.type(MediaType.json)).andReturn(ctx); + expect(ctx.length(json.length())).andReturn(ctx); + ctx.send(EasyMock.aryEq(json.getBytes(StandardCharsets.UTF_8))); + }; + } +} diff --git a/jooby/src/test/java-excluded/org/jooby/json/JacksonParserTest.java b/jooby/src/test/java-excluded/org/jooby/json/JacksonParserTest.java new file mode 100644 index 00000000..9cd758e0 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/json/JacksonParserTest.java @@ -0,0 +1,72 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.json; + +import static org.easymock.EasyMock.expect; + +import org.jooby.MediaType; +import org.jooby.Parser; +import org.jooby.Parser.Context; +import org.jooby.test.MockUnit; +import org.junit.Test; + +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.inject.TypeLiteral; + +public class JacksonParserTest { + + @Test + public void parseAny() throws Exception { + Object value = new Object(); + new MockUnit(ObjectMapper.class, Parser.Context.class, MediaType.class) + .expect(unit -> { + MediaType type = unit.get(MediaType.class); + expect(type.isAny()).andReturn(true); + + Context ctx = unit.get(Parser.Context.class); + expect(ctx.type()).andReturn(type); + expect(ctx.next()).andReturn(value); + }) + .run(unit -> { + new JacksonParser(unit.get(ObjectMapper.class), MediaType.json) + .parse(TypeLiteral.get(JacksonParserTest.class), unit.get(Parser.Context.class)); + }); + } + + @Test + public void parseSkip() throws Exception { + Object value = new Object(); + new MockUnit(ObjectMapper.class, Parser.Context.class, MediaType.class, TypeLiteral.class) + .expect(unit -> { + MediaType type = unit.get(MediaType.class); + expect(type.isAny()).andReturn(false); + + Context ctx = unit.get(Parser.Context.class); + expect(ctx.type()).andReturn(type); + expect(ctx.next()).andReturn(value); + + JavaType javaType = unit.mock(JavaType.class); + + ObjectMapper mapper = unit.get(ObjectMapper.class); + expect(mapper.constructType(null)).andReturn(javaType); + }) + .run(unit -> { + new JacksonParser(unit.get(ObjectMapper.class), MediaType.json) + .parse(unit.get(TypeLiteral.class), unit.get(Parser.Context.class)); + }); + } +} diff --git a/jooby/src/test/java-excluded/org/jooby/servlet/ServerInitializerTest.java b/jooby/src/test/java-excluded/org/jooby/servlet/ServerInitializerTest.java new file mode 100644 index 00000000..5463c60f --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/servlet/ServerInitializerTest.java @@ -0,0 +1,128 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.servlet; + +import static org.easymock.EasyMock.eq; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.isA; +import org.jooby.Jooby; +import org.jooby.test.MockUnit; +import org.junit.Test; + +import javax.servlet.ServletContext; +import javax.servlet.ServletContextEvent; + +public class ServerInitializerTest { + + @SuppressWarnings({"rawtypes", "unchecked" }) + @Test + public void contextInitialized() throws Exception { + new MockUnit(ServletContextEvent.class) + .expect(unit -> { + Class appClass = Jooby.class; + String appClassname = appClass.getName(); + + ClassLoader loader = unit.mock(ClassLoader.class); + expect(loader.loadClass(appClassname)).andReturn(appClass); + + ServletContext ctx = unit.mock(ServletContext.class); + expect(ctx.getInitParameter("application.class")).andReturn(appClassname); + expect(ctx.getClassLoader()).andReturn(loader); + expect(ctx.getContextPath()).andReturn("/"); + ctx.setAttribute(eq(Jooby.class.getName()), isA(Jooby.class)); + + ServletContextEvent sce = unit.get(ServletContextEvent.class); + expect(sce.getServletContext()).andReturn(ctx); + }) + .run(unit -> { + try { + ServerInitializer initializer = new ServerInitializer(); + initializer.contextInitialized(unit.get(ServletContextEvent.class)); + } catch (Throwable ex) { + ex.printStackTrace(); + } + }); + } + + @SuppressWarnings({"rawtypes" }) + @Test(expected = ClassNotFoundException.class) + public void contextInitializedShouldReThrowException() throws Exception { + new MockUnit(ServletContextEvent.class) + .expect( + unit -> { + Class appClass = Jooby.class; + String appClassname = appClass.getName(); + + ClassLoader loader = unit.mock(ClassLoader.class); + expect(loader.loadClass(appClassname)).andThrow( + new ClassNotFoundException("intentional err")); + + ServletContext ctx = unit.mock(ServletContext.class); + expect(ctx.getInitParameter("application.class")).andReturn(appClassname); + expect(ctx.getClassLoader()).andReturn(loader); + expect(ctx.getContextPath()).andReturn("/"); + ctx.setAttribute(eq(Jooby.class.getName()), isA(Jooby.class)); + + ServletContextEvent sce = unit.get(ServletContextEvent.class); + expect(sce.getServletContext()).andReturn(ctx); + }) + .run(unit -> { + ServerInitializer initializer = new ServerInitializer(); + initializer.contextInitialized(unit.get(ServletContextEvent.class)); + }); + } + + @SuppressWarnings({"rawtypes" }) + @Test + public void contextDestroyed() throws Exception { + new MockUnit(ServletContextEvent.class) + .expect(unit -> { + Class appClass = Jooby.class; + String appClassname = appClass.getName(); + + Jooby app = unit.mock(Jooby.class); + app.stop(); + + ServletContext ctx = unit.mock(ServletContext.class); + expect(ctx.getAttribute(appClassname)).andReturn(app); + + ServletContextEvent sce = unit.get(ServletContextEvent.class); + expect(sce.getServletContext()).andReturn(ctx); + }) + .run(unit -> { + new ServerInitializer().contextDestroyed(unit.get(ServletContextEvent.class)); + }); + } + + @SuppressWarnings({"rawtypes" }) + @Test + public void contextDestroyedShouldIgnoreMissingAttr() throws Exception { + new MockUnit(ServletContextEvent.class) + .expect(unit -> { + Class appClass = Jooby.class; + String appClassname = appClass.getName(); + + ServletContext ctx = unit.mock(ServletContext.class); + expect(ctx.getAttribute(appClassname)).andReturn(null); + + ServletContextEvent sce = unit.get(ServletContextEvent.class); + expect(sce.getServletContext()).andReturn(ctx); + }) + .run(unit -> { + new ServerInitializer().contextDestroyed(unit.get(ServletContextEvent.class)); + }); + } +} diff --git a/jooby/src/test/java-excluded/org/jooby/servlet/ServletHandlerTest.java b/jooby/src/test/java-excluded/org/jooby/servlet/ServletHandlerTest.java new file mode 100644 index 00000000..a1853afb --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/servlet/ServletHandlerTest.java @@ -0,0 +1,220 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.servlet; + +import static org.easymock.EasyMock.expect; + +import java.io.IOException; + +import javax.servlet.ServletConfig; +import javax.servlet.ServletContext; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.jooby.Jooby; +import org.jooby.spi.HttpHandler; +import org.jooby.test.MockUnit; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +import com.typesafe.config.Config; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({ServletHandler.class, ServletServletRequest.class, ServletServletResponse.class }) +public class ServletHandlerTest { + + MockUnit.Block init = unit -> { + HttpHandler dispatcher = unit.get(HttpHandler.class); + + Config config = unit.mock(Config.class); + expect(config.getString("application.tmpdir")).andReturn("target"); + + Jooby app = unit.mock(Jooby.class); + expect(app.require(HttpHandler.class)).andReturn(dispatcher); + expect(app.require(Config.class)).andReturn(config); + + ServletContext ctx = unit.mock(ServletContext.class); + expect(ctx.getAttribute(Jooby.class.getName())).andReturn(app); + + ServletConfig servletConfig = unit.get(ServletConfig.class); + + expect(servletConfig.getServletContext()).andReturn(ctx); + }; + + @Test + public void initMethodMustAskForDependencies() throws Exception { + new MockUnit(ServletConfig.class, HttpHandler.class) + .expect(init) + .run(unit -> + new ServletHandler() + .init(unit.get(ServletConfig.class)) + ); + } + + @Test + public void serviceShouldDispatchToHandler() throws Exception { + new MockUnit(ServletConfig.class, HttpHandler.class, HttpServletRequest.class, + HttpServletResponse.class) + .expect(init) + .expect( + unit -> { + HttpHandler dispatcher = unit.get(HttpHandler.class); + + ServletServletRequest req = unit.mockConstructor(ServletServletRequest.class, + new Class[]{HttpServletRequest.class, String.class }, + unit.get(HttpServletRequest.class), "target"); + ServletServletResponse rsp = unit.mockConstructor(ServletServletResponse.class, + new Class[]{HttpServletRequest.class, HttpServletResponse.class }, + unit.get(HttpServletRequest.class), unit.get(HttpServletResponse.class)); + + dispatcher.handle(req, rsp); + }) + .run(unit -> { + ServletHandler handler = new ServletHandler(); + handler.init(unit.get(ServletConfig.class)); + handler.service(unit.get(HttpServletRequest.class), unit.get(HttpServletResponse.class)); + }); + } + + @Test(expected = IllegalStateException.class) + public void serviceShouldCatchExceptionAndRethrowAsRuntime() throws Exception { + HttpHandler dispatcher = (request, response) -> { + throw new Exception("intentional err"); + }; + + new MockUnit(ServletConfig.class, HttpServletRequest.class, + HttpServletResponse.class) + .expect(unit -> { + Config config = unit.mock(Config.class); + expect(config.getString("application.tmpdir")).andReturn("target"); + + Jooby app = unit.mock(Jooby.class); + expect(app.require(HttpHandler.class)).andReturn(dispatcher); + expect(app.require(Config.class)).andReturn(config); + + ServletContext ctx = unit.mock(ServletContext.class); + expect(ctx.getAttribute(Jooby.class.getName())).andReturn(app); + + ServletConfig servletConfig = unit.get(ServletConfig.class); + + expect(servletConfig.getServletContext()).andReturn(ctx); + }) + .expect(unit -> { + unit.mockConstructor(ServletServletRequest.class, + new Class[]{HttpServletRequest.class, String.class }, + unit.get(HttpServletRequest.class), "target"); + unit.mockConstructor(ServletServletResponse.class, + new Class[]{HttpServletRequest.class, HttpServletResponse.class }, + unit.get(HttpServletRequest.class), unit.get(HttpServletResponse.class)); + }) + .expect(unit -> { + HttpServletRequest req = unit.get(HttpServletRequest.class); + expect(req.getRequestURI()).andReturn("/"); + }) + .run(unit -> { + ServletHandler handler = new ServletHandler(); + handler.init(unit.get(ServletConfig.class)); + handler.service(unit.get(HttpServletRequest.class), unit.get(HttpServletResponse.class)); + }); + } + + @Test(expected = IOException.class) + public void serviceShouldCatchIOExceptionAndRethrow() throws Exception { + HttpHandler dispatcher = (request, response) -> { + throw new IOException("intentional err"); + }; + + new MockUnit(ServletConfig.class, HttpServletRequest.class, + HttpServletResponse.class) + .expect(unit -> { + Config config = unit.mock(Config.class); + expect(config.getString("application.tmpdir")).andReturn("target"); + + Jooby app = unit.mock(Jooby.class); + expect(app.require(HttpHandler.class)).andReturn(dispatcher); + expect(app.require(Config.class)).andReturn(config); + + ServletContext ctx = unit.mock(ServletContext.class); + expect(ctx.getAttribute(Jooby.class.getName())).andReturn(app); + + ServletConfig servletConfig = unit.get(ServletConfig.class); + + expect(servletConfig.getServletContext()).andReturn(ctx); + }) + .expect(unit -> { + unit.mockConstructor(ServletServletRequest.class, + new Class[]{HttpServletRequest.class, String.class }, + unit.get(HttpServletRequest.class), "target"); + unit.mockConstructor(ServletServletResponse.class, + new Class[]{HttpServletRequest.class, HttpServletResponse.class }, + unit.get(HttpServletRequest.class), unit.get(HttpServletResponse.class)); + }) + .expect(unit -> { + HttpServletRequest req = unit.get(HttpServletRequest.class); + expect(req.getRequestURI()).andReturn("/"); + }) + .run(unit -> { + ServletHandler handler = new ServletHandler(); + handler.init(unit.get(ServletConfig.class)); + handler.service(unit.get(HttpServletRequest.class), unit.get(HttpServletResponse.class)); + }); + } + + @Test(expected = ServletException.class) + public void serviceShouldCatchServletExceptionAndRethrow() throws Exception { + HttpHandler dispatcher = (request, response) -> { + throw new ServletException("intentional err"); + }; + + new MockUnit(ServletConfig.class, HttpServletRequest.class, + HttpServletResponse.class) + .expect(unit -> { + Config config = unit.mock(Config.class); + expect(config.getString("application.tmpdir")).andReturn("target"); + + Jooby app = unit.mock(Jooby.class); + expect(app.require(HttpHandler.class)).andReturn(dispatcher); + expect(app.require(Config.class)).andReturn(config); + + ServletContext ctx = unit.mock(ServletContext.class); + expect(ctx.getAttribute(Jooby.class.getName())).andReturn(app); + + ServletConfig servletConfig = unit.get(ServletConfig.class); + + expect(servletConfig.getServletContext()).andReturn(ctx); + }) + .expect(unit -> { + unit.mockConstructor(ServletServletRequest.class, + new Class[]{HttpServletRequest.class, String.class }, + unit.get(HttpServletRequest.class), "target"); + unit.mockConstructor(ServletServletResponse.class, + new Class[]{HttpServletRequest.class, HttpServletResponse.class }, + unit.get(HttpServletRequest.class), unit.get(HttpServletResponse.class)); + }) + .expect(unit -> { + HttpServletRequest req = unit.get(HttpServletRequest.class); + expect(req.getRequestURI()).andReturn("/"); + }) + .run(unit -> { + ServletHandler handler = new ServletHandler(); + handler.init(unit.get(ServletConfig.class)); + handler.service(unit.get(HttpServletRequest.class), unit.get(HttpServletResponse.class)); + }); + } +} diff --git a/jooby/src/test/java-excluded/org/jooby/servlet/ServletServletRequestTest.java b/jooby/src/test/java-excluded/org/jooby/servlet/ServletServletRequestTest.java new file mode 100644 index 00000000..a31dc732 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/servlet/ServletServletRequestTest.java @@ -0,0 +1,288 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.servlet; + +import static org.easymock.EasyMock.expect; +import static org.junit.Assert.assertEquals; + +import java.io.IOException; +import java.util.Collections; +import java.util.UUID; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; + +import org.jooby.MediaType; +import org.jooby.test.MockUnit; +import org.junit.Test; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Iterators; +import com.google.common.collect.Lists; + +public class ServletServletRequestTest { + + @Test + public void defaults() throws IOException, Exception { + String tmpdir = System.getProperty("java.io.tmpdir"); + new MockUnit(HttpServletRequest.class) + .expect(unit -> { + HttpServletRequest req = unit.get(HttpServletRequest.class); + expect(req.getContentType()).andReturn("text/html"); + expect(req.getPathInfo()).andReturn("/"); + expect(req.getContextPath()).andReturn(""); + }) + .run(unit -> { + new ServletServletRequest(unit.get(HttpServletRequest.class), tmpdir); + }); + } + + @Test + public void nullPathInfo() throws IOException, Exception { + String tmpdir = System.getProperty("java.io.tmpdir"); + new MockUnit(HttpServletRequest.class) + .expect(unit -> { + HttpServletRequest req = unit.get(HttpServletRequest.class); + expect(req.getContentType()).andReturn("text/html"); + expect(req.getPathInfo()).andReturn(null); + expect(req.getContextPath()).andReturn(""); + }) + .run(unit -> { + String path = new ServletServletRequest(unit.get(HttpServletRequest.class), tmpdir) + .path(); + assertEquals("/", path); + }); + } + + @Test + public void withContextPath() throws IOException, Exception { + String tmpdir = System.getProperty("java.io.tmpdir"); + new MockUnit(HttpServletRequest.class) + .expect(unit -> { + HttpServletRequest req = unit.get(HttpServletRequest.class); + expect(req.getContentType()).andReturn("text/html"); + expect(req.getPathInfo()).andReturn(null); + expect(req.getContextPath()).andReturn("/foo"); + }) + .run(unit -> { + String path = new ServletServletRequest(unit.get(HttpServletRequest.class), tmpdir) + .path(); + assertEquals("/foo/", path); + }); + } + + @Test + public void defaultsNullCT() throws IOException, Exception { + String tmpdir = System.getProperty("java.io.tmpdir"); + new MockUnit(HttpServletRequest.class) + .expect(unit -> { + HttpServletRequest req = unit.get(HttpServletRequest.class); + expect(req.getContentType()).andReturn(null); + expect(req.getPathInfo()).andReturn("/"); + expect(req.getContextPath()).andReturn(""); + }) + .run(unit -> { + new ServletServletRequest(unit.get(HttpServletRequest.class), tmpdir); + }); + + } + + @Test + public void multipartDefaults() throws IOException, Exception { + String tmpdir = System.getProperty("java.io.tmpdir"); + new MockUnit(HttpServletRequest.class) + .expect(unit -> { + HttpServletRequest req = unit.get(HttpServletRequest.class); + expect(req.getContentType()).andReturn(MediaType.multipart.name()); + expect(req.getPathInfo()).andReturn("/"); + expect(req.getContextPath()).andReturn(""); + }) + .run(unit -> { + new ServletServletRequest(unit.get(HttpServletRequest.class), tmpdir); + }); + } + + @Test + public void reqMethod() throws IOException, Exception { + String tmpdir = System.getProperty("java.io.tmpdir"); + new MockUnit(HttpServletRequest.class) + .expect(unit -> { + HttpServletRequest req = unit.get(HttpServletRequest.class); + expect(req.getContentType()).andReturn("text/html"); + expect(req.getPathInfo()).andReturn("/"); + expect(req.getMethod()).andReturn("GET"); + expect(req.getContextPath()).andReturn(""); + }) + .run(unit -> { + assertEquals("GET", new ServletServletRequest(unit.get(HttpServletRequest.class), + tmpdir).method()); + }); + + } + + @Test + public void path() throws IOException, Exception { + String tmpdir = System.getProperty("java.io.tmpdir"); + new MockUnit(HttpServletRequest.class) + .expect(unit -> { + HttpServletRequest req = unit.get(HttpServletRequest.class); + expect(req.getContentType()).andReturn("text/html"); + expect(req.getPathInfo()).andReturn("/spaces%20in%20it"); + expect(req.getContextPath()).andReturn(""); + }) + .run(unit -> { + assertEquals("/spaces in it", + new ServletServletRequest(unit.get(HttpServletRequest.class), tmpdir).path()); + }); + + } + + @Test + public void paramNames() throws IOException, Exception { + String tmpdir = System.getProperty("java.io.tmpdir"); + new MockUnit(HttpServletRequest.class) + .expect(unit -> { + HttpServletRequest req = unit.get(HttpServletRequest.class); + expect(req.getContentType()).andReturn("text/html"); + expect(req.getPathInfo()).andReturn("/"); + expect(req.getParameterNames()).andReturn( + Iterators.asEnumeration(Lists.newArrayList("p1", "p2").iterator())); + expect(req.getContextPath()).andReturn(""); + }) + .run(unit -> { + assertEquals(Lists.newArrayList("p1", "p2"), + new ServletServletRequest(unit.get(HttpServletRequest.class), tmpdir) + .paramNames()); + }); + + } + + @Test + public void params() throws IOException, Exception { + String tmpdir = System.getProperty("java.io.tmpdir"); + new MockUnit(HttpServletRequest.class) + .expect(unit -> { + HttpServletRequest req = unit.get(HttpServletRequest.class); + expect(req.getContentType()).andReturn("text/html"); + expect(req.getPathInfo()).andReturn("/"); + expect(req.getParameterValues("x")).andReturn(new String[]{"a", "b" }); + expect(req.getContextPath()).andReturn(""); + }) + .run(unit -> { + assertEquals(Lists.newArrayList("a", "b"), + new ServletServletRequest(unit.get(HttpServletRequest.class), tmpdir) + .params("x")); + }); + + } + + @Test + public void noparams() throws IOException, Exception { + String tmpdir = System.getProperty("java.io.tmpdir"); + new MockUnit(HttpServletRequest.class) + .expect(unit -> { + HttpServletRequest req = unit.get(HttpServletRequest.class); + expect(req.getContentType()).andReturn("text/html"); + expect(req.getPathInfo()).andReturn("/"); + expect(req.getParameterValues("x")).andReturn(null); + expect(req.getContextPath()).andReturn(""); + }) + .run(unit -> { + assertEquals(Lists.newArrayList(), + new ServletServletRequest(unit.get(HttpServletRequest.class), tmpdir) + .params("x")); + }); + + } + + @Test + public void attributes() throws Exception { + String tmpdir = System.getProperty("java.io.tmpdir"); + final UUID serverAttribute = UUID.randomUUID(); + new MockUnit(HttpServletRequest.class) + .expect(unit -> { + HttpServletRequest req = unit.get(HttpServletRequest.class); + expect(req.getContentType()).andReturn("text/html"); + expect(req.getPathInfo()).andReturn("/"); + expect(req.getContextPath()).andReturn(""); + expect(req.getAttributeNames()).andReturn( + Collections.enumeration(Collections.singletonList("server.attribute"))); + expect(req.getAttribute("server.attribute")).andReturn(serverAttribute); + }) + .run(unit -> { + assertEquals(ImmutableMap.of("server.attribute", serverAttribute), + new ServletServletRequest(unit.get(HttpServletRequest.class), tmpdir) + .attributes()); + }); + + } + + @Test + public void emptyAttributes() throws Exception { + String tmpdir = System.getProperty("java.io.tmpdir"); + new MockUnit(HttpServletRequest.class) + .expect(unit -> { + HttpServletRequest req = unit.get(HttpServletRequest.class); + expect(req.getContentType()).andReturn("text/html"); + expect(req.getPathInfo()).andReturn("/"); + expect(req.getContextPath()).andReturn(""); + expect(req.getAttributeNames()).andReturn(Collections.emptyEnumeration()); + }) + .run(unit -> { + assertEquals(Collections.emptyMap(), + new ServletServletRequest(unit.get(HttpServletRequest.class), tmpdir) + .attributes()); + }); + + } + + @Test(expected = IOException.class) + public void filesFailure() throws IOException, Exception { + String tmpdir = System.getProperty("java.io.tmpdir"); + new MockUnit(HttpServletRequest.class) + .expect(unit -> { + HttpServletRequest req = unit.get(HttpServletRequest.class); + expect(req.getContentType()).andReturn(MediaType.multipart.name()); + expect(req.getPathInfo()).andReturn("/"); + expect(req.getParts()).andThrow(new ServletException("intentional err")); + expect(req.getContextPath()).andReturn(""); + }) + .run(unit -> { + new ServletServletRequest(unit.get(HttpServletRequest.class), tmpdir) + .files("x"); + }); + + } + + @Test(expected = UnsupportedOperationException.class) + public void noupgrade() throws IOException, Exception { + String tmpdir = System.getProperty("java.io.tmpdir"); + new MockUnit(HttpServletRequest.class) + .expect(unit -> { + HttpServletRequest req = unit.get(HttpServletRequest.class); + expect(req.getContentType()).andReturn(MediaType.multipart.name()); + expect(req.getPathInfo()).andReturn("/"); + expect(req.getContextPath()).andReturn(""); + }) + .run(unit -> { + assertEquals(Lists.newArrayList(), + new ServletServletRequest(unit.get(HttpServletRequest.class), tmpdir) + .upgrade(ServletServletRequest.class)); + }); + + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/servlet/ServletServletResponseTest.java b/jooby/src/test/java-excluded/org/jooby/servlet/ServletServletResponseTest.java new file mode 100644 index 00000000..86d37089 --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/servlet/ServletServletResponseTest.java @@ -0,0 +1,243 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.servlet; + +import com.google.common.io.ByteStreams; +import static org.easymock.EasyMock.expect; +import org.jooby.funzy.Throwing; +import org.jooby.test.MockUnit; +import static org.junit.Assert.assertEquals; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.nio.channels.WritableByteChannel; +import java.util.Arrays; +import java.util.Collections; +import java.util.Optional; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({ServletServletResponse.class, Channels.class, ByteStreams.class, + FileChannel.class, Throwing.class, Throwing.Runnable.class}) +public class ServletServletResponseTest { + + @Test + public void defaults() throws Exception { + new MockUnit(HttpServletRequest.class, HttpServletResponse.class) + .run(unit -> { + new ServletServletResponse(unit.get(HttpServletRequest.class), + unit.get(HttpServletResponse.class)); + }); + } + + @Test + public void close() throws Exception { + new MockUnit(HttpServletRequest.class, HttpServletResponse.class) + .run(unit -> { + new ServletServletResponse(unit.get(HttpServletRequest.class), + unit.get(HttpServletResponse.class)).close(); + }); + } + + @Test + public void headers() throws Exception { + new MockUnit(HttpServletRequest.class, HttpServletResponse.class) + .expect(unit -> { + HttpServletResponse rsp = unit.get(HttpServletResponse.class); + expect(rsp.getHeaders("h")).andReturn(Arrays.asList("v")); + }) + .run(unit -> { + assertEquals(Arrays.asList("v"), + new ServletServletResponse(unit.get(HttpServletRequest.class), + unit.get(HttpServletResponse.class)).headers("h")); + }); + } + + @Test + public void emptyHeaders() throws Exception { + new MockUnit(HttpServletRequest.class, HttpServletResponse.class) + .expect(unit -> { + HttpServletResponse rsp = unit.get(HttpServletResponse.class); + expect(rsp.getHeaders("h")).andReturn(Collections.emptyList()); + }) + .run(unit -> { + assertEquals(Collections.emptyList(), + new ServletServletResponse(unit.get(HttpServletRequest.class), + unit.get(HttpServletResponse.class)).headers("h")); + }); + } + + @Test + public void noHeaders() throws Exception { + new MockUnit(HttpServletRequest.class, HttpServletResponse.class) + .expect(unit -> { + HttpServletResponse rsp = unit.get(HttpServletResponse.class); + expect(rsp.getHeaders("h")).andReturn(null); + }) + .run(unit -> { + assertEquals(Collections.emptyList(), + new ServletServletResponse(unit.get(HttpServletRequest.class), + unit.get(HttpServletResponse.class)).headers("h")); + }); + } + + @Test + public void header() throws Exception { + new MockUnit(HttpServletRequest.class, HttpServletResponse.class) + .expect(unit -> { + HttpServletResponse rsp = unit.get(HttpServletResponse.class); + expect(rsp.getHeader("h")).andReturn("v"); + }) + .run(unit -> { + assertEquals(Optional.of("v"), + new ServletServletResponse(unit.get(HttpServletRequest.class), + unit.get(HttpServletResponse.class)).header("h")); + }); + } + + @Test + public void emptyHeader() throws Exception { + new MockUnit(HttpServletRequest.class, HttpServletResponse.class) + .expect(unit -> { + HttpServletResponse rsp = unit.get(HttpServletResponse.class); + expect(rsp.getHeader("h")).andReturn(""); + }) + .run(unit -> { + assertEquals(Optional.empty(), + new ServletServletResponse(unit.get(HttpServletRequest.class), + unit.get(HttpServletResponse.class)).header("h")); + }); + } + + @Test + public void noHeader() throws Exception { + new MockUnit(HttpServletRequest.class, HttpServletResponse.class) + .expect(unit -> { + HttpServletResponse rsp = unit.get(HttpServletResponse.class); + expect(rsp.getHeader("h")).andReturn(null); + }) + .run(unit -> { + assertEquals(Optional.empty(), + new ServletServletResponse(unit.get(HttpServletRequest.class), + unit.get(HttpServletResponse.class)).header("h")); + }); + } + + @Test + public void sendBytes() throws Exception { + byte[] bytes = "bytes".getBytes(); + new MockUnit(HttpServletRequest.class, HttpServletResponse.class, ServletOutputStream.class) + .expect(unit -> { + ServletOutputStream output = unit.get(ServletOutputStream.class); + output.write(bytes); + output.close(); + + HttpServletResponse rsp = unit.get(HttpServletResponse.class); + rsp.setHeader("Transfer-Encoding", null); + expect(rsp.getOutputStream()).andReturn(output); + }) + .run(unit -> { + new ServletServletResponse(unit.get(HttpServletRequest.class), + unit.get(HttpServletResponse.class)).send(bytes); + }); + } + + @Test + public void sendByteBuffer() throws Exception { + byte[] bytes = "bytes".getBytes(); + ByteBuffer buffer = ByteBuffer.wrap(bytes); + new MockUnit(HttpServletRequest.class, HttpServletResponse.class, ServletOutputStream.class) + .expect(unit -> { + ServletOutputStream output = unit.get(ServletOutputStream.class); + + WritableByteChannel channel = unit.mock(WritableByteChannel.class); + expect(channel.write(buffer)).andReturn(bytes.length); + channel.close(); + + unit.mockStatic(Channels.class); + expect(Channels.newChannel(output)).andReturn(channel); + + HttpServletResponse rsp = unit.get(HttpServletResponse.class); + expect(rsp.getOutputStream()).andReturn(output); + }) + .run(unit -> { + new ServletServletResponse(unit.get(HttpServletRequest.class), + unit.get(HttpServletResponse.class)).send(buffer); + }); + } + + @Test + public void sendFileChannel() throws Exception { + new MockUnit(HttpServletRequest.class, HttpServletResponse.class, ServletOutputStream.class) + .expect(unit -> { + FileChannel channel = unit.partialMock(FileChannel.class, "transferTo", "close"); + unit.registerMock(FileChannel.class, channel); + }) + .expect(unit -> { + FileChannel fchannel = unit.get(FileChannel.class); + expect(fchannel.size()).andReturn(10L); + ServletOutputStream output = unit.get(ServletOutputStream.class); + + WritableByteChannel channel = unit.mock(WritableByteChannel.class); + + unit.mockStatic(Channels.class); + expect(Channels.newChannel(output)).andReturn(channel); + + expect(fchannel.transferTo(0L, 10L, channel)).andReturn(1L); + fchannel.close(); + channel.close(); + + HttpServletResponse rsp = unit.get(HttpServletResponse.class); + expect(rsp.getOutputStream()).andReturn(output); + }) + .run(unit -> { + new ServletServletResponse(unit.get(HttpServletRequest.class), + unit.get(HttpServletResponse.class)).send(unit.get(FileChannel.class)); + }); + } + + @Test + public void sendInputStream() throws Exception { + new MockUnit(HttpServletRequest.class, HttpServletResponse.class, InputStream.class, + ServletOutputStream.class) + .expect(unit -> { + InputStream in = unit.get(InputStream.class); + ServletOutputStream output = unit.get(ServletOutputStream.class); + + unit.mockStatic(ByteStreams.class); + expect(ByteStreams.copy(in, output)).andReturn(0L); + + output.close(); + in.close(); + + HttpServletResponse rsp = unit.get(HttpServletResponse.class); + expect(rsp.getOutputStream()).andReturn(output); + }) + .run(unit -> { + new ServletServletResponse(unit.get(HttpServletRequest.class), + unit.get(HttpServletResponse.class)).send(unit.get(InputStream.class)); + }); + } + +} diff --git a/jooby/src/test/java-excluded/org/jooby/test/JoobySuite.java b/jooby/src/test/java-excluded/org/jooby/test/JoobySuite.java new file mode 100644 index 00000000..df74451a --- /dev/null +++ b/jooby/src/test/java-excluded/org/jooby/test/JoobySuite.java @@ -0,0 +1,77 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.runner.Runner; +import org.junit.runners.Suite; +import org.junit.runners.model.InitializationError; + +import com.google.common.base.Predicate; +import com.google.common.base.Predicates; + +/** + * JUnit suite for Jooby. Internal use only. + * + * @author edgar + */ +public class JoobySuite extends Suite { + + private List runners; + + static { + System.setProperty("io.netty.leakDetectionLevel", "advanced"); + } + + public JoobySuite(final Class klass) throws InitializationError { + super(klass, Collections.emptyList()); + + runners = runners(klass); + } + + @SuppressWarnings("rawtypes") + private List runners(final Class klass) throws InitializationError { + List runners = new ArrayList<>(); + Predicate filter = Predicates.alwaysTrue(); + OnServer onserver = klass.getAnnotation(OnServer.class); + if (onserver != null) { + List> server = Arrays.asList(onserver.value()); + filter = server::contains; + } + String[] servers = {"org.jooby.undertow.Undertow", "org.jooby.jetty.Jetty", + "org.jooby.netty.Netty" }; + for (String server : servers) { + try { + Class serverClass = getClass().getClassLoader().loadClass(server); + if (filter.apply(serverClass)) { + runners.add(new JoobyRunner(getTestClass().getJavaClass(), serverClass)); + } + } catch (ClassNotFoundException ex) { + // do nothing + } + } + return runners; + } + + @Override + protected List getChildren() { + return runners; + } +} diff --git a/jooby/src/test/java/issues/RouteSourceLocation.java b/jooby/src/test/java/issues/RouteSourceLocation.java new file mode 100644 index 00000000..17b92deb --- /dev/null +++ b/jooby/src/test/java/issues/RouteSourceLocation.java @@ -0,0 +1,26 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 issues; + +import org.jooby.Route; + +import java.util.function.Function; + +public class RouteSourceLocation { + public Function route() { + return path -> new Route.Definition("*", path, () -> null); + } +} diff --git a/jooby/src/test/java/jetty/H2Jetty.java b/jooby/src/test/java/jetty/H2Jetty.java new file mode 100644 index 00000000..7335dbd2 --- /dev/null +++ b/jooby/src/test/java/jetty/H2Jetty.java @@ -0,0 +1,61 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 jetty; + +import com.google.common.io.ByteStreams; +import org.jooby.Jooby; +import org.jooby.MediaType; +import org.jooby.Results; +import org.jooby.funzy.Throwing; +import org.jooby.funzy.Try; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.charset.StandardCharsets; + +public class H2Jetty extends Jooby { + + /** The logging system. */ + private final Logger log = LoggerFactory.getLogger(getClass()); + + Throwing.Function html = Throwing.throwingFunction(path -> { + return Try.with(() -> getClass().getResourceAsStream(path)) + .apply(in -> { + byte[] bytes = ByteStreams.toByteArray(in); + return new String(bytes, StandardCharsets.UTF_8); + }).get(); + }).memoized(); + + { + http2(); + securePort(8443); + + use("*", (req, rsp) -> { + log.info("************ {} ************", req.path()); + }); + + assets("/assets/**"); + get("/", req -> { + req.push("/assets/index.js"); + return Results.ok(html.apply("/index.html")).type(MediaType.html); + }); + + } + + public static void main(final String[] args) throws Throwable { + run(H2Jetty::new, args); + } +} diff --git a/jooby/src/test/java/org/jooby/ArgsConfTest.java b/jooby/src/test/java/org/jooby/ArgsConfTest.java new file mode 100644 index 00000000..3f7620b4 --- /dev/null +++ b/jooby/src/test/java/org/jooby/ArgsConfTest.java @@ -0,0 +1,53 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; + +public class ArgsConfTest { + + @Test + public void keypair() { + Config args = Jooby.args(new String[]{"p.foo=bar", "p.bar=foo" }); + assertEquals("bar", args.getConfig("p").getString("foo")); + assertEquals("foo", args.getConfig("p").getString("bar")); + } + + @Test + public void env() { + Config args = Jooby.args(new String[]{"foo" }); + assertEquals("foo", args.getConfig("application").getString("env")); + } + + @Test + public void defnamespace() { + Config args = Jooby.args(new String[]{"port=8080" }); + assertEquals(8080, args.getConfig("application").getInt("port")); + assertEquals(8080, args.getInt("port")); + } + + @Test + public void noargs() { + assertEquals(ConfigFactory.empty(), Jooby.args(null)); + assertEquals(ConfigFactory.empty(), Jooby.args(new String[0])); + } + +} diff --git a/jooby/src/test/java/org/jooby/CookieCodecTest.java b/jooby/src/test/java/org/jooby/CookieCodecTest.java new file mode 100644 index 00000000..26a4b90f --- /dev/null +++ b/jooby/src/test/java/org/jooby/CookieCodecTest.java @@ -0,0 +1,46 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +import com.google.common.collect.ImmutableMap; + +public class CookieCodecTest { + + @Test + public void encode() { + assertEquals("success=OK", Cookie.URL_ENCODER.apply(ImmutableMap.of("success", "OK"))); + assertEquals("success=semi%3Bcolon", + Cookie.URL_ENCODER.apply(ImmutableMap.of("success", "semi;colon"))); + assertEquals("success=eq%3Duals", + Cookie.URL_ENCODER.apply(ImmutableMap.of("success", "eq=uals"))); + + assertEquals("success=OK&error=404", + Cookie.URL_ENCODER.apply(ImmutableMap.of("success", "OK", "error", "404"))); + } + + @Test + public void decode() { + assertEquals(ImmutableMap.of("success", "OK"), Cookie.URL_DECODER.apply("success=OK")); + assertEquals(ImmutableMap.of("success", "OK", "foo", "bar"), + Cookie.URL_DECODER.apply("success=OK&foo=bar")); + assertEquals(ImmutableMap.of("semicolon", "semi;colon"), + Cookie.URL_DECODER.apply("semicolon=semi%3Bcolon")); + } +} diff --git a/jooby/src/test/java/org/jooby/CookieDefinitionTest.java b/jooby/src/test/java/org/jooby/CookieDefinitionTest.java new file mode 100644 index 00000000..74046893 --- /dev/null +++ b/jooby/src/test/java/org/jooby/CookieDefinitionTest.java @@ -0,0 +1,131 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static org.junit.Assert.assertEquals; + +import java.util.Optional; + +import org.jooby.Cookie.Definition; +import org.junit.Test; + +public class CookieDefinitionTest { + + @Test + public void newCookieDefDefaults() { + Definition def = new Cookie.Definition(); + assertEquals(Optional.empty(), def.name()); + assertEquals(Optional.empty(), def.comment()); + assertEquals(Optional.empty(), def.domain()); + assertEquals(Optional.empty(), def.httpOnly()); + assertEquals(Optional.empty(), def.maxAge()); + assertEquals(Optional.empty(), def.path()); + assertEquals(Optional.empty(), def.secure()); + assertEquals(Optional.empty(), def.value()); + } + + @Test + public void newNamedCookieDef() { + Definition def = new Cookie.Definition("name", ""); + assertEquals("name", def.name().get()); + assertEquals("name", def.toCookie().name()); + assertEquals(Optional.empty(), def.comment()); + assertEquals(Optional.empty(), def.domain()); + assertEquals(Optional.empty(), def.httpOnly()); + assertEquals(Optional.empty(), def.maxAge()); + assertEquals(Optional.empty(), def.path()); + assertEquals(Optional.empty(), def.secure()); + assertEquals(Optional.empty(), def.value()); + } + + @Test(expected = NullPointerException.class) + public void newNullNameCookieDef() { + new Cookie.Definition(null, "x"); + } + + @Test + public void newValuedCookieDef() { + Definition def = new Cookie.Definition("name", "v"); + assertEquals("name", def.name().get()); + assertEquals("v", def.value().get()); + assertEquals("v", def.toCookie().value().get()); + assertEquals(Optional.empty(), def.comment()); + assertEquals(Optional.empty(), def.domain()); + assertEquals(Optional.empty(), def.httpOnly()); + assertEquals(Optional.empty(), def.maxAge()); + assertEquals(Optional.empty(), def.path()); + assertEquals(Optional.empty(), def.secure()); + } + + @Test(expected = NullPointerException.class) + public void newNullValueCookieDef() { + new Cookie.Definition("name", null); + } + + @Test + public void cookieWithComment() { + Definition def = new Cookie.Definition("name", "c"); + assertEquals(Optional.empty(), def.comment()); + assertEquals("a comment", def.comment("a comment").comment().get()); + assertEquals("a comment", def.toCookie().comment().get()); + } + + @Test + public void cookieWithDomain() { + Definition def = new Cookie.Definition("name", ""); + assertEquals(Optional.empty(), def.domain()); + assertEquals("jooby.org", def.domain("jooby.org").domain().get()); + assertEquals("jooby.org", def.toCookie().domain().get()); + } + + @Test + public void cookieHttpOnly() { + Definition def = new Cookie.Definition("name", "x"); + assertEquals(Optional.empty(), def.httpOnly()); + assertEquals(true, def.httpOnly(true).httpOnly().get()); + assertEquals(true, def.toCookie().httpOnly()); + } + + @Test + public void cookieMaxAge() { + Definition def = new Cookie.Definition("name", "s"); + assertEquals(Optional.empty(), def.maxAge()); + assertEquals(123L, (long) def.maxAge(123).maxAge().get()); + assertEquals(123, def.toCookie().maxAge()); + } + + @Test + public void cookiePath() { + Definition def = new Cookie.Definition("name", "x"); + assertEquals(Optional.empty(), def.path()); + assertEquals("/", def.path("/").path().get()); + assertEquals("/", def.toCookie().path().get()); + } + + @Test + public void cookieSecure() { + Definition def = new Cookie.Definition("name", "q"); + assertEquals(Optional.empty(), def.secure()); + assertEquals(true, def.secure(true).secure().get()); + assertEquals(true, def.toCookie().secure()); + } + + @Test + public void toStr() { + Definition def = new Cookie.Definition("name", "q"); + assertEquals("name=q;Version=1", def.toString()); + } +} diff --git a/jooby/src/test/java/org/jooby/CorsTest.java b/jooby/src/test/java/org/jooby/CorsTest.java new file mode 100644 index 00000000..2c5fe28e --- /dev/null +++ b/jooby/src/test/java/org/jooby/CorsTest.java @@ -0,0 +1,148 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static com.typesafe.config.ConfigValueFactory.fromAnyRef; +import static java.util.Arrays.asList; +import static org.junit.Assert.assertEquals; + +import java.util.Arrays; +import java.util.function.Consumer; + +import org.jooby.handlers.Cors; +import org.junit.Test; + +import com.google.common.collect.Lists; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; + +public class CorsTest { + + @Test + public void defaults() { + cors(cors -> { + assertEquals(true, cors.anyOrigin()); + assertEquals(true, cors.enabled()); + assertEquals(Arrays.asList("*"), cors.origin()); + assertEquals(true, cors.credentials()); + + assertEquals(true, cors.allowMethod("get")); + assertEquals(true, cors.allowMethod("post")); + assertEquals(Arrays.asList("GET", "POST"), cors.allowedMethods()); + + assertEquals(true, cors.allowHeader("X-Requested-With")); + assertEquals(true, cors.allowHeader("Content-Type")); + assertEquals(true, cors.allowHeader("Accept")); + assertEquals(true, cors.allowHeader("Origin")); + assertEquals(true, cors.allowHeaders("X-Requested-With", "Content-Type", "Accept", "Origin")); + assertEquals(Arrays.asList("X-Requested-With", "Content-Type", "Accept", "Origin"), + cors.allowedHeaders()); + + assertEquals(1800, cors.maxAge()); + + assertEquals(Arrays.asList(), cors.exposedHeaders()); + + assertEquals(false, cors.withoutCreds().credentials()); + + assertEquals(false, cors.disabled().enabled()); + }); + } + + @Test + public void origin() { + cors(baseconf().withValue("origin", fromAnyRef("*")), cors -> { + assertEquals(true, cors.anyOrigin()); + assertEquals(true, cors.allowOrigin("http://foo.com")); + }); + + cors(baseconf().withValue("origin", fromAnyRef("http://*.com")), cors -> { + assertEquals(false, cors.anyOrigin()); + assertEquals(true, cors.allowOrigin("http://foo.com")); + assertEquals(true, cors.allowOrigin("http://bar.com")); + }); + + cors(baseconf().withValue("origin", fromAnyRef("http://foo.com")), cors -> { + assertEquals(false, cors.anyOrigin()); + assertEquals(true, cors.allowOrigin("http://foo.com")); + assertEquals(false, cors.allowOrigin("http://bar.com")); + }); + } + + @Test + public void allowedMethods() { + cors(baseconf().withValue("allowedMethods", fromAnyRef("GET")), cors -> { + assertEquals(true, cors.allowMethod("GET")); + assertEquals(true, cors.allowMethod("get")); + assertEquals(false, cors.allowMethod("POST")); + }); + + cors(baseconf().withValue("allowedMethods", fromAnyRef(asList("get", "post"))), cors -> { + assertEquals(true, cors.allowMethod("GET")); + assertEquals(true, cors.allowMethod("get")); + assertEquals(true, cors.allowMethod("POST")); + }); + } + + @Test + public void requestHeaders() { + cors(baseconf().withValue("allowedHeaders", fromAnyRef("*")), cors -> { + assertEquals(true, cors.anyHeader()); + assertEquals(true, cors.allowHeader("Custom-Header")); + }); + + cors(baseconf().withValue("allowedHeaders", fromAnyRef(asList("X-Requested-With", "*"))), + cors -> { + assertEquals(true, cors.allowHeader("X-Requested-With")); + assertEquals(true, cors.anyHeader()); + }); + + cors( + baseconf().withValue("allowedHeaders", + fromAnyRef(asList("X-Requested-With", "Content-Type", "Accept", "Origin"))), + cors -> { + assertEquals(false, cors.anyHeader()); + assertEquals(true, cors.allowHeader("X-Requested-With")); + assertEquals(true, cors.allowHeader("Content-Type")); + assertEquals(true, cors.allowHeader("Accept")); + assertEquals(true, cors.allowHeader("Origin")); + assertEquals(true, + cors.allowHeaders(asList("X-Requested-With", "Content-Type", "Accept", "Origin"))); + assertEquals(false, + cors.allowHeaders(asList("X-Requested-With", "Content-Type", "Custom"))); + }); + } + + private void cors(final Config conf, final Consumer callback) { + callback.accept(new Cors(conf)); + } + + private void cors(final Consumer callback) { + callback.accept(new Cors()); + } + + private Config baseconf() { + Config config = ConfigFactory.empty() + .withValue("enabled", fromAnyRef(true)) + .withValue("credentials", fromAnyRef(true)) + .withValue("maxAge", fromAnyRef("30m")) + .withValue("origin", fromAnyRef(Lists.newArrayList())) + .withValue("exposedHeaders", fromAnyRef(Lists.newArrayList("X"))) + .withValue("allowedMethods", fromAnyRef(Lists.newArrayList())) + .withValue("allowedHeaders", fromAnyRef(Lists.newArrayList())); + return config; + } + +} diff --git a/jooby/src/test/java/org/jooby/EnvTest.java b/jooby/src/test/java/org/jooby/EnvTest.java new file mode 100644 index 00000000..5a59a127 --- /dev/null +++ b/jooby/src/test/java/org/jooby/EnvTest.java @@ -0,0 +1,356 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import com.google.common.collect.ImmutableMap; +import com.google.inject.Key; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; +import com.typesafe.config.ConfigValueFactory; +import org.jooby.funzy.Throwing; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.fail; +import org.junit.Test; + +import javax.annotation.Nullable; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.function.Function; + +public class EnvTest { + + @Test + public void resolveOneAtMiddle() { + Config config = ConfigFactory.empty() + .withValue("contextPath", ConfigValueFactory.fromAnyRef("/myapp")); + + Env env = Env.DEFAULT.build(config); + assertEquals("function ($) {$.ajax(\"/myapp/api\")}", + env.resolve("function ($) {$.ajax(\"${contextPath}/api\")}")); + } + + @Test + public void resolveSingle() { + Config config = ConfigFactory.empty() + .withValue("var", ConfigValueFactory.fromAnyRef("foo.bar")); + + Env env = Env.DEFAULT.build(config); + assertEquals("foo.bar", env.resolve("${var}")); + } + + @Test + public void altplaceholder() { + + Env env = Env.DEFAULT.build(ConfigFactory.empty() + .withValue("var", ConfigValueFactory.fromAnyRef("foo.bar"))); + + assertEquals("foo.bar", env.resolver().delimiters("{{", "}}").resolve("{{var}}")); + assertEquals("foo.bar", env.resolver().delimiters("<%", "%>").resolve("<%var%>")); + } + + @Test + public void resolveHead() { + Config config = ConfigFactory.empty() + .withValue("var", ConfigValueFactory.fromAnyRef("foo.bar")); + + Env env = Env.DEFAULT.build(config); + assertEquals("foo.bar-", env.resolve("${var}-")); + } + + @Test + public void resolveTail() { + Config config = ConfigFactory.empty() + .withValue("var", ConfigValueFactory.fromAnyRef("foo.bar")); + + Env env = Env.DEFAULT.build(config); + assertEquals("-foo.bar", env.resolve("-${var}")); + } + + @Test + public void resolveMore() { + Config config = ConfigFactory.empty() + .withValue("var", ConfigValueFactory.fromAnyRef("foo.bar")); + + Env env = Env.DEFAULT.build(config); + assertEquals("foo.bar - foo.bar", env.resolve("${var} - ${var}")); + } + + @Test + public void resolveMap() { + Config config = ConfigFactory.empty(); + + Env env = Env.DEFAULT.build(config); + assertEquals("foo.bar - foo.bar", env.resolver().source(ImmutableMap.of("var", "foo.bar")) + .resolve("${var} - ${var}")); + } + + @Test + public void resolveMapIgnore() { + Config config = ConfigFactory.empty(); + + Env env = Env.DEFAULT.build(config); + assertEquals("${varx} - ${varx}", + env.resolver().ignoreMissing().source(ImmutableMap.of("var", "foo.bar")) + .resolve("${varx} - ${varx}")); + } + + @Test + public void resolveIgnoreMissing() { + Config config = ConfigFactory.empty(); + + Env env = Env.DEFAULT.build(config); + assertEquals("${var} - ${var}", env.resolver().ignoreMissing().resolve("${var} - ${var}")); + + assertEquals(" - ${foo.var} -", env.resolver().ignoreMissing().resolve(" - ${foo.var} -")); + } + + @Test + public void novars() { + Config config = ConfigFactory.empty() + .withValue("var", ConfigValueFactory.fromAnyRef("foo.bar")); + + Env env = Env.DEFAULT.build(config); + assertEquals("var", env.resolve("var")); + } + + @Test + public void globalObject() { + Config config = ConfigFactory.empty() + .withValue("var", ConfigValueFactory.fromAnyRef("foo.bar")); + + Env env = Env.DEFAULT.build(config); + Object value = new Object(); + env.set(Object.class, value); + assertEquals(value, env.get(Object.class).get()); + } + + @Test + public void serviceKey() { + Config config = ConfigFactory.empty() + .withValue("var", ConfigValueFactory.fromAnyRef("foo.bar")); + + Env env = Env.DEFAULT.build(config); + assertNotNull(env.serviceKey()); + + assertNotNull(new Env() { + @Override public Env set(Key key, T value) { + throw new UnsupportedOperationException(); + } + + @Override public Optional get(Key key) { + throw new UnsupportedOperationException(); + } + + @Nullable @Override public T unset(Key key) { + throw new UnsupportedOperationException(); + } + + @Override + public LifeCycle onStart(final Throwing.Consumer task) { + return null; + } + + @Override + public LifeCycle onStarted(final Throwing.Consumer task) { + return null; + } + + @Override + public LifeCycle onStop(final Throwing.Consumer task) { + return null; + } + + @Override + public Map> xss() { + return null; + } + + @Override + public Env xss(final String name, final Function escaper) { + return null; + } + + @Override + public String name() { + return null; + } + + @Override + public Router router() throws UnsupportedOperationException { + return null; + } + + @Override + public Config config() { + return null; + } + + @Override + public Locale locale() { + return null; + } + + @Override + public List> startTasks() { + return null; + } + + @Override + public List> startedTasks() { + return null; + } + + @Override + public List> stopTasks() { + return null; + } + + }.serviceKey()); + } + + @Test(expected = NullPointerException.class) + public void nullText() { + Env env = Env.DEFAULT.build(ConfigFactory.empty()); + env.resolve(null); + } + + @Test + public void unclosedDelimiterWithSpace() { + Env env = Env.DEFAULT.build(ConfigFactory.empty()); + try { + env.resolve(env.resolve("function ($) {$.ajax(\"${contextPath /api\")")); + fail(); + } catch (IllegalArgumentException ex) { + assertEquals("found '${' expecting '}' at 1:23", ex.getMessage()); + } + } + + @Test + public void unclosedDelimiter() { + Env env = Env.DEFAULT.build(ConfigFactory.empty()); + try { + env.resolve(env.resolve("function ($) {$.ajax(\"${contextPath/api\")")); + fail(); + } catch (IllegalArgumentException ex) { + assertEquals("found '${' expecting '}' at 1:23", ex.getMessage()); + } + } + + @Test + public void noSuchKey() { + Env env = Env.DEFAULT.build(ConfigFactory.empty()); + try { + env.resolve(env.resolve("${key}")); + fail(); + } catch (NoSuchElementException ex) { + assertEquals("Missing ${key} at 1:1", ex.getMessage()); + } + + try { + env.resolve(env.resolve(" ${key}")); + fail(); + } catch (NoSuchElementException ex) { + assertEquals("Missing ${key} at 1:5", ex.getMessage()); + } + + try { + env.resolve(env.resolve(" \n ${key}")); + fail(); + } catch (NoSuchElementException ex) { + assertEquals("Missing ${key} at 2:3", ex.getMessage()); + } + + try { + env.resolve(env.resolve(" \n ${key}")); + fail(); + } catch (NoSuchElementException ex) { + assertEquals("Missing ${key} at 2:3", ex.getMessage()); + } + + try { + env.resolve(env.resolve(" \n \n ${key}")); + fail(); + } catch (NoSuchElementException ex) { + assertEquals("Missing ${key} at 3:2", ex.getMessage()); + } + } + + @Test + public void resolveEmpty() { + Env env = Env.DEFAULT.build(ConfigFactory.empty()); + assertEquals("", env.resolve("")); + } + + @Test + public void ifMode() throws Throwable { + assertEquals("$dev", + Env.DEFAULT.build(ConfigFactory.empty()).ifMode("dev", () -> "$dev").get()); + assertEquals(Optional.empty(), + Env.DEFAULT.build(ConfigFactory.empty()).ifMode("prod", () -> "$dev")); + + assertEquals( + "$prod", + Env.DEFAULT + .build( + ConfigFactory.empty().withValue("application.env", + ConfigValueFactory.fromAnyRef("prod"))) + .ifMode("prod", () -> "$prod").get()); + assertEquals(Optional.empty(), + Env.DEFAULT + .build( + ConfigFactory.empty().withValue("application.env", + ConfigValueFactory.fromAnyRef("prod"))) + .ifMode("dev", () -> "$prod")); + } + + @Test(expected = UnsupportedOperationException.class) + public void noRouter() { + Env.DEFAULT.build(ConfigFactory.empty()).router(); + } + + @Test + public void name() throws Exception { + assertEquals("dev", Env.DEFAULT.build(ConfigFactory.empty()).toString()); + + assertEquals("prod", Env.DEFAULT.build(ConfigFactory.empty().withValue("application.env", + ConfigValueFactory.fromAnyRef("prod"))).toString()); + + } + + @Test + public void onStart() throws Exception { + Env env = Env.DEFAULT.build(ConfigFactory.empty()); + Throwing.Runnable task = () -> { + }; + env.onStart(task); + + assertEquals(1, env.startTasks().size()); + } + + @Test + public void onStop() throws Exception { + Env env = Env.DEFAULT.build(ConfigFactory.empty()); + Throwing.Runnable task = () -> { + }; + env.onStop(task); + + assertEquals(1, env.stopTasks().size()); + } +} diff --git a/jooby/src/test/java/org/jooby/ErrTest.java b/jooby/src/test/java/org/jooby/ErrTest.java new file mode 100644 index 00000000..8c89dc00 --- /dev/null +++ b/jooby/src/test/java/org/jooby/ErrTest.java @@ -0,0 +1,96 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public class ErrTest { + + @Test + public void exceptionWithStatus() { + Err exception = new Err(Status.NOT_FOUND); + + assertEquals(Status.NOT_FOUND.value(), exception.statusCode()); + assertEquals("Not Found(404)", exception.getMessage()); + } + + @Test + public void exceptionWithIntStatus() { + Err exception = new Err(404); + + assertEquals(Status.NOT_FOUND.value(), exception.statusCode()); + assertEquals("Not Found(404)", exception.getMessage()); + } + + @Test + public void exceptionWithStatusAndCause() { + Exception cause = new IllegalArgumentException(); + Err exception = new Err(Status.NOT_FOUND, cause); + + assertEquals(Status.NOT_FOUND.value(), exception.statusCode()); + assertEquals("Not Found(404)", exception.getMessage()); + assertEquals(cause, exception.getCause()); + } + + @Test + public void exceptionWithIntStatusAndCause() { + Exception cause = new IllegalArgumentException(); + Err exception = new Err(404, cause); + + assertEquals(Status.NOT_FOUND.value(), exception.statusCode()); + assertEquals("Not Found(404)", exception.getMessage()); + assertEquals(cause, exception.getCause()); + } + + @Test + public void exceptionWithStatusAndMessage() { + Err exception = new Err(Status.NOT_FOUND, "GET/missing"); + + assertEquals(Status.NOT_FOUND.value(), exception.statusCode()); + assertEquals("Not Found(404): GET/missing", exception.getMessage()); + } + + @Test + public void exceptionWithIntStatusAndMessage() { + Err exception = new Err(404, "GET/missing"); + + assertEquals(Status.NOT_FOUND.value(), exception.statusCode()); + assertEquals("Not Found(404): GET/missing", exception.getMessage()); + } + + @Test + public void exceptionWithStatusCauseAndMessage() { + Exception cause = new IllegalArgumentException(); + Err exception = new Err(Status.NOT_FOUND, "GET/missing", cause); + + assertEquals(Status.NOT_FOUND.value(), exception.statusCode()); + assertEquals("Not Found(404): GET/missing", exception.getMessage()); + assertEquals(cause, exception.getCause()); + } + + @Test + public void exceptionWithIntStatusCauseAndMessage() { + Exception cause = new IllegalArgumentException(); + Err exception = new Err(404, "GET/missing", cause); + + assertEquals(Status.NOT_FOUND.value(), exception.statusCode()); + assertEquals("(404): GET/missing", exception.getMessage()); + assertEquals(cause, exception.getCause()); + } + +} diff --git a/jooby/src/test/java/org/jooby/LifeCycleTest.java b/jooby/src/test/java/org/jooby/LifeCycleTest.java new file mode 100644 index 00000000..45a886e6 --- /dev/null +++ b/jooby/src/test/java/org/jooby/LifeCycleTest.java @@ -0,0 +1,99 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import org.junit.Test; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.io.IOException; + +public class LifeCycleTest { + + static class ShouldNotAllowStaticMethod { + @PostConstruct + public static void start() { + } + } + + static class ShouldNotAllowPrivateMethod { + @PreDestroy + private void destroy() { + } + } + + static class ShouldNotAllowMethodWithArguments { + @PostConstruct + public void start(final int arg) { + } + } + + static class ShouldNotAllowMethodWithReturnType { + @PostConstruct + public String start() { + return null; + } + } + + static class ShouldNotWrapRuntimeException { + @PostConstruct + public void start() { + throw new RuntimeException("intetional err"); + } + } + + static class ShouldWrapNoRuntimeException { + @PostConstruct + public void start() throws IOException { + throw new IOException("intetional err"); + } + } + + @Test(expected = IllegalArgumentException.class) + public void noStaticMethod() { + LifeCycle.lifeCycleAnnotation(ShouldNotAllowStaticMethod.class, PostConstruct.class); + } + + @Test(expected = IllegalArgumentException.class) + public void shouldNotAllowPrivateMethod() { + LifeCycle.lifeCycleAnnotation(ShouldNotAllowPrivateMethod.class, PreDestroy.class); + } + + @Test(expected = IllegalArgumentException.class) + public void shouldNotAllowMethodWithArguments() { + LifeCycle.lifeCycleAnnotation(ShouldNotAllowMethodWithArguments.class, PostConstruct.class); + } + + @Test(expected = IllegalArgumentException.class) + public void shouldNotAllowMethodWithReturnType() { + LifeCycle.lifeCycleAnnotation(ShouldNotAllowMethodWithReturnType.class, PostConstruct.class); + } + + @Test(expected = RuntimeException.class) + public void shouldNotWrapRuntimeExceptin() throws Throwable { + LifeCycle.lifeCycleAnnotation(ShouldNotWrapRuntimeException.class, PostConstruct.class) + .get().accept(new ShouldNotWrapRuntimeException()); + ; + } + + @Test(expected = IOException.class) + public void shouldWrapNotWrapException() throws Throwable { + LifeCycle.lifeCycleAnnotation(ShouldWrapNoRuntimeException.class, PostConstruct.class) + .get().accept(new ShouldWrapNoRuntimeException()); + ; + } + +} diff --git a/jooby/src/test/java/org/jooby/MediaTypeDbTest.java b/jooby/src/test/java/org/jooby/MediaTypeDbTest.java new file mode 100644 index 00000000..2eddad35 --- /dev/null +++ b/jooby/src/test/java/org/jooby/MediaTypeDbTest.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static org.junit.Assert.assertEquals; + +import java.io.File; + +import org.junit.Test; + +public class MediaTypeDbTest { + + @Test + public void javascript() { + assertEquals(MediaType.js, MediaType.byExtension("js").get()); + assertEquals(MediaType.js, MediaType.byFile(new File("file.js")).get()); + } + + @Test + public void css() { + assertEquals(MediaType.css, MediaType.byExtension("css").get()); + assertEquals(MediaType.css, MediaType.byFile(new File("file.css")).get()); + } + + @Test + public void json() { + assertEquals(MediaType.json, MediaType.byExtension("json").get()); + assertEquals(MediaType.json, MediaType.byFile(new File("file.json")).get()); + } + + @Test + public void png() { + assertEquals(MediaType.valueOf("image/png"), MediaType.byExtension("png").get()); + assertEquals(MediaType.valueOf("image/png"), MediaType.byFile(new File("file.png")).get()); + } +} diff --git a/jooby/src/test/java/org/jooby/MediaTypeTest.java b/jooby/src/test/java/org/jooby/MediaTypeTest.java new file mode 100644 index 00000000..5868546f --- /dev/null +++ b/jooby/src/test/java/org/jooby/MediaTypeTest.java @@ -0,0 +1,269 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import java.nio.file.Paths; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.junit.Test; + +public class MediaTypeTest { + + @Test + public void first1() { + List supported = MediaType.valueOf("text/html", "application/xhtml+xml", + "application/xml;q=0.9", "image/webp", "*/*;q=0.8"); + + assertFirst(supported, MediaType.valueOf("text/html")); + + assertFirst(supported, MediaType.valueOf("text/plain")); + } + + @Test + public void firstFilter() { + assertEquals(MediaType.js, MediaType.matcher(MediaType.js).first(MediaType.js).get()); + assertEquals(true, MediaType.matcher(MediaType.js).matches(MediaType.js)); + assertEquals(false, MediaType.matcher(MediaType.js).matches(MediaType.json)); + } + + @Test + public void types() { + assertEquals("application", MediaType.js.type()); + assertEquals("javascript", MediaType.js.subtype()); + } + + @Test + public void any() { + assertEquals(true, MediaType.all.isAny()); + assertEquals(false, MediaType.js.isAny()); + assertEquals(false, MediaType.valueOf("application/*+json").isAny()); + } + + @Test(expected = IllegalArgumentException.class) + public void nullFilter() { + MediaType.matcher(MediaType.js).filter(null); + } + + @Test(expected = IllegalArgumentException.class) + public void emptyFilter() { + MediaType.matcher(MediaType.js).filter(Collections.emptyList()); + } + + @Test + public void firstMany() { + List supported = MediaType.valueOf("text/html", "application/*+json"); + + assertFirst(supported, MediaType.valueOf("text/html")); + + assertFirst(supported, MediaType.valueOf("application/vnd.github+json")); + } + + private void assertFirst(final List supported, final MediaType candidate) { + assertFirst(supported, candidate, candidate); + } + + private void assertFirst(final List supported, final MediaType candidate, + final MediaType expected) { + assertEquals(expected, MediaType.matcher(supported).first(candidate).get()); + } + + @Test + public void matchesEq() { + assertTrue(MediaType.valueOf("text/html").matches(MediaType.valueOf("text/html"))); + } + + @Test + public void matchesAny() { + assertTrue(MediaType.valueOf("*/*").matches(MediaType.valueOf("text/html"))); + } + + @Test + public void matchesSubtype() { + assertTrue(MediaType.valueOf("text/*").matches(MediaType.valueOf("text/html"))); + } + + @Test + public void matchesSubtypeSuffix() { + assertTrue(MediaType.valueOf("application/*+xml").matches( + MediaType.valueOf("application/soap+xml"))); + assertTrue(MediaType.valueOf("application/*xml").matches( + MediaType.valueOf("application/soapxml"))); + } + + @Test + public void order() { + assertMediaTypes((MediaType.valueOf("*/*", "audio/*", "audio/basic")), + "audio/basic;q=1", "audio/*;q=1", "*/*;q=1"); + + assertMediaTypes((MediaType.valueOf("audio/*;q=0.7", "audio/*;q=0.3", "audio/*")), + "audio/*;q=1", "audio/*;q=0.7", "audio/*;q=0.3"); + + assertMediaTypes( + (MediaType.valueOf("text/plain; q=0.5", "text/html", "text/x-dvi; q=0.8", + "text/x-c")), + "text/html;q=1", "text/x-c;q=1", "text/x-dvi;q=0.8", "text/plain;q=0.5"); + } + + @Test + public void precedenceWithLevel() { + assertMediaTypes( + (MediaType.valueOf("text/*", "text/html", "text/html;level=1", "*/*")), + "text/html;q=1;level=1", "text/html;q=1", "text/*;q=1", "*/*;q=1"); + } + + @Test + public void precedenceWithLevelAndQuality() { + assertMediaTypes((MediaType.valueOf( + "text/*;q=0.3", "text/html;q=0.7", "text/html;level=1", + "text/html;level=2;q=0.4", "*/*;q=0.5")), + "text/html;q=1;level=1", "text/html;q=0.7", "text/html;q=0.4;level=2", "text/*;q=0.3", + "*/*;q=0.5"); + } + + @Test + public void text() { + assertTrue(MediaType.json.isText()); + assertTrue(MediaType.html.isText()); + assertTrue(MediaType.xml.isText()); + assertTrue(MediaType.css.isText()); + assertTrue(MediaType.js.isText()); + assertTrue(MediaType.valueOf("application/*+xml").isText()); + assertTrue(MediaType.valueOf("application/*xml").isText()); + assertFalse(MediaType.octetstream.isText()); + assertTrue(MediaType.valueOf("application/hocon").isText()); + } + + @Test + public void compareSameInstance() { + assertTrue(MediaType.json.compareTo(MediaType.json) == 0); + } + + @Test + public void wildcardHasLessPrecendence() { + assertTrue(MediaType.all.compareTo(MediaType.json) == 1); + + assertTrue(MediaType.json.compareTo(MediaType.all) == -1); + } + + @Test + public void compareParams() { + MediaType one = MediaType.valueOf("application/json;charset=UTF-8"); + assertEquals(-1, one.compareTo(MediaType.json)); + assertEquals(0, MediaType.valueOf("application/json").compareTo(MediaType.json)); + assertEquals(1, MediaType.json.compareTo(one)); + } + + @Test + public void hash() { + assertEquals(MediaType.json.hashCode(), MediaType.json.hashCode()); + assertNotEquals(MediaType.html.hashCode(), MediaType.json.hashCode()); + } + + @Test + public void eq() { + assertEquals(MediaType.json, MediaType.json); + assertEquals(MediaType.json, MediaType.valueOf("application/json")); + assertEquals(MediaType.valueOf("application/json"), MediaType.json); + assertNotEquals(MediaType.html, MediaType.json); + assertNotEquals(MediaType.json, MediaType.html); + assertNotEquals(MediaType.text, MediaType.html); + assertNotEquals(MediaType.json, MediaType.valueOf("application/json;text=true")); + assertNotEquals(MediaType.json, new Object()); + } + + @Test(expected = Err.BadMediaType.class) + public void badMediaType() { + MediaType.valueOf(""); + } + + @Test(expected = Err.BadMediaType.class) + public void badMediaType2() { + MediaType.valueOf("application/and/something"); + } + + @Test(expected = Err.BadMediaType.class) + public void badMediaType3() { + MediaType.valueOf("*/json"); + } + + @Test + public void params() { + MediaType type = MediaType.valueOf("application/json;q=1.7;charset=UTF-16"); + assertEquals("1.7", type.params().get("q")); + assertEquals("utf-16", type.params().get("charset")); + } + + @Test + public void badParam() { + MediaType type = MediaType.valueOf("application/json;charset"); + assertEquals(null, type.params().get("charset")); + } + + @Test + public void acceptHeader() { + List types = MediaType.valueOf("json", "html"); + assertEquals(MediaType.json, types.get(0)); + assertEquals(MediaType.html, types.get(1)); + } + + @Test + public void byPath() { + Optional type = MediaType.byPath(Paths.get("file.json")); + assertEquals(MediaType.json, type.get()); + } + + @Test + public void byBadPath() { + Optional type = MediaType.byPath(Paths.get("file")); + assertEquals(Optional.empty(), type); + } + + @Test + public void byExt() { + Optional type = MediaType.byExtension("json"); + assertEquals(MediaType.json, type.get()); + } + + @Test + public void byUnknownExt() { + Optional type = MediaType.byExtension("unk"); + assertEquals(Optional.empty(), type); + } + + private void assertMediaTypes(final List types, final String... expected) { + assertEquals(types.toString(), expected.length, types.size()); + Collections.sort(types); + Iterator iterator = types.iterator(); + for (int i = 0; i < expected.length; i++) { + MediaType m = iterator.next(); + String found = m.name() + + m.params().entrySet().stream().map(Map.Entry::toString) + .collect(Collectors.joining(";", ";", "")); + assertEquals("types[" + i + "] must be: " + expected[i] + " found: " + types, expected[i], + found); + } + } +} diff --git a/jooby/src/test/java/org/jooby/MvcClassTest.java b/jooby/src/test/java/org/jooby/MvcClassTest.java new file mode 100644 index 00000000..283ecd0f --- /dev/null +++ b/jooby/src/test/java/org/jooby/MvcClassTest.java @@ -0,0 +1,36 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static org.junit.Assert.assertEquals; + +import org.jooby.Jooby.MvcClass; +import org.jooby.Route.Definition; +import org.junit.Test; + +public class MvcClassTest { + + @Test + public void rendererAttr() throws Exception { + MvcClass mvcClass = new Jooby.MvcClass(MvcClassTest.class, "/", null); + mvcClass.renderer("text"); + assertEquals("text", mvcClass.renderer()); + Definition route = new Route.Definition("GET", "/", (req, rsp, chain) -> { + }); + mvcClass.apply(route); + assertEquals("text", route.renderer()); + } +} diff --git a/jooby/src/test/java/org/jooby/ResponseTest.java b/jooby/src/test/java/org/jooby/ResponseTest.java new file mode 100644 index 00000000..fdf12571 --- /dev/null +++ b/jooby/src/test/java/org/jooby/ResponseTest.java @@ -0,0 +1,294 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import java.io.File; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.util.LinkedList; +import java.util.Optional; + +import org.jooby.Cookie.Definition; +import org.jooby.Route.After; +import org.jooby.Route.Complete; +import org.junit.Test; + +public class ResponseTest { + public static class ResponseMock implements Response { + + @Override public boolean isResetHeadersOnError() { + throw new UnsupportedOperationException(); + } + + @Override public void setResetHeadersOnError(boolean value) { + throw new UnsupportedOperationException(); + } + + @Override + public void download(final String filename, final InputStream stream) throws Exception { + throw new UnsupportedOperationException(); + } + + @Override + public void download(final String filename, final String location) throws Exception { + } + + @Override + public Response cookie(final Definition cookie) { + throw new UnsupportedOperationException(); + } + + @Override + public Response cookie(final Cookie cookie) { + throw new UnsupportedOperationException(); + } + + @Override + public Response clearCookie(final String name) { + throw new UnsupportedOperationException(); + } + + @Override + public Mutant header(final String name) { + throw new UnsupportedOperationException(); + } + + @Override + public Response header(final String name, final Object value) { + throw new UnsupportedOperationException(); + } + + @Override + public Response header(final String name, final Iterable values) { + throw new UnsupportedOperationException(); + } + + @Override + public Charset charset() { + throw new UnsupportedOperationException(); + } + + @Override + public Response charset(final Charset charset) { + throw new UnsupportedOperationException(); + } + + @Override + public Response length(final long length) { + throw new UnsupportedOperationException(); + } + + @Override + public void end() { + throw new UnsupportedOperationException(); + } + + @Override + public Optional type() { + throw new UnsupportedOperationException(); + } + + @Override + public Response type(final MediaType type) { + throw new UnsupportedOperationException(); + } + + @Override + public void send(final Result result) throws Exception { + throw new UnsupportedOperationException(); + } + + @Override + public void redirect(final Status status, final String location) throws Exception { + throw new UnsupportedOperationException(); + } + + @Override + public Optional status() { + throw new UnsupportedOperationException(); + } + + @Override + public Response status(final Status status) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean committed() { + throw new UnsupportedOperationException(); + } + + @Override + public void after(final After handler) { + throw new UnsupportedOperationException(); + } + + @Override + public void complete(final Complete handler) { + throw new UnsupportedOperationException(); + } + } + + @Test + public void type() { + LinkedList types = new LinkedList<>(); + new ResponseMock() { + @Override + public Response type(final MediaType type) { + types.add(type); + return this; + } + }.type("json"); + assertEquals(MediaType.json, types.getFirst()); + } + + @Test + public void sendObject() throws Throwable { + Object data = new Object(); + LinkedList dataList = new LinkedList<>(); + new ResponseMock() { + @Override + public void send(final Result result) { + assertNotNull(result); + assertEquals(Status.OK, result.status().get()); + assertEquals(MediaType.json, result.type().get()); + dataList.add(result.ifGet().get()); + } + + @Override + public Optional status() { + return Optional.of(Status.OK); + } + + @Override + public Optional type() { + return Optional.of(MediaType.json); + } + + }.send(data); + + assertEquals(data, dataList.getFirst()); + } + + @Test + public void sendBody() throws Throwable { + Object data = Results.noContent(); + LinkedList dataList = new LinkedList<>(); + new ResponseMock() { + @Override + public void send(final Result body) throws Exception { + assertNotNull(body); + dataList.add(body); + } + + @Override + public Optional status() { + return Optional.of(Status.OK); + } + + @Override + public Optional type() { + return Optional.of(MediaType.json); + } + + }.send(data); + + assertEquals(data, dataList.getFirst()); + } + + @Test + public void redirect() throws Throwable { + LinkedList dataList = new LinkedList<>(); + new ResponseMock() { + @Override + public void redirect(final Status status, final String location) throws Exception { + assertEquals(Status.FOUND, status); + dataList.add(location); + } + }.redirect("/red"); + assertEquals("/red", dataList.getFirst()); + } + + @Test + public void statusCode() throws Exception { + LinkedList dataList = new LinkedList<>(); + new ResponseMock() { + @Override + public Response status(final Status status) { + dataList.add(status); + return this; + } + }.status(200); + assertEquals(Status.OK, dataList.getFirst()); + } + + @Test + public void downloadFileWithName() throws Throwable { + LinkedList dataList = new LinkedList<>(); + File resource = file("src/test/resources/org/jooby/ResponseTest.js"); + new ResponseMock() { + @Override + public void download(final String filename, final InputStream stream) throws Exception { + assertNotNull(stream); + stream.close(); + dataList.add(filename); + } + + @Override + public Response length(final long length) { + dataList.add(length); + return this; + } + }.download("alias.js", resource); + assertEquals("[20, alias.js]", dataList.toString()); + } + + @Test + public void cookieWithNameAndValue() throws Exception { + LinkedList dataList = new LinkedList<>(); + new ResponseMock() { + @Override + public Response cookie(final Cookie.Definition cookie) { + dataList.add(cookie); + return this; + } + }.cookie("name", "value"); + + assertEquals("name", dataList.getFirst().name().get()); + assertEquals("value", dataList.getFirst().value().get()); + } + + /** + * Attempt to load a file from multiple location. required by unit and integration tests. + * + * @param location + * @return + */ + private File file(final String location) { + for (String candidate : new String[]{location, "jooby/" + location, + "../../jooby/" + location }) { + File file = new File(candidate); + if (file.exists()) { + return file; + } + } + return file(location); + } + +} diff --git a/jooby/src/test/java/org/jooby/ResultTest.java b/jooby/src/test/java/org/jooby/ResultTest.java new file mode 100644 index 00000000..b1411dc6 --- /dev/null +++ b/jooby/src/test/java/org/jooby/ResultTest.java @@ -0,0 +1,228 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static org.junit.Assert.assertEquals; + +import java.util.Date; +import java.util.Optional; + +import org.junit.Test; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Lists; + +public class ResultTest { + + @Test + public void sillyJacocoWithStaticMethods() { + new Results(); + } + + @Test + public void entityAndStatus() { + Result result = Results.with("x", 200); + assertEquals("x", result.ifGet().get()); + assertEquals(Optional.empty(), result.type()); + assertEquals(Status.OK, result.status().get()); + } + + @Test + public void json() { + Result result = Results.json("{}"); + assertEquals("{}", result.ifGet().get()); + assertEquals(MediaType.json, result.type().get()); + assertEquals(Status.OK, result.status().get()); + } + + @Test + public void xml() { + Result result = Results.xml("{}"); + assertEquals("{}", result.ifGet().get()); + assertEquals(MediaType.xml, result.type().get()); + assertEquals(Status.OK, result.status().get()); + } + + @Test + public void accepted() { + Result result = Results.accepted(); + assertEquals(Optional.empty(), result.ifGet()); + assertEquals(Optional.empty(), result.type()); + assertEquals(Status.ACCEPTED, result.status().get()); + } + + @Test + public void acceptedWithConent() { + Result result = Results.accepted("s"); + assertEquals(Optional.empty(), result.type()); + assertEquals("s", result.ifGet().get()); + assertEquals(Status.ACCEPTED, result.status().get()); + } + + @Test + public void ok() { + Result result = Results.ok(); + assertEquals(Optional.empty(), result.ifGet()); + assertEquals((Object) null, result.get()); + assertEquals(Optional.empty(), result.type()); + assertEquals(Status.OK, result.status().get()); + } + + @Test + public void okWithConent() { + Result result = Results.ok("s"); + assertEquals(Optional.empty(), result.type()); + assertEquals("s", result.ifGet().get()); + assertEquals(Status.OK, result.status().get()); + } + + @Test + public void withStatusCode() { + Result result = Results.with(200); + assertEquals(Optional.empty(), result.ifGet()); + assertEquals(Optional.empty(), result.type()); + assertEquals(Status.OK, result.status().get()); + } + + @Test + public void chainStatusCode() { + Result result = Results.with("b").status(200); + assertEquals(Optional.empty(), result.type()); + assertEquals("b", result.ifGet().get()); + assertEquals(Status.OK, result.status().get()); + } + + @Test + public void type() { + Result result = Results.with("b").type("json"); + assertEquals(MediaType.json, result.type().get()); + assertEquals("b", result.ifGet().get()); + assertEquals(Optional.empty(), result.status()); + } + + @Test + public void header() { + Date date = new Date(); + Result result = Results.ok().header("char", 'c') + .header("byte", (byte) 3) + .header("short", (short) 4) + .header("int", 5) + .header("long", 6l) + .header("float", 7f) + .header("double", 8d) + .header("date", date) + .header("list", 1, 2, 3); + + assertEquals('c', result.headers().get("char")); + assertEquals((byte) 3, result.headers().get("byte")); + assertEquals((short) 4, result.headers().get("short")); + assertEquals(5, result.headers().get("int")); + assertEquals((long) 6, result.headers().get("long")); + assertEquals(7.0f, result.headers().get("float")); + assertEquals(8.0d, result.headers().get("double")); + assertEquals(date, result.headers().get("date")); + assertEquals(Lists.newArrayList(1, 2, 3), result.headers().get("list")); + } + + @Test + public void chainStatus() { + Result result = Results.with("b").status(Status.OK); + assertEquals(Optional.empty(), result.type()); + assertEquals("b", result.ifGet().get()); + assertEquals(Status.OK, result.status().get()); + } + + @Test + public void noContent() { + Result result = Results.noContent(); + assertEquals(Optional.empty(), result.ifGet()); + assertEquals(Optional.empty(), result.type()); + assertEquals(Status.NO_CONTENT, result.status().get()); + } + + @Test + public void withStatus() { + Result result = Results.with(Status.CREATED); + assertEquals(Optional.empty(), result.ifGet()); + assertEquals(Optional.empty(), result.type()); + assertEquals(Status.CREATED, result.status().get()); + } + + @Test + public void resultWithConent() { + Result result = Results.with("s"); + assertEquals(Optional.empty(), result.type()); + assertEquals(Optional.empty(), result.status()); + assertEquals("s", result.ifGet().get()); + } + + @Test + public void moved() { + Result result = Results.moved("/location"); + assertEquals(Optional.empty(), result.ifGet()); + assertEquals(Optional.empty(), result.type()); + assertEquals(Status.MOVED_PERMANENTLY, result.status().get()); + assertEquals("/location", result.headers().get("location")); + } + + @Test + public void redirect() { + Result result = Results.redirect("/location"); + assertEquals(Optional.empty(), result.ifGet()); + assertEquals(Optional.empty(), result.type()); + assertEquals(Status.FOUND, result.status().get()); + assertEquals("/location", result.headers().get("location")); + } + + @Test + public void seeOther() { + Result result = Results.seeOther("/location"); + assertEquals(Optional.empty(), result.ifGet()); + assertEquals(Optional.empty(), result.type()); + assertEquals(Status.SEE_OTHER, result.status().get()); + assertEquals("/location", result.headers().get("location")); + } + + @Test + public void temporaryRedirect() { + Result result = Results.tempRedirect("/location"); + assertEquals(Optional.empty(), result.ifGet()); + assertEquals(Optional.empty(), result.type()); + assertEquals(Status.TEMPORARY_REDIRECT, result.status().get()); + assertEquals("/location", result.headers().get("location")); + } + + @Test + public void whenGet() { + Object value = new Object(); + Object json = new Object(); + Result result = Results + .when(MediaType.json, () -> json) + .when(MediaType.all, () -> value); + Result clone = result.clone(); + assertEquals(json, result.get()); + assertEquals(value, clone.get(ImmutableList.of(MediaType.html))); + } + + @Test + public void whenIfGet() { + Object value = new Object(); + Result result = Results + .when(MediaType.all, () -> value); + assertEquals(value, result.ifGet().get()); + } + +} diff --git a/jooby/src/test/java/org/jooby/RouteCollectionTest.java b/jooby/src/test/java/org/jooby/RouteCollectionTest.java new file mode 100644 index 00000000..e47bd231 --- /dev/null +++ b/jooby/src/test/java/org/jooby/RouteCollectionTest.java @@ -0,0 +1,57 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static org.junit.Assert.assertEquals; + +import java.util.Arrays; + +import org.jooby.Route.Collection; +import org.jooby.Route.Definition; +import org.junit.Test; + +public class RouteCollectionTest { + + @Test + public void renderer() { + Collection col = new Route.Collection(new Route.Definition("*", "*", (req, rsp, chain) -> { + })) + .renderer("json"); + + assertEquals("json", col.renderer()); + } + + @Test + public void attr() { + Definition def = new Route.Definition("*", "*", (req, rsp, chain) -> { + }); + new Route.Collection(def) + .attr("foo", "bar"); + + assertEquals("bar", def.attributes().get("foo")); + } + + @Test + public void excludes() { + Definition def = new Route.Definition("*", "*", (req, rsp, chain) -> { + }); + new Route.Collection(def) + .excludes("/path"); + + assertEquals(Arrays.asList("/path"), def.excludes()); + } + +} diff --git a/jooby/src/test/java/org/jooby/StatusTest.java b/jooby/src/test/java/org/jooby/StatusTest.java new file mode 100644 index 00000000..9f7ff122 --- /dev/null +++ b/jooby/src/test/java/org/jooby/StatusTest.java @@ -0,0 +1,32 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class StatusTest { + + @Test + public void customCode() { + Status status = Status.valueOf(444); + assertEquals("444", status.reason()); + assertEquals("444 (444)", status.toString()); + assertEquals(444, status.value()); + } + +} diff --git a/jooby/src/test/java/org/jooby/ViewTest.java b/jooby/src/test/java/org/jooby/ViewTest.java new file mode 100644 index 00000000..2298d8e7 --- /dev/null +++ b/jooby/src/test/java/org/jooby/ViewTest.java @@ -0,0 +1,82 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +import com.google.common.collect.ImmutableMap; + +public class ViewTest { + + static class ViewTestEngine implements View.Engine { + + @Override + public void render(final View viewable, final Context ctx) throws Exception { + // TODO Auto-generated method stub + + } + + } + + @Test + public void viewOnly() { + View view = Results.html("v"); + assertEquals("v", view.name()); + assertEquals(0, view.model().size()); + } + + @Test + public void viewWithDefModel() { + View view = Results.html("v").put("m", "x"); + assertEquals("v", view.name()); + assertEquals(1, view.model().size()); + assertEquals("x", view.model().get("m")); + } + + @Test(expected = UnsupportedOperationException.class) + public void failOnSet() { + View view = Results.html("v").put("m", "x"); + view.set(view); + } + + @Test + public void viewBuildModel() { + View view = Results.html("v").put("m", "x"); + assertEquals("v", view.name()); + assertEquals(1, view.model().size()); + assertEquals("x", view.model().get("m")); + } + + @Test + public void viewBuildModelMap() { + View view = Results.html("v").put("m", ImmutableMap.of("k", "v")); + assertEquals("v", view.name()); + assertEquals(1, view.model().size()); + assertEquals(ImmutableMap.of("k", "v"), view.model().get("m")); + } + + @Test + public void viewPutMap() { + View view = Results.html("v").put(ImmutableMap.of("k", "v")); + assertEquals("v", view.name()); + assertEquals(1, view.model().size()); + assertEquals("v", view.model().get("k")); + } + + +} diff --git a/jooby/src/test/java/org/jooby/WebSocketDefinitionTest.java b/jooby/src/test/java/org/jooby/WebSocketDefinitionTest.java new file mode 100644 index 00000000..8c2e17d1 --- /dev/null +++ b/jooby/src/test/java/org/jooby/WebSocketDefinitionTest.java @@ -0,0 +1,95 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; + +import org.junit.Test; + +public class WebSocketDefinitionTest { + + @Test + public void toStr() { + WebSocket.Definition def = new WebSocket.Definition("/pattern", (req, ws) -> { + }); + + assertEquals("WS /pattern\n" + + " consume: text/plain\n" + + " produces: text/plain\n", def.toString()); + } + + @Test + public void matches() { + WebSocket.Definition def = new WebSocket.Definition("/pattern", (req, ws) -> { + }); + + assertEquals(true, def.matches("/pattern").isPresent()); + assertEquals(false, def.matches("/patter").isPresent()); + } + + @Test + public void consumes() { + assertEquals(MediaType.json, new WebSocket.Definition("/pattern", (req, ws) -> { + }).consumes("json").consumes()); + } + + @Test(expected = NullPointerException.class) + public void consumesNull() { + new WebSocket.Definition("/pattern", (req, ws) -> { + }).consumes((MediaType) null); + + } + + @Test + public void produces() { + assertEquals(MediaType.json, new WebSocket.Definition("/pattern", (req, ws) -> { + }).produces("json").produces()); + } + + @Test(expected = NullPointerException.class) + public void producesNull() { + new WebSocket.Definition("/pattern", (req, ws) -> { + }).produces((MediaType) null); + } + + @Test + public void identity() { + assertEquals( + new WebSocket.Definition("/pattern", (req, ws) -> { + }), + new WebSocket.Definition("/pattern", (req, ws) -> { + })); + + assertEquals( + new WebSocket.Definition("/pattern", (req, ws) -> { + }).hashCode(), + new WebSocket.Definition("/pattern", (req, ws) -> { + }).hashCode()); + + assertNotEquals( + new WebSocket.Definition("/path", (req, ws) -> { + }), + new WebSocket.Definition("/patternx", (req, ws) -> { + })); + + assertNotEquals( + new WebSocket.Definition("/patternx", (req, ws) -> { + }), + new Object()); + } + +} diff --git a/jooby/src/test/java/org/jooby/funzy/ThrowingFunctionTest.java b/jooby/src/test/java/org/jooby/funzy/ThrowingFunctionTest.java new file mode 100644 index 00000000..dd25a019 --- /dev/null +++ b/jooby/src/test/java/org/jooby/funzy/ThrowingFunctionTest.java @@ -0,0 +1,95 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.funzy; + +import static org.jooby.funzy.Throwing.throwingFunction; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import org.junit.Test; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Arrays; + +public class ThrowingFunctionTest { + + @Test + public void functionArguments() { + assertEquals(1, Throwing.throwingFunction(v1 -> { + assertEquals(1, v1); + return v1; + }).apply(1)); + + assertEquals("ab", Throwing.throwingFunction((v1, v2) -> + v1 + v2 + ).apply("a", "b")); + + assertEquals("abc", Throwing.throwingFunction((v1, v2, v3) -> + v1 + v2 + v3 + ).apply("a", "b", "c")); + + assertEquals("abcd", + Throwing.throwingFunction((v1, v2, v3, v4) -> + v1 + v2 + v3 + v4 + ).apply("a", "b", "c", "d")); + + assertEquals("abcde", + Throwing.throwingFunction( + (v1, v2, v3, v4, v5) -> + v1 + v2 + v3 + v4 + v5 + ).apply("a", "b", "c", "d", "e")); + + assertEquals("abcdef", + Throwing.throwingFunction( + (v1, v2, v3, v4, v5, v6) -> + v1 + v2 + v3 + v4 + v5 + v6 + ).apply("a", "b", "c", "d", "e", "f")); + + assertEquals("abcdefg", + Throwing.throwingFunction( + (v1, v2, v3, v4, v5, v6, v7) -> + v1 + v2 + v3 + v4 + v5 + v6 + v7 + ).apply("a", "b", "c", "d", "e", "f", "g")); + + assertEquals("abcdefgh", + Throwing.throwingFunction( + (v1, v2, v3, v4, v5, v6, v7, v8) -> + v1 + v2 + v3 + v4 + v5 + v6 + v7 + v8 + ).apply("a", "b", "c", "d", "e", "f", "g", "h")); + } + + @Test(expected = IOException.class) + public void fn1Throw() { + Throwing.Function fn = (v1) -> { + throw new IOException(); + }; + fn.apply(null); + } + + @Test(expected = NullPointerException.class) + public void fn2Throw() { + Throwing.Function2 fn = (v1, v2) -> v1.toString() + v2; + fn.apply(null, "x"); + } + + @Test(expected = NullPointerException.class) + public void fn3Throw() { + Throwing.Function3 fn = (v1, v2, v3) -> v1.toString() + v2 + v3; + fn.apply(null, true, "x"); + } + +} diff --git a/jooby/src/test/java/org/jooby/funzy/TryTest.java b/jooby/src/test/java/org/jooby/funzy/TryTest.java new file mode 100644 index 00000000..822b4def --- /dev/null +++ b/jooby/src/test/java/org/jooby/funzy/TryTest.java @@ -0,0 +1,210 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.funzy; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class TryTest { + + static class Resource implements AutoCloseable { + + final CountDownLatch closer; + + public Resource(CountDownLatch closer) { + this.closer = closer; + } + + @Override public void close() throws Exception { + closer.countDown(); + } + } + + static class Conn extends Resource { + public Conn(final CountDownLatch closer) { + super(closer); + } + + public PreparedStatemet preparedStatemet(String sql) { + return new PreparedStatemet(closer); + } + } + + static class PreparedStatemet extends Resource { + public PreparedStatemet(final CountDownLatch closer) { + super(closer); + } + + public ResultSet executeQuery() { + return new ResultSet(closer); + } + } + + static class ResultSet extends Resource { + public ResultSet(final CountDownLatch closer) { + super(closer); + } + + public String next() { + return "OK"; + } + } + + @Test + public void apply() { + AtomicReference callback = new AtomicReference<>(); + Try.Value value = Try.apply(() -> "OK") + .onSuccess(callback::set); + assertEquals(true, value.isSuccess()); + assertEquals(false, value.isFailure()); + assertEquals("OK", value.get()); + assertEquals("OK", callback.get()); + } + + @Test + public void applyWithFailure() { + AtomicReference callback = new AtomicReference<>(); + Try value = Try.apply(() -> { + throw new IllegalArgumentException("Catch me"); + }).onFailure(callback::set); + assertEquals(false, value.isSuccess()); + assertEquals(true, value.isFailure()); + assertEquals(value.getCause().get(), callback.get()); + } + + @Test + public void applyWithRecover() { + Function> factory = x -> { + return Try.apply(() -> { + throw x; + }); + }; + assertEquals("x", + factory.apply(new Throwable("intentional err")).recover(Throwable.class, "x").get()); + + assertEquals("ex", + factory.apply(new Throwable("intentional err")).recover(Throwable.class, x -> "ex").get()); + assertEquals("OK", + factory.apply(new Throwable("intentional err")).recover(x -> "OK").get()); + + assertEquals("ex", + factory.apply(new Throwable("intentional err")).orElse("ex")); + assertEquals("exGet", + factory.apply(new Throwable("intentional err")).orElseGet(() -> "exGet")); + } + + @Test + public void run() { + AtomicInteger counter = new AtomicInteger(); + Try run = Try.run(() -> { + }).onSuccess(() -> counter.incrementAndGet()); + assertEquals(true, run.isSuccess()); + assertEquals(false, run.isFailure()); + assertEquals(1, counter.get()); + } + + @Test + public void tryResource1() throws InterruptedException { + CountDownLatch latch = new CountDownLatch(1); + String applyResult = Try.with(() -> new Resource((latch))) + .apply(r -> "OK") + .get(); + latch.await(); + assertEquals("OK", applyResult); + + } + + @Test + public void tryResourceObject1() throws InterruptedException { + CountDownLatch latch = new CountDownLatch(1); + Try.of(new Resource(latch)) + .apply(in -> in.toString()) + .get(); + latch.await(); + } + + @Test + public void tryResourceMap() throws InterruptedException { + CountDownLatch counter = new CountDownLatch(4); + + Try.of(new Conn(counter)) + .map(c -> c.preparedStatemet("...")) + .map(s -> s.executeQuery()) + .apply(rs -> { + assertEquals(4L, counter.getCount()); + return rs.next(); + }) + .onComplete(() -> { + assertEquals(1L, counter.getCount()); + counter.countDown(); + }) + .get(); + + counter.await(); + } + + @Test + public void tryResource2() throws InterruptedException { + CountDownLatch latch = new CountDownLatch(2); + String output = Try.with(() -> new Resource(latch), () -> new Resource(latch)) + .apply((in, out) -> in.getClass().getSimpleName() + out.getClass().getSimpleName()) + .get(); + latch.await(); + assertEquals("ResourceResource", output); + } + + @Test + public void runWithFailure() { + AtomicInteger counter = new AtomicInteger(); + Try run = Try.run(() -> { + throw new IllegalArgumentException(); + }).onFailure(x -> { + assertNotNull(x); + counter.incrementAndGet(); + }); + assertEquals(false, run.isSuccess()); + assertEquals(true, run.isFailure()); + assertEquals(1, counter.get()); + } + + @Test(expected = IllegalArgumentException.class) + public void unwrap() { + Try.apply(() -> { + throw new InvocationTargetException(new IllegalArgumentException()); + }) + .unwrap(InvocationTargetException.class) + .get(); + } + + @Test + public void unwrapValue() { + String value = Try.apply(() -> "OK") + .unwrap(InvocationTargetException.class) + .get(); + assertEquals("OK", value); + } +} diff --git a/jooby/src/test/java/org/jooby/funzy/WhenTest.java b/jooby/src/test/java/org/jooby/funzy/WhenTest.java new file mode 100644 index 00000000..7b9e9cce --- /dev/null +++ b/jooby/src/test/java/org/jooby/funzy/WhenTest.java @@ -0,0 +1,70 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.funzy; + +import static org.junit.Assert.assertEquals; +import org.junit.Test; + +import java.util.NoSuchElementException; + +public class WhenTest { + @Test + public void when() { + Throwing.Function fn = value -> + new When<>(value) + .is(Number.class, "Number") + .is(String.class, "String") + .get(); + assertEquals("Number", fn.apply(1)); + assertEquals("String", fn.apply("v")); + } + + @Test(expected = NoSuchElementException.class) + public void nomatch() { + Throwing.Function fn = value -> + new When<>(value) + .is(Number.class, "Number") + .is(String.class, "String") + .get(); + fn.apply(true); + } + + @Test + public void safeCast() { + Throwing.Function fn = value -> + new When<>(value) + .is(Integer.class, x -> x * 2) + .orElse(-1); + assertEquals(2, fn.apply(1).intValue()); + assertEquals(4, fn.apply(2).intValue()); + } + + @Test + public void mixed() { + Throwing.Function fn = value -> + new When<>(value) + .is(Integer.class, x -> "int") + .is(Long.class, x -> "long") + .is(Float.class, "float") + .is(Double.class, x->"double") + .orElse("number"); + assertEquals("int", fn.apply(1)); + assertEquals("long", fn.apply(1L)); + assertEquals("float", fn.apply(1f)); + assertEquals("double", fn.apply(1d)); + assertEquals("number", fn.apply((short) 1)); + } +} diff --git a/jooby/src/test/java/org/jooby/internal/AppPrinterTest.java b/jooby/src/test/java/org/jooby/internal/AppPrinterTest.java new file mode 100644 index 00000000..52f04f77 --- /dev/null +++ b/jooby/src/test/java/org/jooby/internal/AppPrinterTest.java @@ -0,0 +1,198 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.junit.Assert.assertEquals; + +import java.util.Arrays; + +import org.jooby.Route; +import org.jooby.Route.Before; +import org.jooby.WebSocket; +import org.junit.Test; +import org.slf4j.LoggerFactory; + +import com.google.common.collect.Sets; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; +import com.typesafe.config.ConfigValueFactory; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; + +public class AppPrinterTest { + + @Test + public void print() { + String setup = new AppPrinter( + Sets.newLinkedHashSet( + Arrays.asList(before("/"), beforeSend("/"), after("/"), route("/"), route("/home"))), + Sets.newLinkedHashSet(Arrays.asList(socket("/ws"))), config("/")) + .toString(); + assertEquals(" GET {before}/ [*/*] [*/*] (/anonymous)\n" + + " GET {after}/ [*/*] [*/*] (/anonymous)\n" + + " GET {complete}/ [*/*] [*/*] (/anonymous)\n" + + " GET / [*/*] [*/*] (/anonymous)\n" + + " GET /home [*/*] [*/*] (/anonymous)\n" + + " WS /ws [text/plain] [text/plain]\n" + + "\n" + + "listening on:\n" + + " http://localhost:8080/", setup); + } + + @Test + public void printConfig() { + AppPrinter printer = new AppPrinter( + Sets.newLinkedHashSet( + Arrays.asList(before("/"), beforeSend("/"), after("/"), route("/"), route("/home"))), + Sets.newLinkedHashSet(Arrays.asList(socket("/ws"))), config("/")); + Logger log = (Logger) LoggerFactory.getLogger(AppPrinterTest.class); + log.setLevel(Level.DEBUG); + printer.printConf(log, config("/")); + } + + @Test + public void printHttps() { + String setup = new AppPrinter( + Sets.newLinkedHashSet(Arrays.asList(route("/"), route("/home"))), + Sets.newLinkedHashSet(Arrays.asList(socket("/ws"))), + config("/").withValue("application.securePort", ConfigValueFactory.fromAnyRef(8443))) + .toString(); + assertEquals(" GET / [*/*] [*/*] (/anonymous)\n" + + " GET /home [*/*] [*/*] (/anonymous)\n" + + " WS /ws [text/plain] [text/plain]\n" + + "\n" + + "listening on:\n" + + " http://localhost:8080/\n" + + " https://localhost:8443/", setup); + } + + @Test + public void printHttp2() { + String setup = new AppPrinter( + Sets.newLinkedHashSet(Arrays.asList(route("/"), route("/home"))), + Sets.newLinkedHashSet(Arrays.asList(socket("/ws"))), + config("/") + .withValue("server.http2.enabled", ConfigValueFactory.fromAnyRef(true)) + .withValue("application.securePort", ConfigValueFactory.fromAnyRef(8443))) + .toString(); + assertEquals(" GET / [*/*] [*/*] (/anonymous)\n" + + " GET /home [*/*] [*/*] (/anonymous)\n" + + " WS /ws [text/plain] [text/plain]\n" + + "\n" + + "listening on:\n" + + " http://localhost:8080/ +h2\n" + + " https://localhost:8443/ +h2", setup); + } + + @Test + public void printHttp2Https() { + String setup = new AppPrinter( + Sets.newLinkedHashSet(Arrays.asList(route("/"), route("/home"))), + Sets.newLinkedHashSet(Arrays.asList(socket("/ws"))), + config("/") + .withValue("server.http2.cleartext", ConfigValueFactory.fromAnyRef(false)) + .withValue("server.http2.enabled", ConfigValueFactory.fromAnyRef(true)) + .withValue("application.securePort", ConfigValueFactory.fromAnyRef(8443))) + .toString(); + assertEquals(" GET / [*/*] [*/*] (/anonymous)\n" + + " GET /home [*/*] [*/*] (/anonymous)\n" + + " WS /ws [text/plain] [text/plain]\n" + + "\n" + + "listening on:\n" + + " http://localhost:8080/\n" + + " https://localhost:8443/ +h2", setup); + } + + @Test + public void printHttp2ClearText() { + String setup = new AppPrinter( + Sets.newLinkedHashSet(Arrays.asList(route("/"), route("/home"))), + Sets.newLinkedHashSet(Arrays.asList(socket("/ws"))), + config("/") + .withValue("server.http2.cleartext", ConfigValueFactory.fromAnyRef(true)) + .withValue("server.http2.enabled", ConfigValueFactory.fromAnyRef(true))) + .toString(); + assertEquals(" GET / [*/*] [*/*] (/anonymous)\n" + + " GET /home [*/*] [*/*] (/anonymous)\n" + + " WS /ws [text/plain] [text/plain]\n" + + "\n" + + "listening on:\n" + + " http://localhost:8080/ +h2", setup); + } + + private Config config(final String path) { + return ConfigFactory.empty() + .withValue("application.host", ConfigValueFactory.fromAnyRef("localhost")) + .withValue("application.port", ConfigValueFactory.fromAnyRef("8080")) + .withValue("server.http2.enabled", ConfigValueFactory.fromAnyRef(false)) + .withValue("server.http2.cleartext", ConfigValueFactory.fromAnyRef(true)) + .withValue("application.path", ConfigValueFactory.fromAnyRef(path)); + } + + @Test + public void printWithPath() { + String setup = new AppPrinter( + Sets.newLinkedHashSet(Arrays.asList(route("/"), route("/home"))), + Sets.newLinkedHashSet(Arrays.asList(socket("/ws"))), config("/app")) + .toString(); + assertEquals(" GET / [*/*] [*/*] (/anonymous)\n" + + " GET /home [*/*] [*/*] (/anonymous)\n" + + " WS /ws [text/plain] [text/plain]\n" + + "\n" + + "listening on:\n" + + " http://localhost:8080/app", setup); + } + + @Test + public void printNoSockets() { + String setup = new AppPrinter( + Sets.newLinkedHashSet(Arrays.asList(route("/"), route("/home"))), + Sets.newLinkedHashSet(), config("/app")) + .toString(); + assertEquals(" GET / [*/*] [*/*] (/anonymous)\n" + + " GET /home [*/*] [*/*] (/anonymous)\n" + + "\n" + + "listening on:\n" + + " http://localhost:8080/app", setup); + } + + private Route.Definition route(final String pattern) { + return new Route.Definition("GET", pattern, (req, rsp) -> { + }); + } + + private Route.Definition before(final String pattern) { + return new Route.Definition("GET", pattern, (Before) (req, rsp) -> { + }); + } + + private Route.Definition beforeSend(final String pattern) { + return new Route.Definition("GET", pattern, (Route.After) (req, rsp, r) -> { + return r; + }); + } + + private Route.Definition after(final String pattern) { + return new Route.Definition("GET", pattern, (Route.Complete) (req, rsp, r) -> { + }); + } + + private WebSocket.Definition socket(final String pattern) { + return new WebSocket.Definition(pattern, (req, ws) -> { + }); + } +} diff --git a/jooby/src/test/java/org/jooby/internal/BuiltinParserTest.java b/jooby/src/test/java/org/jooby/internal/BuiltinParserTest.java new file mode 100644 index 00000000..8593f76f --- /dev/null +++ b/jooby/src/test/java/org/jooby/internal/BuiltinParserTest.java @@ -0,0 +1,34 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public class BuiltinParserTest { + + @Test + public void values() { + assertEquals(5, BuiltinParser.values().length); + } + + @Test + public void bytesValueOf() { + assertEquals(BuiltinParser.Bytes, BuiltinParser.valueOf("Bytes")); + } + +} diff --git a/jooby/src/test/java/org/jooby/internal/BuiltinRendererTest.java b/jooby/src/test/java/org/jooby/internal/BuiltinRendererTest.java new file mode 100644 index 00000000..88ba7a8a --- /dev/null +++ b/jooby/src/test/java/org/jooby/internal/BuiltinRendererTest.java @@ -0,0 +1,34 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public class BuiltinRendererTest { + + @Test + public void values() { + assertEquals(9, BuiltinRenderer.values().length); + } + + @Test + public void bytesValueOf() { + assertEquals(BuiltinRenderer.bytes, BuiltinRenderer.valueOf("bytes")); + } + +} diff --git a/jooby/src/test/java/org/jooby/internal/ByteRangeTest.java b/jooby/src/test/java/org/jooby/internal/ByteRangeTest.java new file mode 100644 index 00000000..e73ab8e2 --- /dev/null +++ b/jooby/src/test/java/org/jooby/internal/ByteRangeTest.java @@ -0,0 +1,80 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import org.jooby.Err; +import static org.junit.Assert.assertEquals; +import org.junit.Test; + +public class ByteRangeTest { + + @Test + public void newInstance() { + new ByteRange(); + } + + @Test(expected = Err.class) + public void noByteRange() { + ByteRange.parse("foo"); + } + + @Test(expected = Err.class) + public void emptyRange() { + ByteRange.parse("byte="); + } + + @Test(expected = Err.class) + public void invalidRange() { + ByteRange.parse("bytes=-"); + } + + @Test(expected = Err.class) + public void invalidRange2() { + ByteRange.parse("bytes=z-"); + } + + @Test(expected = Err.class) + public void invalidRange3() { + ByteRange.parse("bytes=-z"); + } + + @Test(expected = Err.class) + public void invalidRange4() { + ByteRange.parse("bytes=6"); + } + + @Test + public void validRange() { + long[] range = ByteRange.parse("bytes=1-10"); + assertEquals(1L, range[0]); + assertEquals(10L, range[1]); + } + + @Test + public void prefixRange() { + long[] range = ByteRange.parse("bytes=99-"); + assertEquals(99L, range[0]); + assertEquals(-1L, range[1]); + } + + @Test + public void suffixRange() { + long[] range = ByteRange.parse("bytes=-99"); + assertEquals(-1L, range[0]); + assertEquals(99L, range[1]); + } + +} diff --git a/jooby/src/test/java/org/jooby/internal/ConnectionResetByPeerTest.java b/jooby/src/test/java/org/jooby/internal/ConnectionResetByPeerTest.java new file mode 100644 index 00000000..e37f5123 --- /dev/null +++ b/jooby/src/test/java/org/jooby/internal/ConnectionResetByPeerTest.java @@ -0,0 +1,34 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import org.junit.Test; + +import java.io.IOException; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ConnectionResetByPeerTest { + + @Test + public void isConnectionResetByPeer() { + new ConnectionResetByPeer(); + assertTrue(ConnectionResetByPeer.test(new IOException("connection reset by Peer"))); + assertFalse(ConnectionResetByPeer.test(new IOException())); + assertFalse(ConnectionResetByPeer.test(new IllegalStateException("connection reset by peer"))); + } +} diff --git a/jooby/src/test/java/org/jooby/internal/EmptyBodyReferenceTest.java b/jooby/src/test/java/org/jooby/internal/EmptyBodyReferenceTest.java new file mode 100644 index 00000000..2af37741 --- /dev/null +++ b/jooby/src/test/java/org/jooby/internal/EmptyBodyReferenceTest.java @@ -0,0 +1,55 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import org.jooby.Err; +import org.jooby.Status; +import org.jooby.funzy.Throwing; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; +import org.junit.Test; + +public class EmptyBodyReferenceTest { + + @Test + public void bytes() throws Throwable { + badRequest(EmptyBodyReference::bytes); + } + + @Test + public void text() throws Throwable { + badRequest(EmptyBodyReference::text); + } + + @Test + public void writeTo() throws Throwable { + badRequest(e -> e.writeTo(null)); + } + + @Test + public void len() throws Throwable { + assertEquals(0, new EmptyBodyReference().length()); + } + + private void badRequest(final Throwing.Consumer callback) throws Throwable { + try { + callback.accept(new EmptyBodyReference()); + fail(); + } catch (Err x) { + assertEquals(Status.BAD_REQUEST.value(), x.statusCode()); + } + } +} diff --git a/jooby/src/test/java/org/jooby/internal/FallbackRouteTest.java b/jooby/src/test/java/org/jooby/internal/FallbackRouteTest.java new file mode 100644 index 00000000..ba970e2e --- /dev/null +++ b/jooby/src/test/java/org/jooby/internal/FallbackRouteTest.java @@ -0,0 +1,56 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.junit.Assert.assertEquals; + +import java.util.concurrent.atomic.AtomicBoolean; + +import org.jooby.MediaType; +import org.jooby.Route; +import org.jooby.Route.Filter; +import org.junit.Test; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +public class FallbackRouteTest { + + @Test + public void props() throws Throwable { + AtomicBoolean handled = new AtomicBoolean(false); + Filter filter = (req, rsp, chain) -> { + handled.set(true); + }; + FallbackRoute route = new FallbackRoute("foo", "GET", "/x", ImmutableList.of(MediaType.json), + filter); + + assertEquals(true, route.apply(null)); + assertEquals(0, route.attributes().size()); + assertEquals(0, route.vars().size()); + assertEquals(MediaType.ALL, route.consumes()); + assertEquals(false, route.glob()); + assertEquals("foo", route.name()); + assertEquals("/x", route.path()); + assertEquals("/x", route.pattern()); + assertEquals(ImmutableList.of(MediaType.json), route.produces()); + assertEquals("/x", route.reverse(ImmutableMap.of())); + assertEquals("/x", route.reverse("a", "b")); + assertEquals(Route.Source.BUILTIN, route.source()); + route.handle(null, null, null); + assertEquals(true, handled.get()); + } +} diff --git a/jooby/src/test/java/org/jooby/internal/HeadersTest.java b/jooby/src/test/java/org/jooby/internal/HeadersTest.java new file mode 100644 index 00000000..ede64275 --- /dev/null +++ b/jooby/src/test/java/org/jooby/internal/HeadersTest.java @@ -0,0 +1,54 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.junit.Assert.assertEquals; + +import java.util.Calendar; +import java.util.Date; + +import org.junit.Test; + +public class HeadersTest { + + @Test + public void sillyJacoco() { + new Headers(); + } + + @Test + public void encodeString() { + assertEquals("x1", Headers.encode("x1")); + } + + @Test + public void encodeNumber() { + assertEquals("12", Headers.encode(12)); + } + + @Test + public void date() { + assertEquals("Fri, 10 Apr 2015 23:31:25 GMT", Headers.encode(new Date(1428708685066L))); + } + + @Test + public void calendar() { + Calendar calendar = Calendar.getInstance(); + calendar.setTimeInMillis(1428708685066L); + assertEquals("Fri, 10 Apr 2015 23:31:25 GMT", Headers.encode(calendar)); + } + +} diff --git a/jooby/src/test/java/org/jooby/internal/LocaleUtilsTest.java b/jooby/src/test/java/org/jooby/internal/LocaleUtilsTest.java new file mode 100644 index 00000000..ee1c2dbf --- /dev/null +++ b/jooby/src/test/java/org/jooby/internal/LocaleUtilsTest.java @@ -0,0 +1,45 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public class LocaleUtilsTest { + + @Test + public void sillyJacoco() { + new LocaleUtils(); + } + + @Test + public void lang() { + assertEquals("es", LocaleUtils.parse("es").iterator().next().getLanguage().toLowerCase()); + } + + @Test + public void langCountry() { + assertEquals("es", LocaleUtils.parse("es-ar").iterator().next().getLanguage().toLowerCase()); + assertEquals("ar", LocaleUtils.parse("es-ar").iterator().next().getCountry().toLowerCase()); + } + + @Test + public void langCountryVariant() { + assertEquals("ja", LocaleUtils.parse("ja-JP").iterator().next().getLanguage().toLowerCase()); + assertEquals("jp", LocaleUtils.parse("ja-JP").iterator().next().getCountry().toLowerCase()); + } +} diff --git a/jooby/src/test/java/org/jooby/internal/MutantImplTest.java b/jooby/src/test/java/org/jooby/internal/MutantImplTest.java new file mode 100644 index 00000000..ed9ac6b8 --- /dev/null +++ b/jooby/src/test/java/org/jooby/internal/MutantImplTest.java @@ -0,0 +1,562 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.easymock.EasyMock.createMock; +import static org.junit.Assert.assertEquals; + +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import org.jooby.Err; +import org.jooby.MediaType; +import org.jooby.Mutant; +import org.jooby.internal.parser.DateParser; +import org.jooby.internal.parser.LocalDateParser; +import org.jooby.internal.parser.LocaleParser; +import org.jooby.internal.parser.ParserExecutor; +import org.jooby.internal.parser.StaticMethodParser; +import org.jooby.internal.parser.StringConstructorParser; +import org.junit.Test; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ImmutableSortedSet; +import com.google.common.collect.Sets; +import com.google.inject.Injector; +import com.google.inject.TypeLiteral; +import com.typesafe.config.ConfigFactory; +import com.typesafe.config.ConfigValueFactory; + +public class MutantImplTest { + + public enum LETTER { + A, B; + } + + @Test + public void asEmptyList() throws Exception { + assertEquals(Collections.emptyList(), + newMutant((String) null).toList(String.class)); + } + + @Test + public void asBoolean() throws Exception { + assertEquals(true, newMutant("true").booleanValue()); + assertEquals(false, newMutant("false").booleanValue()); + + assertEquals(false, newMutant("false").to(boolean.class)); + } + + @Test + public void asBooleanList() throws Exception { + assertEquals(ImmutableList.of(Boolean.TRUE, Boolean.FALSE), + newMutant("true", "false").toList(boolean.class)); + + assertEquals(ImmutableList.of(Boolean.TRUE, Boolean.FALSE), + newMutant("true", "false").toList(Boolean.class)); + + assertEquals(ImmutableList.of(Boolean.TRUE, Boolean.FALSE), + newMutant("true", "false").to(new TypeLiteral>() { + })); + } + + @Test + public void asBooleanSet() throws Exception { + assertEquals(ImmutableSet.of(Boolean.TRUE, Boolean.FALSE), + newMutant("true", "false").toSet(boolean.class)); + + assertEquals(ImmutableSet.of(Boolean.TRUE, Boolean.FALSE), + newMutant("true", "false").toSet(Boolean.class)); + } + + @Test + public void asBooleanSortedSet() throws Exception { + assertEquals(ImmutableSet.of(Boolean.TRUE, Boolean.FALSE), + newMutant("false", "true").toSortedSet(boolean.class)); + + assertEquals(ImmutableSet.of(Boolean.TRUE, Boolean.FALSE), + newMutant("false", "true").toSortedSet(Boolean.class)); + } + + @Test + public void asOptionalBoolean() throws Exception { + assertEquals(Optional.empty(), newMutant((String) null).toOptional(Boolean.class)); + + assertEquals(Optional.of(true), newMutant("true").toOptional(Boolean.class)); + + assertEquals(true, newMutant((String) null).booleanValue(true)); + } + + @Test + public void asOptionalChar() throws Exception { + assertEquals(Optional.empty(), newMutant((String) null).toOptional(Character.class)); + + assertEquals(Optional.of('x'), newMutant("x").toOptional(Character.class)); + + assertEquals('x', newMutant((String) null).charValue('x')); + } + + @Test(expected = Err.class) + public void notABoolean() throws Exception { + assertEquals(true, newMutant("True").booleanValue()); + } + + @Test + public void asByte() throws Exception { + assertEquals(23, newMutant("23").byteValue()); + + assertEquals((byte) 23, (byte) newMutant("23").to(Byte.class)); + } + + @Test(expected = Err.class) + public void notAByte() throws Exception { + assertEquals(23, newMutant("23x").byteValue()); + } + + @Test(expected = Err.class) + public void byteOverflow() throws Exception { + assertEquals(255, newMutant("255").byteValue()); + } + + @Test + public void asByteList() throws Exception { + assertEquals(ImmutableList.of((byte) 1, (byte) 2, (byte) 3), + newMutant("1", "2", "3").toList(byte.class)); + + assertEquals(ImmutableList.of((byte) 1, (byte) 2, (byte) 3), + newMutant("1", "2", "3").toList(Byte.class)); + } + + @Test + public void asByteSet() throws Exception { + assertEquals(ImmutableSet.of((byte) 1, (byte) 2, (byte) 3), + newMutant("1", "2", "3").toSet(byte.class)); + + assertEquals(ImmutableSet.of((byte) 1, (byte) 2, (byte) 3), + newMutant("1", "2", "3").toSet(Byte.class)); + } + + @Test + public void asByteSortedSet() throws Exception { + assertEquals(ImmutableSortedSet.of((byte) 1, (byte) 2, (byte) 3), + newMutant("1", "2", "3").toSortedSet(byte.class)); + + assertEquals(ImmutableSortedSet.of((byte) 1, (byte) 2, (byte) 3), + newMutant("1", "2", "3").toSortedSet(Byte.class)); + } + + @Test + public void asOptionalByte() throws Exception { + assertEquals(Optional.empty(), newMutant((String) null).toOptional(Byte.class)); + + assertEquals(5, newMutant((String) null).byteValue((byte) 5)); + + assertEquals(Optional.of((byte) 1), newMutant("1").toOptional(Byte.class)); + } + + @Test + public void asShort() throws Exception { + assertEquals(23, newMutant("23").shortValue()); + + assertEquals((short) 23, (short) newMutant("23").to(short.class)); + } + + @Test(expected = Err.class) + public void notAShort() throws Exception { + assertEquals(23, newMutant("23x").shortValue()); + } + + @Test(expected = Err.class) + public void shortOverflow() throws Exception { + assertEquals(45071, newMutant("45071").shortValue()); + } + + @Test + public void asShortList() throws Exception { + assertEquals(ImmutableList.of((short) 1, (short) 2, (short) 3), + newMutant("1", "2", "3").toList(short.class)); + + assertEquals(ImmutableList.of((short) 1, (short) 2, (short) 3), + newMutant("1", "2", "3").toList(Short.class)); + } + + @Test + public void asShortSet() throws Exception { + assertEquals(ImmutableSet.of((short) 1, (short) 2, (short) 3), + newMutant("1", "2", "3").toSet(short.class)); + + assertEquals(ImmutableSet.of((short) 1, (short) 2, (short) 3), + newMutant("1", "2", "3").toSet(Short.class)); + } + + @Test + public void asShortSortedSet() throws Exception { + assertEquals(ImmutableSortedSet.of((short) 1, (short) 2, (short) 3), + newMutant("1", "2", "3").toSortedSet(short.class)); + + assertEquals(ImmutableSortedSet.of((short) 1, (short) 2, (short) 3), + newMutant("1", "2", "3").toSortedSet(Short.class)); + } + + @Test + public void asOptionalShort() throws Exception { + assertEquals(Optional.empty(), newMutant((String) null).toOptional(Short.class)); + + assertEquals(7, newMutant((String) null).shortValue((short) 7)); + + assertEquals(Optional.of((short) 1), newMutant("1").toOptional(short.class)); + } + + @Test + public void asInt() throws Exception { + assertEquals(678, newMutant("678").intValue()); + + assertEquals(678, (int) newMutant("678").to(int.class)); + + assertEquals(678, (int) newMutant("678").to(Integer.class)); + } + + @Test + public void asIntList() throws Exception { + assertEquals(ImmutableList.of(1, 2, 3), + newMutant("1", "2", "3").toList(int.class)); + + assertEquals(ImmutableList.of(1, 2, 3), + newMutant("1", "2", "3").toList(Integer.class)); + } + + @Test + public void asIntSet() throws Exception { + assertEquals(ImmutableSet.of(1, 2, 3), + newMutant("1", "2", "3").toSet(int.class)); + + assertEquals(ImmutableSet.of(1, 2, 3), + newMutant("1", "2", "3").toSet(Integer.class)); + + assertEquals(ImmutableSet.of(1, 2, 3), + newMutant("1", "2", "3").to(new TypeLiteral>() { + })); + } + + @Test + public void asIntSortedSet() throws Exception { + assertEquals(ImmutableSortedSet.of(1, 2, 3), + newMutant("1", "2", "3").toSortedSet(int.class)); + + assertEquals(ImmutableSet.of(1, 2, 3), + newMutant("1", "2", "3").toSortedSet(Integer.class)); + } + + @Test + public void asOptionalInt() throws Exception { + assertEquals(Optional.empty(), newMutant((String) null).toOptional(int.class)); + + assertEquals(13, newMutant((String) null).intValue(13)); + + assertEquals(Optional.of(1), newMutant("1").toOptional(int.class)); + } + + @Test(expected = Err.class) + public void notAnInt() throws Exception { + assertEquals(23, newMutant("23x").intValue()); + } + + @Test + public void asLong() throws Exception { + assertEquals(6781919191l, newMutant("6781919191").longValue()); + + assertEquals(6781919191l, (long) newMutant("6781919191").to(long.class)); + + assertEquals(6781919191l, (long) newMutant("6781919191").to(Long.class)); + } + + @Test(expected = Err.class) + public void notALong() throws Exception { + assertEquals(2323113, newMutant("23113x").longValue()); + } + + @Test + public void asLongList() throws Exception { + assertEquals(ImmutableList.of(1l, 2l, 3l), + newMutant("1", "2", "3").toList(long.class)); + + assertEquals(ImmutableList.of(1l, 2l, 3l), + newMutant("1", "2", "3").toList(Long.class)); + } + + @Test + public void asMediaTypeList() throws Exception { + assertEquals(ImmutableList.of(MediaType.valueOf("application/json")), + newMutant("application/json").toList(MediaType.class)); + } + + @Test + public void asLongSet() throws Exception { + assertEquals(ImmutableSet.of(1l, 2l, 3l), + newMutant("1", "2", "3").toSet(long.class)); + + assertEquals(ImmutableSet.of(1l, 2l, 3l), + newMutant("1", "2", "3").toSet(Long.class)); + } + + @Test + public void asLongSortedSet() throws Exception { + assertEquals(ImmutableSortedSet.of(1l, 2l, 3l), + newMutant("1", "2", "3").toSortedSet(long.class)); + + assertEquals(ImmutableSortedSet.of(1l, 2l, 3l), + newMutant("1", "2", "3").toSortedSet(Long.class)); + } + + @Test + public void asOptionalLong() throws Exception { + assertEquals(Optional.empty(), newMutant((String) null).toOptional(long.class)); + + assertEquals(87, newMutant((String) null).longValue(87)); + + assertEquals(Optional.of(1l), newMutant("1").toOptional(long.class)); + } + + @Test + public void asFloat() throws Exception { + assertEquals(4.3f, newMutant("4.3").floatValue(), 0); + + assertEquals(4.3f, newMutant("4.3").to(float.class), 0); + } + + @Test(expected = Err.class) + public void notAFloat() throws Exception { + assertEquals(23.113, newMutant("23.113x").floatValue(), 0); + } + + @Test + public void asFloatList() throws Exception { + assertEquals(ImmutableList.of(1f, 2f, 3f), + newMutant("1", "2", "3").toList(float.class)); + + assertEquals(ImmutableList.of(1f, 2f, 3f), + newMutant("1", "2", "3").toList(Float.class)); + } + + @Test + public void asFloatSet() throws Exception { + assertEquals(ImmutableSet.of(1f, 2f, 3f), + newMutant("1", "2", "3").toSet(float.class)); + + Set asSet = newMutant("1", "2", "3").toSet(Float.class); + assertEquals(ImmutableSet.of(1f, 2f, 3f), + asSet); + } + + @Test + public void asFloatSortedSet() throws Exception { + assertEquals(ImmutableSortedSet.of(1f, 2f, 3f), + newMutant("1", "2", "3").toSortedSet(float.class)); + + assertEquals(ImmutableSortedSet.of(1f, 2f, 3f), + newMutant("1", "2", "3").toSortedSet(Float.class)); + } + + @Test + public void asOptionalFloat() throws Exception { + assertEquals(Optional.empty(), newMutant((String) null).toOptional(float.class)); + + assertEquals(4f, newMutant((String) null).floatValue(4f), 0); + + assertEquals(Optional.of(1f), newMutant("1").toOptional(float.class)); + } + + @Test + public void asDouble() throws Exception { + assertEquals(4.23d, newMutant("4.23").doubleValue(), 0); + + assertEquals(4.3d, newMutant("4.3").to(double.class), 0); + } + + @Test(expected = Err.class) + public void notADouble() throws Exception { + assertEquals(23.113, newMutant("23.113x").doubleValue(), 0); + } + + @Test + public void asDoubleList() throws Exception { + assertEquals(ImmutableList.of(1d, 2d, 3d), + newMutant("1", "2", "3").toList(double.class)); + + assertEquals(ImmutableList.of(1d, 2d, 3d), + newMutant("1", "2", "3").toList(Double.class)); + } + + @Test + public void asDoubleSet() throws Exception { + assertEquals(ImmutableSet.of(1d, 2d, 3d), + newMutant("1", "2", "3").toSet(double.class)); + + assertEquals(ImmutableSet.of(1d, 2d, 3d), + newMutant("1", "2", "3").toSet(Double.class)); + } + + @Test + public void asDoubleSortedSet() throws Exception { + assertEquals(ImmutableSortedSet.of(1d, 2d, 3d), + newMutant("1", "2", "3").toSortedSet(double.class)); + + assertEquals(ImmutableSortedSet.of(1d, 2d, 3d), + newMutant("1", "2", "3").toSortedSet(Double.class)); + } + + @Test + public void asOptionalDouble() throws Exception { + assertEquals(Optional.empty(), newMutant((String) null).toOptional(double.class)); + + assertEquals(3d, newMutant((String) null).doubleValue(3d), 0); + + assertEquals(Optional.of(1d), newMutant("1").toOptional(double.class)); + } + + @Test + public void asEnum() throws Exception { + assertEquals(LETTER.A, newMutant("A").toEnum(LETTER.class)); + assertEquals(LETTER.A, newMutant("A").toEnum(LETTER.class)); + assertEquals(LETTER.B, newMutant("B").toEnum(LETTER.class)); + + assertEquals(LETTER.B, newMutant("B").to(LETTER.class)); + } + + @Test + public void asEnumList() throws Exception { + assertEquals(ImmutableList.of(LETTER.A, LETTER.B), + newMutant("A", "B").toList(LETTER.class)); + } + + @Test + public void asEnumSet() throws Exception { + assertEquals(ImmutableSet.of(LETTER.A, LETTER.B), + newMutant("A", "B").toSet(LETTER.class)); + } + + @Test + public void asEnumSortedSet() throws Exception { + assertEquals(ImmutableSortedSet.of(LETTER.A, LETTER.B), + newMutant("A", "B").toSortedSet(LETTER.class)); + } + + @Test + public void asOptionalEnum() throws Exception { + assertEquals(Optional.empty(), newMutant((String) null).toOptional(LETTER.class)); + + assertEquals(LETTER.A, newMutant((String) null).toEnum(LETTER.A)); + + assertEquals(LETTER.B, newMutant("B").toEnum(LETTER.A)); + + assertEquals(Optional.of(LETTER.A), newMutant("A").toOptional(LETTER.class)); + } + + @Test(expected = Err.class) + public void notAnEnum() throws Exception { + assertEquals(LETTER.A, newMutant("c").toEnum(LETTER.class)); + } + + @Test + public void asString() throws Exception { + assertEquals("xx", newMutant("xx").value()); + + assertEquals("xx", newMutant("xx").to(String.class)); + + assertEquals("[xx]", newMutant("xx").toString()); + } + + @Test + public void asStringList() throws Exception { + assertEquals(ImmutableList.of("aa", "bb"), + newMutant("aa", "bb").toList(String.class)); + + assertEquals("[aa, bb]", newMutant("aa", "bb").toString()); + } + + @Test + public void asStringSet() throws Exception { + assertEquals(ImmutableSet.of("aa", "bb"), + newMutant("aa", "bb", "bb").toSet()); + } + + @Test + public void asStringSortedSet() throws Exception { + assertEquals(ImmutableSortedSet.of("aa", "bb"), + newMutant("aa", "bb", "bb").toSortedSet()); + } + + @Test + public void asOptionalString() throws Exception { + assertEquals(Optional.empty(), newMutant((String) null).toOptional(String.class)); + + assertEquals(Optional.empty(), newMutant((String) null).toOptional()); + + assertEquals("A", newMutant((String) null).value("A")); + + assertEquals(Optional.of("A"), newMutant("A").toOptional(String.class)); + + assertEquals(Optional.of("A"), newMutant("A").toOptional()); + } + + @Test + public void emptyList() throws Exception { + assertEquals(Collections.emptyList(), newMutant(new String[0]).toList(String.class)); + assertEquals("[]", newMutant(new String[0]).toString()); + } + + @Test + public void nullList() throws Exception { + assertEquals(Collections.emptyList(), newMutant((String) null).toList(String.class)); + assertEquals("[]", newMutant((String) null).toString()); + } + + private Mutant newMutant(final String... values) { + return new MutantImpl(newConverter(), + new StrParamReferenceImpl("parameter", "test", Arrays.asList(values))); + } + + private Mutant newMutant(final String value) { + StrParamReferenceImpl reference = new StrParamReferenceImpl("parameter", "test", value == null + ? Collections.emptyList() + : ImmutableList.of(value)); + return new MutantImpl(newConverter(), reference); + } + + private ParserExecutor newConverter() { + return new ParserExecutor(createMock(Injector.class), + Sets.newLinkedHashSet( + Arrays.asList( + BuiltinParser.Basic, + BuiltinParser.Collection, + BuiltinParser.Optional, + BuiltinParser.Enum, + new DateParser("dd/MM/yyyy"), + new LocalDateParser(DateTimeFormatter.ofPattern("dd/MM/yyyy")), + new LocaleParser(), + new StaticMethodParser("valueOf"), + new StringConstructorParser(), + new StaticMethodParser("fromString"), + new StaticMethodParser("forName"))), + new StatusCodeProvider(ConfigFactory.empty().withValue("err", + ConfigValueFactory.fromAnyRef(Collections.emptyMap())))); + } +} diff --git a/jooby/src/test/java/org/jooby/internal/ParamConverterTest.java b/jooby/src/test/java/org/jooby/internal/ParamConverterTest.java new file mode 100644 index 00000000..135e5d05 --- /dev/null +++ b/jooby/src/test/java/org/jooby/internal/ParamConverterTest.java @@ -0,0 +1,414 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Lists; +import com.google.common.collect.Sets; +import com.google.inject.Injector; +import com.google.inject.TypeLiteral; +import com.google.inject.util.Types; +import com.typesafe.config.ConfigFactory; +import org.jooby.MediaType; +import org.jooby.internal.parser.*; +import org.junit.Test; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.charset.Charset; +import java.text.SimpleDateFormat; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.*; + +import static org.easymock.EasyMock.*; +import static org.junit.Assert.*; + +public class ParamConverterTest { + + public enum Letter { + + A, + + B; + } + + public static class StringBean { + + private String value; + + public StringBean(final String value) { + this.value = value; + } + + @Override + public boolean equals(final Object obj) { + if (obj instanceof StringBean) { + return value.equals(((StringBean) obj).value); + } + return false; + } + + @Override + public int hashCode() { + return value.hashCode(); + } + + @Override + public String toString() { + return value.toString(); + } + + } + + public static class ValueOf { + + private String value; + + @Override + public boolean equals(final Object obj) { + if (obj instanceof ValueOf) { + return value.equals(((ValueOf) obj).value); + } + return false; + } + + @Override + public int hashCode() { + return value.hashCode(); + } + + @Override + public String toString() { + return value.toString(); + } + + public static ValueOf valueOf(final String value) { + ValueOf v = new ValueOf(); + v.value = value; + return v; + } + + } + + @Test + public void nullShouldResolveAsEmptyList() throws Throwable { + ParserExecutor resolver = newParser(); + List value = resolver.convert(TypeLiteral.get(Types.listOf(String.class)), data()); + assertNotNull(value); + assertTrue(value.isEmpty()); + } + + @Test + public void shouldConvertToDateFromString() throws Throwable { + ParserExecutor resolver = newParser(); + Date date = resolver.convert(TypeLiteral.get(Date.class), data("22/02/2014")); + assertNotNull(date); + + Calendar calendar = Calendar.getInstance(); + calendar.setTime(date); + + assertEquals(22, calendar.get(Calendar.DAY_OF_MONTH)); + assertEquals(2, calendar.get(Calendar.MONTH) + 1); + assertEquals(2014, calendar.get(Calendar.YEAR)); + } + + private Object data(final String... value) { + return new StrParamReferenceImpl("parameter", "test", ImmutableList.copyOf(value)); + } + + @Test + public void shouldConvertToDateFromLong() throws Throwable { + ParserExecutor resolver = newParser(); + Date date = resolver.convert(TypeLiteral.get(Date.class), data("1393038000000")); + assertNotNull(date); + SimpleDateFormat simpleDateFormat = new SimpleDateFormat("dd/MM/yyyy"); + simpleDateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + assertEquals("22/02/2014", simpleDateFormat.format(date)); + } + + @Test + public void shouldConvertToLocalDateFromString() throws Throwable { + ParserExecutor resolver = newParser(); + LocalDate date = resolver.convert(TypeLiteral.get(LocalDate.class), data("22/02/2014")); + assertNotNull(date); + assertEquals(22, date.getDayOfMonth()); + assertEquals(2, date.getMonthValue()); + assertEquals(2014, date.getYear()); + } + + @Test + public void shouldConvertToLocalDateFromLong() throws Throwable { + ParserExecutor resolver = newParser(); + LocalDate date = resolver.convert(TypeLiteral.get(LocalDate.class), data("1393038000000")); + assertNotNull(date); + assertEquals(22, date.getDayOfMonth()); + assertEquals(2, date.getMonthValue()); + assertEquals(2014, date.getYear()); + } + + @Test + public void shouldConvertBeanWithStringConstructor() throws Throwable { + ParserExecutor resolver = newParser(); + assertEquals(new StringBean("231"), + resolver.convert(TypeLiteral.get(StringBean.class), data("231"))); + } + + @Test + public void shouldConvertListOfBeanWithStringConstructor() throws Throwable { + ParserExecutor resolver = newParser(); + assertEquals(Lists.newArrayList(new StringBean("231")), + resolver.convert(TypeLiteral.get(Types.listOf(StringBean.class)), data("231"))); + } + + @Test + public void shouldConvertWithValueOfStaticMethod() throws Throwable { + ParserExecutor resolver = newParser(); + assertEquals(ValueOf.valueOf("231"), + resolver.convert(TypeLiteral.get(ValueOf.class), data("231"))); + } + + @Test + public void shouldConvertWithFromStringStaticMethod() throws Throwable { + String uuid = UUID.randomUUID().toString(); + ParserExecutor resolver = newParser(); + assertEquals(UUID.fromString(uuid), resolver.convert(TypeLiteral.get(UUID.class), data(uuid))); + } + + @Test + public void shouldConvertWithForNameStaticMethod() throws Throwable { + String cs = "UTF-8"; + ParserExecutor resolver = newParser(); + assertEquals(Charset.forName(cs), resolver.convert(TypeLiteral.get(Charset.class), data(cs))); + } + + @Test + public void shouldConvertFromLocale() throws Throwable { + String locale = "es-ar"; + ParserExecutor resolver = newParser(); + assertEquals(LocaleUtils.parse(locale), + resolver.convert(TypeLiteral.get(Locale.class), data(locale))); + } + + @Test + public void shouldConvertToInt() throws Throwable { + ParserExecutor resolver = newParser(); + assertEquals(231, (int) resolver.convert(TypeLiteral.get(int.class), data("231"))); + + assertEquals(421, (int) resolver.convert(TypeLiteral.get(Integer.class), data("421"))); + } + + @Test + public void shouldConvertToBigDecimal() throws Throwable { + ParserExecutor resolver = newParser(); + assertEquals(new BigDecimal(231.5), + resolver.convert(TypeLiteral.get(BigDecimal.class), data("231.5"))); + } + + @Test + public void shouldConvertOptionalListOfString() throws Throwable { + ParserExecutor resolver = newParser(); + assertEquals("Optional[[a, b, c]]", resolver.convert( + TypeLiteral.get(Types.newParameterizedType(Optional.class, Types.listOf(String.class))), + data("a", "b", "c")) + .toString()); + } + + @Test + public void shouldConvertToBigInteger() throws Throwable { + ParserExecutor resolver = newParser(); + assertEquals(new BigInteger("231411"), + resolver.convert(TypeLiteral.get(BigInteger.class), data("231411"))); + } + + @Test + public void shouldConvertToString() throws Throwable { + ParserExecutor resolver = newParser(); + assertEquals("231", resolver.convert(TypeLiteral.get(String.class), data("231"))); + } + + @Test + public void shouldConvertToChar() throws Throwable { + ParserExecutor resolver = newParser(); + assertEquals('c', (char) resolver.convert(TypeLiteral.get(char.class), data("c"))); + assertEquals('c', (char) resolver.convert(TypeLiteral.get(Character.class), data("c"))); + } + + @Test + public void shouldConvertToLong() throws Throwable { + ParserExecutor resolver = newParser(); + assertEquals(231L, (long) resolver.convert(TypeLiteral.get(long.class), data("231"))); + + assertEquals(421L, (long) resolver.convert(TypeLiteral.get(Long.class), data("421"))); + } + + @Test + public void shouldConvertToFloat() throws Throwable { + ParserExecutor resolver = newParser(); + assertEquals(231.5f, (float) resolver.convert(TypeLiteral.get(float.class), data("231.5")), 0f); + + assertEquals(421.3f, (float) resolver.convert(TypeLiteral.get(Float.class), data("421.3")), 0f); + } + + @Test + public void shouldConvertToDouble() throws Throwable { + ParserExecutor resolver = newParser(); + assertEquals(231.5d, (double) resolver.convert(TypeLiteral.get(double.class), data("231.5")), + 0f); + + assertEquals(421.3d, (double) resolver.convert(TypeLiteral.get(Double.class), data("421.3")), + 0f); + } + + @Test + public void shouldConvertToShort() throws Throwable { + ParserExecutor resolver = newParser(); + assertEquals((short) 231, (short) resolver.convert(TypeLiteral.get(short.class), data("231"))); + + assertEquals((short) 421, (short) resolver.convert(TypeLiteral.get(Short.class), data("421"))); + } + + @Test + public void shouldConvertToByte() throws Throwable { + ParserExecutor resolver = newParser(); + assertEquals((byte) 23, (byte) resolver.convert(TypeLiteral.get(byte.class), data("23"))); + + assertEquals((byte) 42, (byte) resolver.convert(TypeLiteral.get(Byte.class), data("42"))); + } + + @Test + public void shouldConvertToListOfBytes() throws Throwable { + ParserExecutor resolver = newParser(); + assertEquals(Lists.newArrayList((byte) 23, (byte) 45), + resolver.convert(TypeLiteral.get(Types.listOf(Byte.class)), data("23", "45"))); + } + + @Test + public void shouldConvertToSetOfBytes() throws Throwable { + ParserExecutor resolver = newParser(); + assertEquals(Sets.newHashSet((byte) 23, (byte) 45), + resolver.convert(TypeLiteral.get(Types.setOf(Byte.class)), data("23", "45", "23"))); + } + + @Test + public void shouldConvertToOptionalByte() throws Throwable { + ParserExecutor resolver = newParser(); + assertEquals(Optional.of((byte) 23), + resolver.convert(TypeLiteral.get(Types.newParameterizedType(Optional.class, Byte.class)), + data("23"))); + + assertEquals(Optional.empty(), + resolver.convert(TypeLiteral.get(Types.newParameterizedType(Optional.class, Byte.class)), + data())); + } + + @Test + public void shouldConvertToEnum() throws Throwable { + ParserExecutor resolver = newParser(); + assertEquals(Letter.A, resolver.convert(TypeLiteral.get(Letter.class), data("A"))); + } + + @Test + public void shouldConvertToBoolean() throws Throwable { + ParserExecutor resolver = newParser(); + assertEquals(true, resolver.convert(TypeLiteral.get(boolean.class), data("true"))); + assertEquals(false, resolver.convert(TypeLiteral.get(boolean.class), data("false"))); + + assertEquals(true, resolver.convert(TypeLiteral.get(Boolean.class), data("true"))); + assertEquals(false, resolver.convert(TypeLiteral.get(Boolean.class), data("false"))); + } + + @Test + public void shouldConvertToSortedSet() throws Throwable { + ParserExecutor resolver = newParser(); + assertEquals("[a, b, c]", resolver.convert( + TypeLiteral.get(Types.newParameterizedType(SortedSet.class, String.class)), + data("c", "a", "b")).toString()); + } + + @Test + public void shouldConvertToListOfBoolean() throws Throwable { + ParserExecutor resolver = newParser(); + assertEquals(Lists.newArrayList(true, false), + resolver.convert(TypeLiteral.get(Types.listOf(Boolean.class)), + data("true", "false"))); + + assertEquals(Lists.newArrayList(false, false), + resolver.convert(TypeLiteral.get(Types.listOf(Boolean.class)), + data("false", "false"))); + } + + @Test + public void shouldConvertToSetOfBoolean() throws Throwable { + ParserExecutor resolver = newParser(); + assertEquals(Sets.newHashSet(true, false), + resolver.convert(TypeLiteral.get(Types.setOf(Boolean.class)), + data("true", "false"))); + + assertEquals(Sets.newHashSet(false), + resolver.convert(TypeLiteral.get(Types.setOf(Boolean.class)), + data("false", "false"))); + } + + @Test + public void shouldConvertToOptionalBoolean() throws Throwable { + ParserExecutor resolver = newParser(); + + assertEquals(Optional.of(true), + resolver.convert( + TypeLiteral.get(Types.newParameterizedType(Optional.class, Boolean.class)), + data("true"))); + + assertEquals(Optional.of(false), + resolver.convert( + TypeLiteral.get(Types.newParameterizedType(Optional.class, Boolean.class)), + data("false"))); + + assertEquals(Optional.empty(), + resolver.convert( + TypeLiteral.get(Types.newParameterizedType(Optional.class, Boolean.class)), + data())); + + } + + @Test + public void shouldConvertToMediaType() throws Throwable { + ParserExecutor resolver = newParser(); + assertEquals(Lists.newArrayList(MediaType.valueOf("text/html")), + resolver.convert(TypeLiteral.get(Types.listOf(MediaType.class)), + data("text/html"))); + } + + private ParserExecutor newParser() { + return new ParserExecutor(createMock(Injector.class), + Sets.newLinkedHashSet( + Arrays.asList( + BuiltinParser.Basic, + BuiltinParser.Collection, + BuiltinParser.Optional, + BuiltinParser.Enum, + new DateParser("dd/MM/yyyy"), + new LocalDateParser( + DateTimeFormatter.ofPattern("dd/MM/yyyy").withZone(ZoneId.of("UTC"))), + new LocaleParser(), + new StaticMethodParser("valueOf"), + new StringConstructorParser(), + new StaticMethodParser("fromString"), + new StaticMethodParser("forName"))), + new StatusCodeProvider(ConfigFactory.empty())); + } +} diff --git a/jooby/src/test/java/org/jooby/internal/ReaderInputStreamTest.java b/jooby/src/test/java/org/jooby/internal/ReaderInputStreamTest.java new file mode 100644 index 00000000..383430de --- /dev/null +++ b/jooby/src/test/java/org/jooby/internal/ReaderInputStreamTest.java @@ -0,0 +1,50 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.Assert.assertEquals; + +import java.io.IOException; +import java.io.StringReader; + +import org.junit.Test; + +public class ReaderInputStreamTest { + + @Test + public void empty() throws IOException { + try (ReaderInputStream reader = new ReaderInputStream(new StringReader(""), UTF_8)) { + assertEquals(-1, reader.read()); + } + + } + + @Test + public void one() throws IOException { + try (ReaderInputStream reader = new ReaderInputStream(new StringReader("a"), UTF_8)) { + assertEquals(97, reader.read()); + } + } + + @Test + public void read0() throws IOException { + try (ReaderInputStream reader = new ReaderInputStream(new StringReader("a"), UTF_8)) { + assertEquals(0, reader.read(new byte[0])); + } + } + +} diff --git a/jooby/src/test/java/org/jooby/internal/RoutePatternTest.java b/jooby/src/test/java/org/jooby/internal/RoutePatternTest.java new file mode 100644 index 00000000..a1828c0e --- /dev/null +++ b/jooby/src/test/java/org/jooby/internal/RoutePatternTest.java @@ -0,0 +1,480 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.Map; +import java.util.function.Consumer; + +import org.junit.Test; + +public class RoutePatternTest { + + class RoutePathAssert { + + RoutePattern path; + + public RoutePathAssert(final String method, final String pattern, boolean ignoreCase) { + path = new RoutePattern(method, pattern, ignoreCase); + } + + public RoutePathAssert(final String method, final String pattern) { + this(method, pattern, false); + } + + public RoutePathAssert matches(final String path) { + return matches(path, (vars) -> { + }); + } + + public RoutePathAssert matches(final String path, final Consumer> vars) { + String message = this.path + " != " + path; + RouteMatcher matcher = this.path.matcher(path); + boolean matches = matcher.matches(); + if (!matches) { + System.err.println(message); + } + assertTrue(message, matches); + vars.accept(matcher.vars()); + return this; + } + + public RoutePathAssert butNot(final String path) { + String message = this.path + " == " + path; + RouteMatcher matcher = this.path.matcher(path); + boolean matches = matcher.matches(); + if (matches) { + System.err.println(message); + } + assertFalse(message, matches); + return this; + } + } + + @Test + public void fixed() { + new RoutePathAssert("GET", "com/test.jsp") + .matches("GET/com/test.jsp") + .butNot("GET/com/tsst.jsp"); + } + + @Test + public void multipleVerb() { + new RoutePathAssert("get|POST", "com/test.jsp") + .matches("GET/com/test.jsp") + .matches("POST/com/test.jsp") + .butNot("PUT/com/test.jsp") + .butNot("DELETE/com/test.jsp") + .butNot("GET/com/tsst.jsp"); + } + + @Test + public void anyVerb() { + new RoutePathAssert("*", "com/test.jsp") + .matches("GET/com/test.jsp") + .matches("POST/com/test.jsp") + .butNot("GET/com/tsst.jsp"); + + new RoutePathAssert("*", "user/:id") + .matches("GET/user/xid", (vars) -> { + assertEquals("xid", vars.get("id")); + }) + .matches("POST/user/xid2", (vars) -> { + assertEquals("xid2", vars.get("id")); + }) + .butNot("GET/com/tsst.jsp"); + } + + @Test + public void wildOne() { + new RoutePathAssert("GET", "com/t?st.jsp") + .matches("GET/com/test.jsp") + .matches("GET/com/tsst.jsp") + .matches("GET/com/tast.jsp") + .matches("GET/com/txst.jsp") + .butNot("GET/com/test1.jsp"); + } + + @Test + public void wildMany() { + new RoutePathAssert("GET", "/profile/*/edit") + .matches("GET/profile/ee-00-9-k/edit") + .butNot("GET/profile/ee-00-9-k/p/edit"); + + new RoutePathAssert("GET", "/profile/*/*/edit") + .matches("GET/profile/ee-00-9-k/p/edit") + .butNot("GET/profile/ee-00-9-k/edit") + .butNot("GET/profile/ee-00-9-k/p/k/edit"); + } + + @Test + public void subdir() { + new RoutePathAssert("GET", "com/**/test.jsp") + .matches("GET/com/test.jsp") + .matches("GET/com/a/test.jsp") + .butNot("GET/com/a/testx.jsp") + .butNot("GET/org/test.jsp"); + + new RoutePathAssert("GET", "com/**") + .matches("GET/com/test.jsp") + .matches("GET/com/a/test.jsp") + .matches("GET/com/a/testx.jsp") + .butNot("GET/org/test.jsp"); + + } + + @Test + public void any() { + new RoutePathAssert("GET", "com/**") + .matches("GET/com/test.jsp") + .matches("GET/com/a/test.jsp") + .matches("GET/com/a/testx.jsp") + .butNot("GET/org/test.jsp"); + } + + @Test + public void any2() { + new RoutePathAssert("GET", "org/**/servlet/*.html") + .matches("GET/org/jooby/servlet/test.html") + .matches("GET/org/jooby/a/servlet/test.html") + .matches("GET/org/jooby/a/b/c/servlet/test.html") + .butNot("GET/org/jooby/a/b/c/servlet/test.js"); + } + + @Test + public void anyNamed() { + new RoutePathAssert("GET", "com/**:rest") + .matches("GET/com/test.jsp", vars -> assertEquals("test.jsp", vars.get("rest"))) + .matches("GET/com/a/test.jsp", vars -> assertEquals("a/test.jsp", vars.get("rest"))) + .matches("GET/com/", vars -> assertEquals("", vars.get("rest"))) + .butNot("GET/com") + .butNot("GET/test.jsp"); + } + + @Test + public void anyNamedSpring() { + new RoutePathAssert("GET", "com/{rest:**}") + .matches("GET/com/test.jsp", vars -> assertEquals("test.jsp", vars.get("rest"))) + .matches("GET/com/a/test.jsp", vars -> assertEquals("a/test.jsp", vars.get("rest"))) + .matches("GET/com/", vars -> assertEquals("", vars.get("rest"))) + .butNot("GET/com") + .butNot("GET/test.jsp"); + } + + @Test + public void anyNamedInner() { + new RoutePathAssert("GET", "com/**:rest/bar") + .matches("GET/com/foo/bar", (vars) -> assertEquals("foo", vars.get("rest"))) + .matches("GET/com/a/foo/bar", (vars) -> assertEquals("a/foo", vars.get("rest"))) + .butNot("GET/com/foo/baz") + .butNot("GET/test.jsp"); + } + + @Test + public void anyNamedInnerSpring() { + new RoutePathAssert("GET", "com/{rest:**}/bar") + .matches("GET/com/foo/bar", (vars) -> assertEquals("foo", vars.get("rest"))) + .matches("GET/com/a/foo/bar", (vars) -> assertEquals("a/foo", vars.get("rest"))) + .butNot("GET/com/foo/baz") + .butNot("GET/test.jsp"); + } + + @Test + public void anyNamedMulti() { + new RoutePathAssert("GET", "com/**:first/bar/**:second") + .matches("GET/com/foo/bar/moo", (vars) -> { + assertEquals("foo", vars.get("first")); + assertEquals("moo", vars.get("second")); + }) + .matches("GET/com/a/foo/bar/moo/baz", (vars) -> { + assertEquals("a/foo", vars.get("first")); + assertEquals("moo/baz", vars.get("second")); + }) + .butNot("GET/com/foo/baz") + .butNot("GET/test.jsp"); + } + + @Test + public void anyNamedMultiSpring() { + new RoutePathAssert("GET", "com/{first:**}/bar/{second:**}") + .matches("GET/com/foo/bar/moo", (vars) -> { + assertEquals("foo", vars.get("first")); + assertEquals("moo", vars.get("second")); + }) + .matches("GET/com/a/foo/bar/moo/baz", (vars) -> { + assertEquals("a/foo", vars.get("first")); + assertEquals("moo/baz", vars.get("second")); + }) + .butNot("GET/com/foo/baz") + .butNot("GET/test.jsp"); + } + + @Test + public void anyNamedMultiMixed() { + new RoutePathAssert("GET", "com/**:first/bar/{second:**}") + .matches("GET/com/foo/bar/moo", (vars) -> { + assertEquals("foo", vars.get("first")); + assertEquals("moo", vars.get("second")); + }) + .matches("GET/com/a/foo/bar/moo/baz", (vars) -> { + assertEquals("a/foo", vars.get("first")); + assertEquals("moo/baz", vars.get("second")); + }) + .butNot("GET/com/foo/baz") + .butNot("GET/test.jsp"); + } + + @Test + public void anyNamedMultiMixed2() { + new RoutePathAssert("GET", "com/{first:**}/bar/**:second") + .matches("GET/com/foo/bar/moo", (vars) -> { + assertEquals("foo", vars.get("first")); + assertEquals("moo", vars.get("second")); + }) + .matches("GET/com/a/foo/bar/moo/baz", (vars) -> { + assertEquals("a/foo", vars.get("first")); + assertEquals("moo/baz", vars.get("second")); + }) + .butNot("GET/com/foo/baz") + .butNot("GET/test.jsp"); + } + + @Test + public void rootVar() { + new RoutePathAssert("GET", "{id}/list") + .matches("GET/xqi/list") + .matches("GET/123/list") + .butNot("GET/123/lisx"); + } + + @Test + public void mixedVar() { + new RoutePathAssert("GET", "user/:id/:name") + .matches("GET/user/xqi/n", (vars) -> { + assertEquals("xqi", vars.get("id")); + assertEquals("n", vars.get("name")); + }) + .butNot("GET/user/123/x/y"); + + new RoutePathAssert("GET", "user/{id}/{name}") + .matches("GET/user/xqi/n", (vars) -> { + assertEquals("xqi", vars.get("id")); + assertEquals("n", vars.get("name")); + }) + .butNot("GET/user/123/x/y"); + + new RoutePathAssert("GET", "user/{id}/:name") + .matches("GET/user/xqi/n", (vars) -> { + assertEquals("xqi", vars.get("id")); + assertEquals("n", vars.get("name")); + }) + .butNot("GET/user/123/x/y"); + + new RoutePathAssert("GET", "user/:id/{name}") + .matches("GET/user/xqi/n", (vars) -> { + assertEquals("xqi", vars.get("id")); + assertEquals("n", vars.get("name")); + }) + .butNot("GET/user/123/x/y"); + } + + @Test + public void var() { + new RoutePathAssert("GET", "user/{id}") + .matches("GET/user/xqi", (vars) -> { + assertEquals("xqi", vars.get("id")); + }) + .matches("GET/user/123", (vars) -> { + assertEquals("123", vars.get("id")); + }) + .butNot("GET/user/123/x"); + + new RoutePathAssert("GET", "user/:id") + .matches("GET/user/xqi", (vars) -> { + assertEquals("xqi", vars.get("id")); + }) + .matches("GET/user/123", (vars) -> { + assertEquals("123", vars.get("id")); + }) + .butNot("GET/user/123/x"); + + new RoutePathAssert("GET", "/:id") + .matches("GET/xqi", (vars) -> { + assertEquals("xqi", vars.get("id")); + }) + .matches("GET/123", (vars) -> { + assertEquals("123", vars.get("id")); + }) + .butNot("GET/"); + + new RoutePathAssert("GET", "/{id}") + .matches("GET/xqi", (vars) -> { + assertEquals("xqi", vars.get("id")); + }) + .matches("GET/123", (vars) -> { + assertEquals("123", vars.get("id")); + }) + .butNot("GET/"); + } + + @Test + public void varWithPrefix() { + new RoutePathAssert("GET", "user/p{id}") + .matches("GET/user/pxqi", (vars) -> { + assertEquals("xqi", vars.get("id")); + }) + .matches("GET/user/p123", (vars) -> { + assertEquals("123", vars.get("id")); + }) + .butNot("GET/user/p123/x"); + + new RoutePathAssert("GET", "user/p:id") + .matches("GET/user/pxqi", (vars) -> { + assertEquals("xqi", vars.get("id")); + }) + .matches("GET/user/p123", (vars) -> { + assertEquals("123", vars.get("id")); + }) + .butNot("GET/user/p123/x"); + } + + @Test + public void regex() { + new RoutePathAssert("GET", "user/{id:\\d+}") + .matches("GET/user/123", (vars) -> { + assertEquals("123", vars.get("id")); + }) + .butNot("GET/user/123/x") + .butNot("GET/user/123x") + .butNot("GET/user/xqi"); + } + + @Test + public void antExamples() { + new RoutePathAssert("GET", "*.java") + .matches("GET/.java") + .matches("GET/x.java") + .matches("GET/FooBar.java") + .butNot("GET/FooBar.xml"); + + new RoutePathAssert("GET", "?.java") + .matches("GET/x.java") + .matches("GET/A.java") + .butNot("GET/.java") + .butNot("GET/xyz.java"); + + new RoutePathAssert("GET", "**/CVS/*") + .matches("GET/CVS/Repository") + .matches("GET/org/apache/CVS/Entries") + .matches("GET/org/apache/jakarta/tools/ant/CVS/Entries") + .butNot("GET/org/apache/CVS/foo/bar/Entries"); + + new RoutePathAssert("GET", "org/apache/jakarta/**") + .matches("GET/org/apache/jakarta/tools/ant/docs/index.html") + .matches("GET/org/apache/jakarta/test.xml") + .butNot("GET/org/apache/xyz.java"); + + new RoutePathAssert("GET", "org/apache/**/CVS/*") + .matches("GET/org/apache/CVS/Entries") + .matches("GET/org/apache/jakarta/tools/ant/CVS/Entries") + .butNot("GET/org/apache/CVS/foo/bar/Entries"); + } + + @Test + public void moreExpression() { + new RoutePathAssert("GET", "/views/products/**/*.cfm") + .matches("GET/views/products/index.cfm") + .matches("GET/views/products/SE10/index.cfm") + .matches("GET/views/products/SE10/details.cfm") + .matches("GET/views/products/ST80/index.cfm") + .matches("GET/views/products/ST80/details.cfm") + .butNot("GET/views/index.cfm") + .butNot("GET/views/aboutUs/index.cfm") + .butNot("GET/views/aboutUs/managementTeam.cfm"); + + new RoutePathAssert("GET", "/views/index??.cfm") + .matches("GET/views/index01.cfm") + .matches("GET/views/index02.cfm") + .matches("GET/views/indexAA.cfm") + .butNot("GET/views/index01.htm") + .butNot("GET/views/index1.cfm") + .butNot("GET/views/indexOther.cfm") + .butNot("GET/views/anotherDir/index01.cfm"); + } + + @Test + public void normalizePath() { + assertEquals("/", new RoutePattern("GET", "/").pattern()); + assertEquals("/", new RoutePattern("GET", "//").pattern()); + assertEquals("/foo", new RoutePattern("GET", "/foo//").pattern()); + assertEquals("/foo", new RoutePattern("GET", "foo//").pattern()); + assertEquals("/foo", new RoutePattern("GET", "foo").pattern()); + assertEquals("/foo", new RoutePattern("GET", "foo/").pattern()); + assertEquals("/foo/bar", new RoutePattern("GET", "/foo//bar").pattern()); + } + + @Test + public void capturingGroups() { + new RoutePathAssert("GET", "/js/*/2.1.3/*") + .matches("GET/js/jquery/2.1.3/jquery.js", vars -> { + assertEquals("jquery", vars.get(0)); + assertEquals("jquery.js", vars.get(1)); + }); + + new RoutePathAssert("GET", "/js/**") + .matches("GET/js/jquery/2.1.3/jquery.js", vars -> { + assertEquals("jquery/2.1.3/jquery.js", vars.get(0)); + }); + + new RoutePathAssert("GET", "/js/**/*.js") + .matches("GET/js/jquery/2.1.3/jquery.js", vars -> { + assertEquals("jquery/2.1.3/jquery", vars.get(0)); + }); + } + + @Test + public void cornerCase() { + new RoutePathAssert("GET", "/search/**") + .matches("GET/search"); + + new RoutePathAssert("GET", "/m/**") + .butNot("GET/merge/login"); + } + + @Test + public void ignoreCase() { + new RoutePathAssert("GET", "/path/:id", true) + .matches("GET/path/aB", vars -> { + assertEquals("aB", vars.get(0)); + }) + .matches("GET/Path/ab", vars -> { + assertEquals("ab", vars.get(0)); + }); + + new RoutePathAssert("GET", "/path1", true) + .matches("GET/path1") + .matches("GET/Path1"); + } + + @Test + public void shouldNotBreakOnMissing () { + new RoutePathAssert("GET", "/") + .butNot("GET(X11;"); + } +} diff --git a/jooby/src/test/java/org/jooby/internal/RouteSourceImplTest.java b/jooby/src/test/java/org/jooby/internal/RouteSourceImplTest.java new file mode 100644 index 00000000..7dc0bebb --- /dev/null +++ b/jooby/src/test/java/org/jooby/internal/RouteSourceImplTest.java @@ -0,0 +1,42 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import static org.junit.Assert.assertEquals; + +import java.util.Optional; + +import org.jooby.Route.Source; +import org.junit.Test; + +public class RouteSourceImplTest { + + @Test + public void newSource() { + RouteSourceImpl src = new RouteSourceImpl("X", 3); + assertEquals(Optional.of("X"), src.declaringClass()); + assertEquals(3, src.line()); + + assertEquals("X:3", src.toString()); + } + + @Test + public void unknownSource() { + assertEquals(Optional.empty(), Source.BUILTIN.declaringClass()); + assertEquals(-1, Source.BUILTIN.line()); + assertEquals("~builtin", Source.BUILTIN.toString()); + } +} diff --git a/jooby/src/test/java/org/jooby/internal/SseRendererTest.java b/jooby/src/test/java/org/jooby/internal/SseRendererTest.java new file mode 100644 index 00000000..17c6f3ff --- /dev/null +++ b/jooby/src/test/java/org/jooby/internal/SseRendererTest.java @@ -0,0 +1,44 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal; + +import java.io.InputStream; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Locale; + +import org.jooby.MediaType; +import org.junit.Test; + +public class SseRendererTest { + + @Test(expected = UnsupportedOperationException.class) + public void unsupportedSendFile() throws Exception { + FileChannel filechannel = null; + new SseRenderer(Collections.emptyList(), MediaType.ALL, StandardCharsets.UTF_8, Locale.US, + Collections.emptyMap()) + ._send(filechannel); + } + + @Test(expected = UnsupportedOperationException.class) + public void unsupportedStream() throws Exception { + InputStream stream = null; + new SseRenderer(Collections.emptyList(), MediaType.ALL, StandardCharsets.UTF_8, Locale.US, + Collections.emptyMap()) + ._send(stream); + } +} diff --git a/jooby/src/test/java/org/jooby/internal/parser/bean/BeanIndexedPathTest.java b/jooby/src/test/java/org/jooby/internal/parser/bean/BeanIndexedPathTest.java new file mode 100644 index 00000000..6ec5844e --- /dev/null +++ b/jooby/src/test/java/org/jooby/internal/parser/bean/BeanIndexedPathTest.java @@ -0,0 +1,32 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.parser.bean; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +import com.google.inject.TypeLiteral; +import com.google.inject.util.Types; + +public class BeanIndexedPathTest { + + @Test + public void rootStr() { + BeanIndexedPath path = new BeanIndexedPath(null, 0, TypeLiteral.get(Types.listOf(String.class))); + assertEquals("[0]", path.toString()); + } +} diff --git a/jooby/src/test/java/org/jooby/internal/reqparam/StaticMethodParserTest.java b/jooby/src/test/java/org/jooby/internal/reqparam/StaticMethodParserTest.java new file mode 100644 index 00000000..fe109f5b --- /dev/null +++ b/jooby/src/test/java/org/jooby/internal/reqparam/StaticMethodParserTest.java @@ -0,0 +1,95 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.reqparam; + +import static org.junit.Assert.assertEquals; + +import org.jooby.internal.parser.StaticMethodParser; +import org.junit.Test; + +import com.google.inject.TypeLiteral; + +public class StaticMethodParserTest { + + public static class Value { + + private String val; + + private Value(final String val) { + this.val = val; + } + + public static Value valueOf(final String val) { + return new Value(val); + } + + @Override + public String toString() { + return val; + } + + } + + public static class ValueOfNoStatic { + + public ValueOfNoStatic valueOf() { + return new ValueOfNoStatic(); + } + + } + + public static class ValueOfNoPublic { + + @SuppressWarnings("unused") + private static ValueOfNoStatic valueOf() { + return new ValueOfNoStatic(); + } + + } + + public static class ValueOfNoPublicNoStatic { + + ValueOfNoStatic valueOf() { + return new ValueOfNoStatic(); + } + + } + + @Test + public void defaults() throws Exception { + new StaticMethodParser("valueOf"); + } + + @Test(expected = NullPointerException.class) + public void nullArg() throws Exception { + new StaticMethodParser(null); + } + + @Test + public void matches() throws Exception { + assertEquals(true, new StaticMethodParser("valueOf").matches(TypeLiteral.get(Value.class))); + + assertEquals(false, + new StaticMethodParser("valueOf").matches(TypeLiteral.get(ValueOfNoStatic.class))); + + assertEquals(false, + new StaticMethodParser("valueOf").matches(TypeLiteral.get(ValueOfNoPublic.class))); + + assertEquals(false, + new StaticMethodParser("valueOf").matches(TypeLiteral.get(ValueOfNoPublicNoStatic.class))); + } + +} diff --git a/jooby/src/test/java/org/jooby/internal/reqparam/StringConstructorParserTest.java b/jooby/src/test/java/org/jooby/internal/reqparam/StringConstructorParserTest.java new file mode 100644 index 00000000..7e0fdd98 --- /dev/null +++ b/jooby/src/test/java/org/jooby/internal/reqparam/StringConstructorParserTest.java @@ -0,0 +1,66 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.internal.reqparam; + +import static org.junit.Assert.assertEquals; + +import org.jooby.internal.parser.StringConstructorParser; +import org.junit.Test; + +import com.google.inject.TypeLiteral; + +public class StringConstructorParserTest { + + public static class Value { + + private String val; + + public Value(final String val) { + this.val = val; + } + + @Override + public String toString() { + return val; + } + + } + + public static class ValueOfNoPublic { + + private String val; + + private ValueOfNoPublic(final String val) { + this.val = val; + } + + @Override + public String toString() { + return val; + } + + } + + @Test + public void matches() throws Exception { + assertEquals(true, + new StringConstructorParser().matches(TypeLiteral.get(Value.class))); + + assertEquals(false, + new StringConstructorParser().matches(TypeLiteral.get(ValueOfNoPublic.class))); + } + +} diff --git a/jooby/src/test/java/org/jooby/issues/Issue197.java b/jooby/src/test/java/org/jooby/issues/Issue197.java new file mode 100644 index 00000000..7eb5b487 --- /dev/null +++ b/jooby/src/test/java/org/jooby/issues/Issue197.java @@ -0,0 +1,30 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.issues; + +import static org.junit.Assert.assertEquals; + +import org.jooby.MediaType; +import org.junit.Test; + +public class Issue197 { + + @Test + public void shouldParseOddMediaType() { + MediaType type = MediaType.parse("*; q=.2").iterator().next(); + assertEquals("*/*", type.name()); + } +} diff --git a/jooby/src/test/java/org/jooby/issues/Issue384.java b/jooby/src/test/java/org/jooby/issues/Issue384.java new file mode 100644 index 00000000..cd36d470 --- /dev/null +++ b/jooby/src/test/java/org/jooby/issues/Issue384.java @@ -0,0 +1,66 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.issues; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.jooby.Route; +import org.jooby.Route.Mapper; +import org.junit.Test; + +public class Issue384 { + + static class M implements Route.Mapper { + + @Override + public Object map(final Integer value) throws Throwable { + return value; + } + + } + + @Test + public void defaultRouteMapperName() { + Route.Mapper intMapper = (final Integer v) -> v * 2; + assertTrue(intMapper.name().startsWith("issue384")); + + assertEquals("m", new M().name()); + + assertTrue(new Route.Mapper() { + @Override + public Object map(final String value) throws Throwable { + return value; + }; + }.name().startsWith("issue384")); + } + + @Test + public void routeFactory() { + Mapper intMapper = Route.Mapper.create("x", (final Integer v) -> v * 2); + assertEquals("x", intMapper.name()); + assertEquals("x", intMapper.toString()); + } + + @Test + public void chain() throws Throwable { + Mapper intMapper = Route.Mapper.create("int", (final Integer v) -> v * 2); + Mapper strMapper = Route.Mapper.create("str", v -> "{" + v + "}"); + assertEquals("int>str", Route.Mapper.chain(intMapper, strMapper).name()); + assertEquals("str>int", Route.Mapper.chain(strMapper, intMapper).name()); + assertEquals(8, Route.Mapper.chain(intMapper, intMapper).map(2)); + } +} diff --git a/jooby/src/test/java/org/jooby/issues/Issue430.java b/jooby/src/test/java/org/jooby/issues/Issue430.java new file mode 100644 index 00000000..08be02ba --- /dev/null +++ b/jooby/src/test/java/org/jooby/issues/Issue430.java @@ -0,0 +1,53 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.issues; + +import org.jooby.Jooby; +import org.jooby.spi.Server; +import org.junit.Test; + +import java.util.Optional; +import java.util.concurrent.Executor; + +public class Issue430 { + + public static class NOOP implements Server { + + @Override + public void start() throws Exception { + } + + @Override + public void stop() throws Exception { + } + + @Override + public void join() throws InterruptedException { + } + + @Override + public Optional executor() { + return Optional.empty(); + } + + } + + @Test + public void customServer() throws Throwable { + new Jooby().server(NOOP.class).start(); + } + +} diff --git a/jooby/src/test/java/org/jooby/issues/Issue526u.java b/jooby/src/test/java/org/jooby/issues/Issue526u.java new file mode 100644 index 00000000..c2048f92 --- /dev/null +++ b/jooby/src/test/java/org/jooby/issues/Issue526u.java @@ -0,0 +1,91 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.issues; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.Map; +import java.util.function.Consumer; + +import org.jooby.internal.RouteMatcher; +import org.jooby.internal.RoutePattern; +import org.junit.Test; + +public class Issue526u { + + class RoutePathAssert { + + RoutePattern path; + + public RoutePathAssert(final String method, final String pattern) { + path = new RoutePattern(method, pattern); + } + + public RoutePathAssert matches(final String path) { + return matches(path, (vars) -> { + }); + } + + public RoutePathAssert matches(final String path, final Consumer> vars) { + String message = this.path + " != " + path; + RouteMatcher matcher = this.path.matcher(path); + boolean matches = matcher.matches(); + if (!matches) { + System.err.println(message); + } + assertTrue(message, matches); + vars.accept(matcher.vars()); + return this; + } + + public RoutePathAssert butNot(final String path) { + String message = this.path + " == " + path; + RouteMatcher matcher = this.path.matcher(path); + boolean matches = matcher.matches(); + if (matches) { + System.err.println(message); + } + assertFalse(message, matches); + return this; + } + } + + @Test + public void shouldAcceptAdvancedRegexPathExpression() { + new RoutePathAssert("GET", "/V{var:\\d{4,7}}") + .matches("GET/V1234", (vars) -> { + assertEquals("1234", vars.get("var")); + }) + .matches("GET/V1234567", (vars) -> { + assertEquals("1234567", vars.get("var")); + }) + .butNot("GET/V123") + .butNot("GET/V12345678"); + } + + @Test + public void shouldAcceptSpecialChars() { + new RoutePathAssert("GET", "/:var") + .matches("GET/x%252Fy%252Fz", (vars) -> { + assertEquals("x%252Fy%252Fz", vars.get("var")); + }) + .butNot("GET/user/123/x") + .butNot("GET/user/123x") + .butNot("GET/user/xqi"); + } +} diff --git a/jooby/src/test/java/org/jooby/issues/Issue576.java b/jooby/src/test/java/org/jooby/issues/Issue576.java new file mode 100644 index 00000000..c44ab0ef --- /dev/null +++ b/jooby/src/test/java/org/jooby/issues/Issue576.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.issues; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +import org.jooby.Err; +import org.jooby.Jooby; +import org.jooby.Status; +import org.junit.Test; + +public class Issue576 { + + @Test + public void shouldThrowBootstrapException() { + IllegalStateException ies = new IllegalStateException("boot err"); + try { + new Jooby() { + { + throwBootstrapException(); + + onStart(() -> { + throw ies; + }); + } + }.start(); + fail(); + } catch (Err err) { + assertEquals(Status.SERVICE_UNAVAILABLE.value(), err.statusCode()); + assertEquals(ies, err.getCause()); + } + } + +} diff --git a/jooby/src/test/java/org/jooby/issues/Issue649.java b/jooby/src/test/java/org/jooby/issues/Issue649.java new file mode 100644 index 00000000..b21ce60c --- /dev/null +++ b/jooby/src/test/java/org/jooby/issues/Issue649.java @@ -0,0 +1,31 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.issues; + +import org.jooby.Cookie; +import org.junit.Test; + +import static org.junit.Assert.assertTrue; + +public class Issue649 { + + @Test + public void emptyCookie() { + assertTrue(Cookie.URL_DECODER.apply("foo=").isEmpty()); + assertTrue(Cookie.URL_DECODER.apply("foo").isEmpty()); + assertTrue(Cookie.URL_DECODER.apply(null).isEmpty()); + } +} diff --git a/jooby/src/test/java/org/jooby/servlet/ServletContainerTest.java b/jooby/src/test/java/org/jooby/servlet/ServletContainerTest.java new file mode 100644 index 00000000..fe043f09 --- /dev/null +++ b/jooby/src/test/java/org/jooby/servlet/ServletContainerTest.java @@ -0,0 +1,43 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.servlet; + +import static org.junit.Assert.assertFalse; + +import org.junit.Test; + +public class ServletContainerTest { + + @Test + public void start() throws Exception { + ServletContainer.NOOP.start(); + } + + @Test + public void stop() throws Exception { + ServletContainer.NOOP.stop(); + } + + @Test + public void join() throws Exception { + ServletContainer.NOOP.join(); + } + + @Test + public void excutor() throws Exception { + assertFalse(ServletContainer.NOOP.executor().isPresent()); + } +} diff --git a/jooby/src/test/java/org/jooby/servlet/WebXmlTest.java b/jooby/src/test/java/org/jooby/servlet/WebXmlTest.java new file mode 100644 index 00000000..6438fb23 --- /dev/null +++ b/jooby/src/test/java/org/jooby/servlet/WebXmlTest.java @@ -0,0 +1,73 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.servlet; + +import static org.junit.Assert.assertEquals; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; + +import org.junit.Test; + +import com.google.common.io.CharStreams; + +public class WebXmlTest { + + String body = "\n" + + + "\n" + + " \n" + + " application.class\n" + + " ${application.class}\n" + + " \n" + + "\n" + + " \n" + + " %s\n" + + " \n" + + "\n" + + " \n" + + " jooby\n" + + " %s\n" + + " 0\n" + + " \n" + + " \n" + + " 0\n" + + " ${war.maxRequestSize}\n" + + " \n" + + " \n" + + "\n" + + " \n" + + " jooby\n" + + " /*\n" + + " \n" + + "\n"; + + @Test + public void webXmlMustHaveServletDefinition() throws IOException { + InputStream in = getClass().getResourceAsStream("/WEB-INF/web.xml"); + String webxml = CharStreams.toString(new InputStreamReader(in)); + in.close(); + + assertEquals( + String.format(body, ServerInitializer.class.getName(), ServletHandler.class.getName()), + webxml); + } +} diff --git a/jooby/src/test/java/org/jooby/test/MockUnit.java b/jooby/src/test/java/org/jooby/test/MockUnit.java new file mode 100644 index 00000000..daa9bf2f --- /dev/null +++ b/jooby/src/test/java/org/jooby/test/MockUnit.java @@ -0,0 +1,284 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.test; + +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.Multimap; +import static java.util.Objects.requireNonNull; +import org.jooby.funzy.Try; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Utility test class for mocks. Internal use only. + * + * Rewritten from EasyMock+PowerMock to pure Mockito 5. + * See jooby/1-7-easymock-migration.md for migration details. + * + * @author edgar + */ +@SuppressWarnings({"rawtypes", "unchecked"}) +public class MockUnit { + + public class ConstructorBuilder { + + private Class type; + + public ConstructorBuilder(final Class type) { + this.type = type; + } + + public T build(final Object... args) { + T mock = Mockito.mock(type); + constructorPreMocks.computeIfAbsent(type, k -> new ArrayList<>()).add(mock); + return mock; + } + + public ConstructorBuilder args(final Class... types) { + // Argument types are not needed for Mockito's mockConstruction + return this; + } + + } + + public interface Block { + + void run(MockUnit unit) throws Throwable; + + } + + private List mocks = new LinkedList<>(); + + private Multimap globalMock = ArrayListMultimap.create(); + + private Map>> captures = new LinkedHashMap<>(); + + // Static mocks: type → MockedStatic (opened during expect block execution) + private Map staticMocks = new LinkedHashMap<>(); + + // Constructor mocks: type → list of pre-configured mocks (in order) + private Map> constructorPreMocks = new LinkedHashMap<>(); + + // Opened MockedConstruction instances (closed in run()) + private List> constructionMocks = new LinkedList<>(); + + // Maps constructed mock → pre-configured mock (for delegation) + private Map mockToPreMock = new IdentityHashMap<>(); + + private List blocks = new LinkedList<>(); + + public MockUnit(final Class... types) { + this(false, types); + } + + public MockUnit(final boolean strict, final Class... types) { + Arrays.stream(types).forEach(this::registerMock); + } + + public T capture(final Class type) { + ArgumentCaptor captor = ArgumentCaptor.forClass(Object.class); + captures.computeIfAbsent(type, k -> new ArrayList<>()).add(captor); + return (T) captor.capture(); + } + + public List captured(final Class type) { + List> captorList = this.captures.get(type); + List result = new LinkedList<>(); + if (captorList != null) { + captorList.forEach(c -> { + try { + result.add((T) c.getValue()); + } catch (Exception ignored) { + // captor not yet captured + } + }); + } + return result; + } + + public MockedStatic mockStatic(final Class type) { + MockedStatic ms = (MockedStatic) staticMocks.get(type); + if (ms == null) { + ms = Mockito.mockStatic(type); + staticMocks.put(type, ms); + } + return ms; + } + + public MockedStatic mockStaticPartial(final Class type, final String... names) { + // Mockito mockStatic mocks all static methods; callers stub the specific ones they need + return mockStatic(type); + } + + public T partialMock(final Class type, final String... methods) { + // Mockito doesn't have direct partial mock equivalent; + // use spy() for real-method-by-default or mock() for mock-by-default + T mock = Mockito.mock(type, Mockito.CALLS_REAL_METHODS); + mocks.add(mock); + return mock; + } + + public T partialMock(final Class type, final String method, final Class firstArg) { + return partialMock(type, method); + } + + public T partialMock(final Class type, final String method, final Class t1, + final Class t2) { + return partialMock(type, method); + } + + public T mock(final Class type) { + return mock(type, false); + } + + public T powerMock(final Class type) { + // Mockito 5 inline mock maker handles final classes natively + return mock(type); + } + + public T mock(final Class type, final boolean strict) { + // Mockito doesn't distinguish strict/lenient at creation time + T mock = Mockito.mock(type); + mocks.add(mock); + return mock; + } + + public T registerMock(final Class type) { + T mock = mock(type); + globalMock.put(type, mock); + return mock; + } + + public T registerMock(final Class type, final T mock) { + globalMock.put(type, mock); + return mock; + } + + public T get(final Class type) { + try { + List collection = (List) requireNonNull(globalMock.get(type)); + return (T) collection.get(collection.size() - 1); + } catch (ArrayIndexOutOfBoundsException ex) { + throw new IllegalStateException("Not found: " + type); + } + } + + public T first(final Class type) { + List collection = (List) requireNonNull(globalMock.get(type), + "Mock not found: " + type); + return (T) collection.get(0); + } + + public MockUnit expect(final Block block) { + blocks.add(requireNonNull(block, "A block is required.")); + return this; + } + + public MockUnit run(final Block block) throws Exception { + return run(new Block[]{block}); + } + + public MockUnit run(final Block... blocks) throws Exception { + try { + // 1. Execute expect blocks (configures stubs — active immediately in Mockito) + for (Block block : this.blocks) { + Try.run(() -> block.run(this)) + .throwException(); + } + + // 2. Open MockedConstruction for all registered constructor types + openConstructionMocks(); + + // 3. Execute test blocks + for (Block main : blocks) { + Try.run(() -> main.run(this)).throwException(); + } + } finally { + // 4. Close all scoped mocks (MockedStatic, MockedConstruction) + closeAll(); + } + + return this; + } + + public T mockConstructor(final Class type, final Class[] paramTypes, + final Object... args) { + T mock = Mockito.mock(type); + constructorPreMocks.computeIfAbsent(type, k -> new ArrayList<>()).add(mock); + return mock; + } + + public T mockConstructor(final Class type, final Object... args) { + return mockConstructor(type, null, args); + } + + public ConstructorBuilder constructor(final Class type) { + return new ConstructorBuilder<>(type); + } + + private void openConstructionMocks() { + for (Map.Entry> entry : constructorPreMocks.entrySet()) { + Class type = entry.getKey(); + List preMocks = entry.getValue(); + AtomicInteger counter = new AtomicInteger(0); + + MockedConstruction mc = Mockito.mockConstruction(type, + Mockito.withSettings().defaultAnswer(invocation -> { + Object preMock = mockToPreMock.get(invocation.getMock()); + if (preMock != null) { + try { + return invocation.getMethod().invoke(preMock, invocation.getArguments()); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } + return Mockito.RETURNS_DEFAULTS.answer(invocation); + }), + (mock, context) -> { + int i = counter.getAndIncrement(); + if (i < preMocks.size()) { + mockToPreMock.put(mock, preMocks.get(i)); + } + }); + constructionMocks.add(mc); + } + } + + private void closeAll() { + for (MockedConstruction mc : constructionMocks) { + mc.close(); + } + constructionMocks.clear(); + + for (MockedStatic ms : staticMocks.values()) { + ms.close(); + } + staticMocks.clear(); + } + +} diff --git a/jooby/src/test/java/org/jooby/test/OnServer.java b/jooby/src/test/java/org/jooby/test/OnServer.java new file mode 100644 index 00000000..30dec741 --- /dev/null +++ b/jooby/src/test/java/org/jooby/test/OnServer.java @@ -0,0 +1,32 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.test; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Internal use only. + * + * @author edgar + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface OnServer { + Class[] value(); +} diff --git a/jooby/src/test/java/org/jooby/util/ProvidersTest.java b/jooby/src/test/java/org/jooby/util/ProvidersTest.java new file mode 100644 index 00000000..f98b2e87 --- /dev/null +++ b/jooby/src/test/java/org/jooby/util/ProvidersTest.java @@ -0,0 +1,35 @@ +/* + * Copyright 2026 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.jooby.util; + +import org.jooby.scope.Providers; +import org.junit.Test; + +import com.google.inject.OutOfScopeException; + +public class ProvidersTest { + + @Test + public void defaults() { + new Providers(); + } + + @Test(expected = OutOfScopeException.class) + public void outOfScope() { + Providers.outOfScope(ProvidersTest.class).get(); + } + +} diff --git a/jooby/src/test/resources/logback.xml b/jooby/src/test/resources/logback.xml new file mode 100644 index 00000000..b3901e30 --- /dev/null +++ b/jooby/src/test/resources/logback.xml @@ -0,0 +1,12 @@ + + + + + %-5p [%d{ISO8601}] [%thread] %msg%n + + + + + + + diff --git a/jooby/src/test/resources/org/jooby/JoobyTest.conf b/jooby/src/test/resources/org/jooby/JoobyTest.conf new file mode 100644 index 00000000..e69de29b diff --git a/jooby/src/test/resources/org/jooby/JoobyTest.dev.conf b/jooby/src/test/resources/org/jooby/JoobyTest.dev.conf new file mode 100644 index 00000000..4ffcd21a --- /dev/null +++ b/jooby/src/test/resources/org/jooby/JoobyTest.dev.conf @@ -0,0 +1,5 @@ +list = [1, 2, 3] + +application.tz = America/Argentina/Buenos_Aires +application.lang = es-ar +application.numberFormat = "#,##0.###" \ No newline at end of file diff --git a/jooby/src/test/resources/org/jooby/JoobyTest.js b/jooby/src/test/resources/org/jooby/JoobyTest.js new file mode 100644 index 00000000..d749bf03 --- /dev/null +++ b/jooby/src/test/resources/org/jooby/JoobyTest.js @@ -0,0 +1 @@ +(function () {})(); diff --git a/jooby/src/test/resources/org/jooby/ResponseTest.js b/jooby/src/test/resources/org/jooby/ResponseTest.js new file mode 100644 index 00000000..d749bf03 --- /dev/null +++ b/jooby/src/test/resources/org/jooby/ResponseTest.js @@ -0,0 +1 @@ +(function () {})(); diff --git a/jooby/src/test/resources/org/jooby/internal/FileAssetTest.js b/jooby/src/test/resources/org/jooby/internal/FileAssetTest.js new file mode 100644 index 00000000..6c532730 --- /dev/null +++ b/jooby/src/test/resources/org/jooby/internal/FileAssetTest.js @@ -0,0 +1 @@ +function () {} diff --git a/jooby/src/test/resources/org/jooby/internal/RouteMetadataTest$Mvc.bc b/jooby/src/test/resources/org/jooby/internal/RouteMetadataTest$Mvc.bc new file mode 100644 index 00000000..43b57457 Binary files /dev/null and b/jooby/src/test/resources/org/jooby/internal/RouteMetadataTest$Mvc.bc differ diff --git a/jooby/src/test/resources/org/jooby/internal/URLAssetTest.js b/jooby/src/test/resources/org/jooby/internal/URLAssetTest.js new file mode 100644 index 00000000..6c532730 --- /dev/null +++ b/jooby/src/test/resources/org/jooby/internal/URLAssetTest.js @@ -0,0 +1 @@ +function () {} diff --git a/pom.xml b/pom.xml index 703511eb..91a03767 100644 --- a/pom.xml +++ b/pom.xml @@ -49,6 +49,7 @@ skeleton xmlloader automaton + jooby metrics metrics-api utils @@ -139,6 +140,11 @@ killbill-jdbi ${project.version} + + org.kill-bill.commons + killbill-jooby + ${project.version} + org.kill-bill.commons killbill-locker