res = pair.request().path("scheme")
+ .queryParam("foo","f1")
+ .GET().asString();
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("scheme: http");
+ }
+
+}
diff --git a/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/TestPair.java b/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/TestPair.java
new file mode 100644
index 00000000..63cafbb7
--- /dev/null
+++ b/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/TestPair.java
@@ -0,0 +1,64 @@
+package io.avaje.jex.grizzly;
+
+import io.avaje.http.client.HttpClientContext;
+import io.avaje.http.client.HttpClientRequest;
+import io.avaje.http.client.JacksonBodyAdapter;
+import io.avaje.jex.Jex;
+
+import java.net.http.HttpClient;
+import java.util.Random;
+
+/**
+ * Server and Client pair for a test.
+ */
+public class TestPair {
+
+ private final int port;
+
+ private final Jex.Server server;
+
+ private final HttpClientContext client;
+
+ public TestPair(int port, Jex.Server server, HttpClientContext client) {
+ this.port = port;
+ this.server = server;
+ this.client = client;
+ }
+
+ public void shutdown() {
+ server.shutdown();
+ }
+
+ public HttpClientRequest request() {
+ return client.request();
+ }
+
+ public int port() {
+ return port;
+ }
+
+ public String url() {
+ return client.url().build();
+ }
+
+ public static TestPair create(Jex app) {
+ int port = 10000 + new Random().nextInt(1000);
+ return create(app, port);
+ }
+
+ /**
+ * Create a Server and Client pair for a given set of tests.
+ */
+ public static TestPair create(Jex app, int port) {
+ var jexServer = app.port(port).start();
+
+ var url = "http://localhost:" + port;
+ var client = HttpClientContext.newBuilder()
+ .baseUrl(url)
+ .bodyAdapter(new JacksonBodyAdapter())
+ .version(HttpClient.Version.HTTP_1_1)
+ .build();
+
+ return new TestPair(port, jexServer, client);
+ }
+}
diff --git a/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/VanillaMain.java b/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/VanillaMain.java
new file mode 100644
index 00000000..004d9091
--- /dev/null
+++ b/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/VanillaMain.java
@@ -0,0 +1,42 @@
+package io.avaje.jex.grizzly;
+
+import org.glassfish.grizzly.http.server.*;
+
+import java.io.File;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+
+class VanillaMain {
+
+ public static void main(String[] args) throws IOException, InterruptedException {
+
+ File dir = new File(".");
+ System.out.println("workingDirectory" + dir.getAbsolutePath());
+
+ CLStaticHttpHandler clStaticHttpHandler = new CLStaticHttpHandler(VanillaMain.class.getClassLoader(), "/myres/");
+ StaticHttpHandler staticHttpHandler = new StaticHttpHandler();
+
+ final HttpServer httpServer = new HttpServerBuilder()
+ .handler(clStaticHttpHandler, "cl")
+ .handler(staticHttpHandler, "static")
+ .handler(new MyHandler())
+ .build();
+
+ httpServer.start();
+ Thread.currentThread().join();
+ }
+
+ static class MyHandler extends HttpHandler {
+
+ @Override
+ public void service(Request request, Response response) throws Exception {
+ final SimpleDateFormat format = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US);
+ final String date = format.format(new Date(System.currentTimeMillis()));
+ response.setContentType("text/plain");
+ response.setContentLength(date.length());
+ response.getWriter().write(date);
+ }
+ }
+}
diff --git a/avaje-jex-grizzly/src/test/resources/logback-test.xml b/avaje-jex-grizzly/src/test/resources/logback-test.xml
new file mode 100644
index 00000000..ddb21350
--- /dev/null
+++ b/avaje-jex-grizzly/src/test/resources/logback-test.xml
@@ -0,0 +1,19 @@
+
+
+
+ TRACE
+
+
+ %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/avaje-jex-grizzly/src/test/resources/myres/hello.txt b/avaje-jex-grizzly/src/test/resources/myres/hello.txt
new file mode 100644
index 00000000..d6613f5f
--- /dev/null
+++ b/avaje-jex-grizzly/src/test/resources/myres/hello.txt
@@ -0,0 +1 @@
+Hello there
diff --git a/avaje-jex-jdk/pom.xml b/avaje-jex-jdk/pom.xml
new file mode 100644
index 00000000..f3b8c3ac
--- /dev/null
+++ b/avaje-jex-jdk/pom.xml
@@ -0,0 +1,44 @@
+
+
+
+ avaje-jex-parent
+ io.avaje
+ 1.8-SNAPSHOT
+
+ 4.0.0
+
+ avaje-jex-jdk
+
+
+ 11
+ 11
+
+
+
+
+
+ io.avaje
+ avaje-jex
+ 1.8-SNAPSHOT
+
+
+
+
+
+
+
+ maven-surefire-plugin
+
+
+ --add-modules com.fasterxml.jackson.databind
+ --add-opens io.avaje.jex.jdk/io.avaje.jex.jdk=com.fasterxml.jackson.databind
+ --add-opens io.avaje.jex.jdk/io.avaje.jex.jdk=ALL-UNNAMED
+
+
+
+
+
+
+
diff --git a/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/BaseHandler.java b/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/BaseHandler.java
new file mode 100644
index 00000000..fcbf7c6e
--- /dev/null
+++ b/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/BaseHandler.java
@@ -0,0 +1,110 @@
+package io.avaje.jex.jdk;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpHandler;
+import io.avaje.jex.Context;
+import io.avaje.jex.Routing;
+import io.avaje.jex.http.NotFoundResponse;
+import io.avaje.jex.spi.SpiContext;
+import io.avaje.jex.spi.SpiRoutes;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.concurrent.locks.LockSupport;
+
+class BaseHandler implements HttpHandler {
+
+ private static final Logger log = LoggerFactory.getLogger(BaseHandler.class);
+
+ private final SpiRoutes routes;
+ private final ServiceManager mgr;
+
+ BaseHandler(SpiRoutes routes, ServiceManager mgr) {
+ this.mgr = mgr;
+ this.routes = routes;
+ }
+
+ /**
+ * Wait based routes.activeRequests().
+ */
+ void waitForIdle(long maxSeconds) {
+ final long maxAttempts = maxSeconds * 10; // 100 millis per attempt
+ long attempts = 0;
+ while ((routes.activeRequests()) > 0 && ++attempts < maxAttempts) {
+ LockSupport.parkNanos(100_000_000);
+ }
+ if (attempts >= maxAttempts) {
+ final long active = routes.activeRequests();
+ if (active > 0) {
+ log.warn("Active requests since in process - count:{}", active);
+ }
+ }
+ }
+
+ @Override
+ public void handle(HttpExchange exchange) {
+
+ final String uri = exchange.getRequestURI().getPath();
+ final Routing.Type routeType = mgr.lookupRoutingType(exchange.getRequestMethod());
+ final SpiRoutes.Entry route = routes.match(routeType, uri);
+
+ if (route == null) {
+ var ctx = new JdkContext(mgr, exchange, uri);
+ routes.inc();
+ try {
+ processNoRoute(ctx, uri, routeType);
+ routes.after(uri, ctx);
+ } catch (Exception e) {
+ handleException(ctx, e);
+ } finally {
+ routes.dec();
+ }
+ } else {
+ route.inc();
+ try {
+ final SpiRoutes.Params params = route.pathParams(uri);
+ JdkContext ctx = new JdkContext(mgr, exchange, route.matchPath(), params);
+ try {
+ processRoute(ctx, uri, route);
+ routes.after(uri, ctx);
+ } catch (Exception e) {
+ handleException(ctx, e);
+ }
+ } finally {
+ route.dec();
+ }
+ }
+ }
+
+ private void handleException(SpiContext ctx, Exception e) {
+ mgr.handleException(ctx, e);
+ }
+
+ private void processRoute(JdkContext ctx, String uri, SpiRoutes.Entry route) {
+ routes.before(uri, ctx);
+ ctx.setMode(null);
+ route.handle(ctx);
+ }
+
+ private void processNoRoute(JdkContext ctx, String uri, Routing.Type routeType) {
+ routes.before(uri, ctx);
+ if (routeType == Routing.Type.HEAD && hasGetHandler(uri)) {
+ processHead(ctx);
+ return;
+ }
+// if (routeType == Routing.Type.GET || routeType == Routing.Type.HEAD) {
+// // check if handled by static resource
+// // check if handled by singlePageHandler
+// }
+ throw new NotFoundResponse("uri: " + uri);
+ }
+
+ private void processHead(Context ctx) {
+ ctx.status(200);
+ }
+
+ private boolean hasGetHandler(String uri) {
+ return routes.match(Routing.Type.GET, uri) != null;
+ }
+
+}
diff --git a/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/BufferedOutStream.java b/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/BufferedOutStream.java
new file mode 100644
index 00000000..cc7065f6
--- /dev/null
+++ b/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/BufferedOutStream.java
@@ -0,0 +1,56 @@
+package io.avaje.jex.jdk;
+
+import com.sun.net.httpserver.HttpExchange;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+
+class BufferedOutStream extends OutputStream {
+
+ private final JdkContext context;
+ private final long max;
+ private ByteArrayOutputStream buffer;
+ private OutputStream stream;
+ private long count;
+
+ BufferedOutStream(JdkContext context, long max, int bufferSize) {
+ this.context = context;
+ this.max = max;
+ this.buffer = new ByteArrayOutputStream(bufferSize);
+ }
+
+ @Override
+ public void write(int b) throws IOException {
+ if (stream != null) {
+ stream.write(b);
+ } else {
+ buffer.write(b);
+ if (count++ > max) {
+ initialiseChunked();
+ }
+ }
+ }
+
+ /**
+ * Use responseLength 0 and chunked response.
+ */
+ private void initialiseChunked() throws IOException {
+ final HttpExchange exchange = context.exchange();
+ exchange.sendResponseHeaders(context.statusCode(), 0);
+ stream = exchange.getResponseBody();
+ // empty the existing buffer
+ stream.write(buffer.toByteArray());
+ buffer = null;
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (stream != null) {
+ stream.flush();
+ stream.close();
+ } else {
+ context.writeBytes(buffer.toByteArray());
+ }
+ }
+}
diff --git a/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/CookieParser.java b/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/CookieParser.java
new file mode 100644
index 00000000..9948fb26
--- /dev/null
+++ b/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/CookieParser.java
@@ -0,0 +1,130 @@
+package io.avaje.jex.jdk;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static java.util.Collections.emptyMap;
+
+/**
+ * Parse cookies based on RFC6265 skipping parameters.
+ */
+class CookieParser {
+
+ private static final String QUOTE = "\"";
+ private static final char[] QUOTE_CHARS = QUOTE.toCharArray();
+
+ private CookieParser() {
+ }
+
+ private static final String RFC2965_VERSION = "$Version";
+ private static final String RFC2965_PATH = "$Path";
+ private static final String RFC2965_DOMAIN = "$Domain";
+ private static final String RFC2965_PORT = "$Port";
+
+ /**
+ * Parse cookies based on RFC6265 skipping parameters.
+ *
+ * This does not support cookies with multiple values.
+ *
+ * @param rawHeader a value of '{@code Cookie:}' header.
+ */
+ public static Map parse(String rawHeader) {
+ if (rawHeader == null) {
+ return emptyMap();
+ }
+ rawHeader = rawHeader.trim();
+ if (rawHeader.isEmpty()) {
+ return emptyMap();
+ }
+
+ // Beware RFC2965
+ boolean isRfc2965 = false;
+ if (rawHeader.regionMatches(true, 0, RFC2965_VERSION, 0, RFC2965_VERSION.length())) {
+ isRfc2965 = true;
+ int ind = rawHeader.indexOf(';');
+ if (ind < 0) {
+ return emptyMap();
+ } else {
+ rawHeader = rawHeader.substring(ind + 1);
+ }
+ }
+
+ Map map = new LinkedHashMap<>();
+ for (String baseToken : tokenize(',', rawHeader)) {
+ for (String token : tokenize(';', baseToken)) {
+ int eqInd = token.indexOf('=');
+ if (eqInd > 0) {
+ String name = token.substring(0, eqInd).trim();
+ if (name.isEmpty()) {
+ continue; // Name MOST NOT be empty;
+ }
+ if (isRfc2965 && name.charAt(0) == '$' && ignore(name)) {
+ continue; // Skip RFC2965 attributes
+ }
+ final String value = unwrap(token.substring(eqInd + 1).trim());
+ if (!value.isEmpty()) {
+ map.put(name, value);
+ }
+ }
+ }
+ }
+ return map;
+ }
+
+ private static boolean ignore(String name) {
+ return (RFC2965_PATH.equalsIgnoreCase(name) || RFC2965_DOMAIN.equalsIgnoreCase(name)
+ || RFC2965_PORT.equalsIgnoreCase(name) || RFC2965_VERSION.equalsIgnoreCase(name));
+ }
+
+
+ /**
+ * Unwrap double-quotes if present.
+ */
+ private static String unwrap(String value) {
+ if (value.length() >= 2 && '"' == value.charAt(0) && '"' == value.charAt(value.length() - 1)) {
+ return value.substring(1, value.length() - 1);
+ }
+ return value;
+ }
+
+ /**
+ * Tokenize with quoted sub-sequences.
+ */
+ static List tokenize(char separator, String text) {
+ StringBuilder token = new StringBuilder();
+ List result = new ArrayList<>();
+ boolean quoted = false;
+ char lastQuoteCharacter = ' ';
+ for (int i = 0; i < text.length(); i++) {
+ char ch = text.charAt(i);
+ if (quoted) {
+ if (ch == lastQuoteCharacter) {
+ quoted = false;
+ }
+ token.append(ch);
+ } else {
+ if (ch == separator) {
+ if (token.length() > 0) {
+ result.add(token.toString());
+ }
+ token.setLength(0);
+ } else {
+ for (char quote : CookieParser.QUOTE_CHARS) {
+ if (ch == quote) {
+ quoted = true;
+ lastQuoteCharacter = ch;
+ break;
+ }
+ }
+ token.append(ch);
+ }
+ }
+ }
+ if (token.length() > 0) {
+ result.add(token.toString());
+ }
+ return result;
+ }
+}
diff --git a/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/JdkContext.java b/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/JdkContext.java
new file mode 100644
index 00000000..21a262d3
--- /dev/null
+++ b/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/JdkContext.java
@@ -0,0 +1,495 @@
+package io.avaje.jex.jdk;
+
+import com.sun.net.httpserver.Headers;
+import com.sun.net.httpserver.HttpExchange;
+import io.avaje.jex.Context;
+import io.avaje.jex.Routing;
+import io.avaje.jex.UploadedFile;
+import io.avaje.jex.http.RedirectResponse;
+import io.avaje.jex.spi.HeaderKeys;
+import io.avaje.jex.spi.SpiContext;
+import io.avaje.jex.spi.SpiRoutes;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UncheckedIOException;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import static java.util.Collections.emptyList;
+import static java.util.Collections.emptyMap;
+
+class JdkContext implements Context, SpiContext {
+
+ private static final String UTF8 = "UTF8";
+ private static final int SC_MOVED_TEMPORARILY = 302;
+ private static final String SET_COOKIE = "Set-Cookie";
+ private static final String COOKIE = "Cookie";
+ private final ServiceManager mgr;
+ private final String path;
+ private final SpiRoutes.Params params;
+ private final HttpExchange exchange;
+ private Routing.Type mode;
+ private Map> formParams;
+ private Map> queryParams;
+ private Map cookieMap;
+ private int statusCode;
+ private String characterEncoding;
+
+ JdkContext(ServiceManager mgr, HttpExchange exchange, String path, SpiRoutes.Params params) {
+ this.mgr = mgr;
+ this.exchange = exchange;
+ this.path = path;
+ this.params = params;
+ }
+
+ /**
+ * Create when no route matched.
+ */
+ JdkContext(ServiceManager mgr, HttpExchange exchange, String path) {
+ this.mgr = mgr;
+ this.exchange = exchange;
+ this.path = path;
+ this.params = null;
+ }
+
+ @Override
+ public String matchedPath() {
+ return path;
+ }
+
+ @Override
+ public Context attribute(String key, Object value) {
+ exchange.setAttribute(key, value);
+ return this;
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public T attribute(String key) {
+ return (T) exchange.getAttribute(key);
+ }
+
+ @Override
+ public Map attributeMap() {
+ throw new UnsupportedOperationException();
+ }
+
+ private Map parseCookies() {
+ final String cookieHeader = header(exchange.getRequestHeaders(), COOKIE);
+ if (cookieHeader == null || cookieHeader.isEmpty()) {
+ return emptyMap();
+ }
+ return CookieParser.parse(cookieHeader);
+ }
+
+ @Override
+ public Map cookieMap() {
+ if (cookieMap == null) {
+ cookieMap = parseCookies();
+ }
+ return cookieMap;
+ }
+
+ @Override
+ public String cookie(String name) {
+ return cookieMap().get(name);
+ }
+
+ @Override
+ public Context cookie(Cookie cookie) {
+ header(SET_COOKIE, cookie.toString());
+ return this;
+ }
+
+ @Override
+ public Context cookie(String name, String value) {
+ header(SET_COOKIE, Cookie.of(name, value).toString());
+ return this;
+ }
+
+ @Override
+ public Context cookie(String name, String value, int maxAge) {
+ header(SET_COOKIE, Cookie.of(name, value).maxAge(Duration.ofSeconds(maxAge)).toString());
+ return this;
+ }
+
+ @Override
+ public Context removeCookie(String name) {
+ header(SET_COOKIE, Cookie.expired(name).path("/").toString());
+ return this;
+ }
+
+ @Override
+ public Context removeCookie(String name, String path) {
+ header(SET_COOKIE, Cookie.expired(name).path(path).toString());
+ return this;
+ }
+
+ @Override
+ public void redirect(String location) {
+ redirect(location, SC_MOVED_TEMPORARILY);
+ }
+
+ @Override
+ public void redirect(String location, int statusCode) {
+ header(HeaderKeys.LOCATION, location);
+ status(statusCode);
+ if (mode == Routing.Type.BEFORE) {
+ throw new RedirectResponse(statusCode);
+ } else {
+ performRedirect();
+ }
+ }
+
+ @Override
+ public void performRedirect() {
+ try {
+ exchange.sendResponseHeaders(statusCode(), 0);
+ exchange.getResponseBody().close();
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ @Override
+ public T bodyAsClass(Class beanType) {
+ return mgr.jsonRead(beanType, this);
+ }
+
+ @Override
+ public byte[] bodyAsBytes() {
+ try {
+ return exchange.getRequestBody().readAllBytes();
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ private String characterEncoding() {
+ if (characterEncoding == null) {
+ characterEncoding = mgr.requestCharset(this);
+ }
+ return characterEncoding;
+ }
+
+ @Override
+ public String body() {
+ return new String(bodyAsBytes(), Charset.forName(characterEncoding()));
+ }
+
+ @Override
+ public long contentLength() {
+ final String len = header(HeaderKeys.CONTENT_LENGTH);
+ return len == null ? 0 : Long.parseLong(len);
+ }
+
+ @Override
+ public String contentType() {
+ return header(exchange.getRequestHeaders(), HeaderKeys.CONTENT_TYPE);
+ }
+
+ @Override
+ public String responseHeader(String key) {
+ return header(exchange.getResponseHeaders(), key);
+ }
+
+ private String header(Headers headers, String name) {
+ final List values = headers.get(name);
+ return (values == null || values.isEmpty()) ? null : values.get(0);
+ }
+
+ @Override
+ public Context contentType(String contentType) {
+ exchange.getResponseHeaders().set(HeaderKeys.CONTENT_TYPE, contentType);
+ return this;
+ }
+
+ @Override
+ public String splat(int position) {
+ return params.splats.get(position);
+ }
+
+ @Override
+ public List splats() {
+ return params.splats;
+ }
+
+ @Override
+ public Map pathParamMap() {
+ return params.pathParams;
+ }
+
+ @Override
+ public String pathParam(String name) {
+ return params.pathParams.get(name);
+ }
+
+ @Override
+ public String queryParam(String name) {
+ final List vals = queryParams(name);
+ return vals == null || vals.isEmpty() ? null : vals.get(0);
+ }
+
+ private Map> queryParams() {
+ if (queryParams == null) {
+ queryParams = mgr.parseParamMap(queryString(), UTF8);
+ }
+ return queryParams;
+ }
+
+ @Override
+ public List queryParams(String name) {
+ final List vals = queryParams().get(name);
+ return vals == null ? emptyList() : vals;
+ }
+
+ @Override
+ public Map queryParamMap() {
+ final Map> map = queryParams();
+ if (map.isEmpty()) {
+ return emptyMap();
+ }
+ final Map single = new LinkedHashMap<>();
+ for (Map.Entry> entry : map.entrySet()) {
+ final List value = entry.getValue();
+ if (value != null && !value.isEmpty()) {
+ single.put(entry.getKey(), value.get(0));
+ }
+ }
+ return single;
+ }
+
+ @Override
+ public String queryString() {
+ return exchange.getRequestURI().getQuery();
+ }
+
+ @Override
+ public Map> formParamMap() {
+ if (formParams == null) {
+ formParams = initFormParamMap();
+ }
+ return formParams;
+ }
+
+ private Map> initFormParamMap() {
+ return mgr.formParamMap(this, characterEncoding());
+ }
+
+ @Override
+ public String scheme() {
+ return mgr.scheme();
+ }
+
+ @Override
+ public Context sessionAttribute(String key, Object value) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public T sessionAttribute(String key) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Map sessionAttributeMap() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String url() {
+ return scheme() + "://" + host() + path;
+ }
+
+ @Override
+ public String contextPath() {
+ return mgr.contextPath();
+ }
+
+ @Override
+ public Context status(int statusCode) {
+ this.statusCode = statusCode;
+ return this;
+ }
+
+ @Override
+ public int status() {
+ return statusCode;
+ }
+
+ @Override
+ public Context json(Object bean) {
+ contentType(APPLICATION_JSON);
+ mgr.jsonWrite(bean, this);
+ return this;
+ }
+
+ @Override
+ public Context jsonStream(Stream stream) {
+ contentType(APPLICATION_X_JSON_STREAM);
+ mgr.jsonWriteStream(stream, this);
+ return this;
+ }
+
+ @Override
+ public Context jsonStream(Iterator iterator) {
+ contentType(APPLICATION_X_JSON_STREAM);
+ mgr.jsonWriteStream(iterator, this);
+ return this;
+ }
+
+ @Override
+ public Context text(String content) {
+ contentType(TEXT_PLAIN_UTF8);
+ return write(content);
+ }
+
+ @Override
+ public Context html(String content) {
+ contentType(TEXT_HTML_UTF8);
+ return write(content);
+ }
+
+ @Override
+ public Context write(String content) {
+ try {
+ writeBytes(content.getBytes(StandardCharsets.UTF_8));
+ return this;
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ void writeBytes(byte[] bytes) throws IOException {
+ exchange.sendResponseHeaders(statusCode(), bytes.length);
+ final OutputStream os = exchange.getResponseBody();
+ os.write(bytes);
+ os.flush();
+ os.close();
+ }
+
+ int statusCode() {
+ return statusCode == 0 ? 200 : statusCode;
+ }
+
+ @Override
+ public Context render(String name, Map model) {
+ mgr.render(this, name, model);
+ return this;
+ }
+
+ @Override
+ public Map headerMap() {
+ Map map = new LinkedHashMap<>();
+ for (Map.Entry> entry : exchange.getRequestHeaders().entrySet()) {
+ final List value = entry.getValue();
+ if (!value.isEmpty()) {
+ map.put(entry.getKey(), value.get(0));
+ }
+ }
+ return map;
+ }
+
+ @Override
+ public String header(String key) {
+ return header(exchange.getRequestHeaders(), key);
+ }
+
+ @Override
+ public Context header(String key, String value) {
+ exchange.getResponseHeaders().add(key, value);
+ return this;
+ }
+
+ @Override
+ public String host() {
+ return header(HeaderKeys.HOST);
+ }
+
+ @Override
+ public String ip() {
+ final InetSocketAddress remote = exchange.getRemoteAddress();
+ if (remote == null) {
+ return "";
+ }
+ InetAddress address = remote.getAddress();
+ return address == null ? remote.getHostString() : address.getHostAddress();
+ }
+
+ @Override
+ public boolean isMultipart() {
+ // not really supported
+ return false;
+ }
+
+ @Override
+ public boolean isMultipartFormData() {
+ // not really supported
+ return false;
+ }
+
+ @Override
+ public String method() {
+ return exchange.getRequestMethod();
+ }
+
+ @Override
+ public String path() {
+ return path;
+ }
+
+ @Override
+ public int port() {
+ return exchange.getLocalAddress().getPort();
+ }
+
+ @Override
+ public String protocol() {
+ return exchange.getProtocol();
+ }
+
+ @Override
+ public UploadedFile uploadedFile(String name) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public List uploadedFiles(String name) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public List uploadedFiles() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public OutputStream outputStream() {
+ return mgr.createOutputStream(this);
+ }
+
+ @Override
+ public InputStream inputStream() {
+ return exchange.getRequestBody();
+ }
+
+ @Override
+ public void setMode(Routing.Type type) {
+ this.mode = type;
+ }
+
+ HttpExchange exchange() {
+ return exchange;
+ }
+
+}
diff --git a/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/JdkJexServer.java b/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/JdkJexServer.java
new file mode 100644
index 00000000..48c6a1d8
--- /dev/null
+++ b/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/JdkJexServer.java
@@ -0,0 +1,34 @@
+package io.avaje.jex.jdk;
+
+import com.sun.net.httpserver.HttpServer;
+import io.avaje.jex.AppLifecycle;
+import io.avaje.jex.Jex;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+class JdkJexServer implements Jex.Server {
+
+ private static final Logger log = LoggerFactory.getLogger(JdkJexServer.class);
+
+ private final HttpServer server;
+ private final AppLifecycle lifecycle;
+ private final BaseHandler handler;
+
+ JdkJexServer(HttpServer server, AppLifecycle lifecycle, BaseHandler handler) {
+ this.server = server;
+ this.lifecycle = lifecycle;
+ this.handler = handler;
+ lifecycle.registerShutdownHook(this::shutdown);
+ }
+
+ @Override
+ public void shutdown() {
+ lifecycle.status(AppLifecycle.Status.STOPPING);
+ int maxWaitSeconds = 20;
+ log.debug("stopping server with maxWaitSeconds {}", maxWaitSeconds);
+ handler.waitForIdle(maxWaitSeconds);
+ server.stop(0);
+ log.info("shutdown");
+ lifecycle.status(AppLifecycle.Status.STOPPED);
+ }
+}
diff --git a/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/JdkServerStart.java b/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/JdkServerStart.java
new file mode 100644
index 00000000..4e3bbe5b
--- /dev/null
+++ b/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/JdkServerStart.java
@@ -0,0 +1,43 @@
+package io.avaje.jex.jdk;
+
+import com.sun.net.httpserver.HttpServer;
+import io.avaje.jex.AppLifecycle;
+import io.avaje.jex.Jex;
+import io.avaje.jex.spi.SpiRoutes;
+import io.avaje.jex.spi.SpiServiceManager;
+import io.avaje.jex.spi.SpiStartServer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.util.concurrent.Executor;
+
+public class JdkServerStart implements SpiStartServer {
+
+ private static final Logger log = LoggerFactory.getLogger(JdkServerStart.class);
+
+ @Override
+ public Jex.Server start(Jex jex, SpiRoutes routes, SpiServiceManager serviceManager) {
+
+ final ServiceManager manager = new ServiceManager(serviceManager, "http", "");
+ BaseHandler handler = new BaseHandler(routes, manager);
+ try {
+ final HttpServer server = HttpServer.create();
+ server.createContext("/", handler);
+ final Executor executor = jex.attribute(Executor.class);
+ if (executor != null) {
+ server.setExecutor(executor);
+ }
+ int port = jex.inner.port;
+ log.debug("starting server on port {}", port);
+ server.bind(new InetSocketAddress(port), 0);
+ server.start();
+ jex.lifecycle().status(AppLifecycle.Status.STARTED);
+ return new JdkJexServer(server, jex.lifecycle(), handler);
+
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/ServiceManager.java b/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/ServiceManager.java
new file mode 100644
index 00000000..59df313c
--- /dev/null
+++ b/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/ServiceManager.java
@@ -0,0 +1,36 @@
+package io.avaje.jex.jdk;
+
+import io.avaje.jex.spi.ProxyServiceManager;
+import io.avaje.jex.spi.SpiServiceManager;
+
+import java.io.OutputStream;
+
+class ServiceManager extends ProxyServiceManager {
+
+ private final String scheme;
+ private final String contextPath;
+ private final long outputBufferMax = 1024;
+ private final int outputBufferInitial = 256;
+
+ ServiceManager(SpiServiceManager delegate, String scheme, String contextPath) {
+ super(delegate);
+ this.scheme = scheme;
+ this.contextPath = contextPath;
+ }
+
+ OutputStream createOutputStream(JdkContext jdkContext) {
+ return new BufferedOutStream(jdkContext, outputBufferMax, outputBufferInitial);
+ }
+
+ String scheme() {
+ return scheme;
+ }
+
+ public String url(JdkContext jdkContext) {
+ return scheme+"://";
+ }
+
+ public String contextPath() {
+ return contextPath;
+ }
+}
diff --git a/avaje-jex-jdk/src/main/java/module-info.java b/avaje-jex-jdk/src/main/java/module-info.java
new file mode 100644
index 00000000..c34c352f
--- /dev/null
+++ b/avaje-jex-jdk/src/main/java/module-info.java
@@ -0,0 +1,11 @@
+import io.avaje.jex.jdk.JdkServerStart;
+import io.avaje.jex.spi.SpiStartServer;
+
+module io.avaje.jex.jdk {
+
+ requires transitive io.avaje.jex;
+ requires transitive java.net.http;
+ requires transitive jdk.httpserver;
+ requires transitive org.slf4j;
+ provides SpiStartServer with JdkServerStart;
+}
diff --git a/avaje-jex-jdk/src/main/resources/META-INF/services/io.avaje.jex.spi.SpiStartServer b/avaje-jex-jdk/src/main/resources/META-INF/services/io.avaje.jex.spi.SpiStartServer
new file mode 100644
index 00000000..79199ebe
--- /dev/null
+++ b/avaje-jex-jdk/src/main/resources/META-INF/services/io.avaje.jex.spi.SpiStartServer
@@ -0,0 +1 @@
+io.avaje.jex.jdk.JdkServerStart
diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/AutoCloseIterator.java b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/AutoCloseIterator.java
new file mode 100644
index 00000000..36ce4986
--- /dev/null
+++ b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/AutoCloseIterator.java
@@ -0,0 +1,32 @@
+package io.avaje.jex.jdk;
+
+import java.util.Iterator;
+
+public class AutoCloseIterator implements Iterator, AutoCloseable {
+
+ private final Iterator it;
+ private boolean closed;
+
+ public AutoCloseIterator(Iterator it) {
+ this.it = it;
+ }
+
+ @Override
+ public boolean hasNext() {
+ return it.hasNext();
+ }
+
+ @Override
+ public E next() {
+ return it.next();
+ }
+
+ @Override
+ public void close() {
+ closed = true;
+ }
+
+ public boolean isClosed() {
+ return closed;
+ }
+}
diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/CharacterEncodingTest.java b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/CharacterEncodingTest.java
new file mode 100644
index 00000000..3d0f7dcb
--- /dev/null
+++ b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/CharacterEncodingTest.java
@@ -0,0 +1,49 @@
+package io.avaje.jex.jdk;
+
+import io.avaje.jex.Jex;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.http.HttpResponse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class CharacterEncodingTest {
+
+ static TestPair pair = init();
+
+ static TestPair init() {
+ Jex app = Jex.create()
+ .routing(routing -> routing
+ .get("/text", ctx -> ctx.contentType("text/plain;charset=utf-8").write("суп из капусты"))
+ .get("/json", ctx -> ctx.json("白菜湯"))
+ .get("/html", ctx -> ctx.html("kålsuppe")));
+
+ return TestPair.create(app);
+ }
+
+ @AfterAll
+ static void end() {
+ pair.shutdown();
+ }
+
+ @Test
+ void get() {
+
+ var textRes = pair.request().path("text").GET().asString();
+ var jsonRes = pair.request().path("json").GET().asString();
+ var htmlRes = pair.request().path("html").GET().asString();
+
+ assertThat(contentType(jsonRes)).isEqualTo("application/json");
+ assertThat(jsonRes.body()).isEqualTo("\"白菜湯\"");
+ assertThat(contentType(htmlRes)).isEqualTo("text/html;charset=utf-8");
+ assertThat(htmlRes.body()).isEqualTo("kålsuppe");
+ assertThat(contentType(textRes)).isEqualTo("text/plain;charset=utf-8");
+ assertThat(textRes.body()).isEqualTo("суп из капусты");
+ }
+
+ private String contentType(HttpResponse res) {
+ return res.headers().firstValue("Content-Type").get();
+ }
+
+}
diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/ContextAttributeTest.java b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/ContextAttributeTest.java
new file mode 100644
index 00000000..116ff93f
--- /dev/null
+++ b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/ContextAttributeTest.java
@@ -0,0 +1,63 @@
+package io.avaje.jex.jdk;
+
+import io.avaje.jex.Jex;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.http.HttpResponse;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ContextAttributeTest {
+
+ static final UUID uuid = UUID.randomUUID();
+
+ static TestPair pair = init();
+
+ static TestPair attrPair;
+ static UUID attrUuid;
+
+ static TestPair init() {
+ var app = Jex.create()
+ .routing(routing -> routing
+ .before(ctx -> {
+ ctx.attribute("oneUuid", uuid).attribute(TestPair.class.getName(), pair);
+ })
+ .get("/", ctx -> {
+ attrUuid = ctx.attribute("oneUuid");
+ attrPair = ctx.attribute(TestPair.class.getName());
+
+ assert attrUuid == uuid;
+ assert attrPair == pair;
+
+// ctx.attributeMap() is not supported
+// final Map attrMap = ctx.attributeMap();
+// final Object mapUuid = attrMap.get("oneUuid");
+// assert mapUuid == uuid;
+//
+// final Object mapPair = attrMap.get(TestPair.class.getName());
+// assert mapPair == pair;
+ ctx.text("all-good");
+ })
+ );
+ return TestPair.create(app);
+ }
+
+ @AfterAll
+ static void end() {
+ pair.shutdown();
+ }
+
+ @Test
+ void get() {
+ HttpResponse res = pair.request().GET().asString();
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("all-good");
+
+ assertThat(attrPair).isSameAs(pair);
+ assertThat(attrUuid).isSameAs(uuid);
+ }
+
+
+}
diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/ContextFormParamTest.java b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/ContextFormParamTest.java
new file mode 100644
index 00000000..6ca5e2c0
--- /dev/null
+++ b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/ContextFormParamTest.java
@@ -0,0 +1,135 @@
+package io.avaje.jex.jdk;
+
+import io.avaje.jex.Jex;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.http.HttpResponse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ContextFormParamTest {
+
+ static TestPair pair = init();
+
+ static TestPair init() {
+ var app = Jex.create()
+ .routing(routing -> routing
+ .post("/", ctx -> ctx.text("map:" +ctx.formParamMap()))
+ .post("/formParams/{key}", ctx -> ctx.text("formParams:" + ctx.formParams(ctx.pathParam("key"))))
+ .post("/formParam/{key}", ctx -> ctx.text("formParam:" + ctx.formParam(ctx.pathParam("key"))))
+ .post("/formParamWithDefault/{key}", ctx -> ctx.text("formParam:" + ctx.formParam(ctx.pathParam("key"), "foo")))
+ );
+ return TestPair.create(app);
+ }
+
+ @AfterAll
+ static void end() {
+ pair.shutdown();
+ }
+
+ @Test
+ void formParamMap() {
+ HttpResponse res = pair.request()
+ .formParam("one", "ao")
+ .formParam("one", "bo")
+ .formParam("two", "z")
+ .POST().asString();
+
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("map:{one=[ao, bo], two=[z]}");
+ }
+
+
+ @Test
+ void formParams_one() {
+ HttpResponse res = pair.request()
+ .formParam("one", "ao")
+ .formParam("one", "bo")
+ .formParam("two", "z")
+ .path("formParams").path("one")
+ .POST().asString();
+
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("formParams:[ao, bo]");
+ }
+
+ @Test
+ void formParams_two() {
+ HttpResponse res = pair.request()
+ .formParam("one", "ao")
+ .formParam("one", "bo")
+ .formParam("two", "z")
+ .path("formParams").path("two")
+ .POST().asString();
+
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("formParams:[z]");
+ }
+
+
+ @Test
+ void formParam_null() {
+ HttpResponse res = pair.request()
+ .formParam("one", "ao")
+ .formParam("one", "bo")
+ .formParam("two", "z")
+ .path("formParam").path("doesNotExist")
+ .POST().asString();
+
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("formParam:null");
+ }
+
+ @Test
+ void formParam_first() {
+ HttpResponse res = pair.request()
+ .formParam("one", "ao")
+ .formParam("one", "bo")
+ .formParam("two", "z")
+ .path("formParam").path("one")
+ .POST().asString();
+
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("formParam:ao");
+ }
+
+ @Test
+ void formParam_default() {
+ HttpResponse res = pair.request()
+ .formParam("one", "ao")
+ .formParam("one", "bo")
+ .formParam("two", "z")
+ .path("formParamWithDefault").path("doesNotExist")
+ .POST().asString();
+
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("formParam:foo");
+ }
+
+ @Test
+ void formParam_default_first() {
+ HttpResponse res = pair.request()
+ .formParam("one", "ao")
+ .formParam("one", "bo")
+ .formParam("two", "z")
+ .path("formParamWithDefault").path("one")
+ .POST().asString();
+
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("formParam:ao");
+ }
+
+ @Test
+ void formParam_default_only() {
+ HttpResponse res = pair.request()
+ .formParam("one", "ao")
+ .formParam("one", "bo")
+ .formParam("two", "z")
+ .path("formParamWithDefault").path("two")
+ .POST().asString();
+
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("formParam:z");
+ }
+}
diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/ContextLengthTest.java b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/ContextLengthTest.java
new file mode 100644
index 00000000..f817711a
--- /dev/null
+++ b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/ContextLengthTest.java
@@ -0,0 +1,106 @@
+package io.avaje.jex.jdk;
+
+import io.avaje.jex.Jex;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.http.HttpResponse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ContextLengthTest {
+
+ static TestPair pair = init();
+
+ static TestPair init() {
+ var app = Jex.create()
+ .routing(routing -> routing
+ .post("/", ctx -> ctx.text("contentLength:" + ctx.contentLength() + " type:" + ctx.contentType()))
+ .get("/url", ctx -> ctx.text("url:" + ctx.url()))
+ .get("/fullUrl", ctx -> ctx.text("fullUrl:" + ctx.fullUrl()))
+ .get("/contextPath", ctx -> ctx.text("contextPath:" + ctx.contextPath()))
+ .get("/userAgent", ctx -> ctx.text("userAgent:" + ctx.userAgent()))
+ );
+ return TestPair.create(app);
+ }
+
+ @AfterAll
+ static void end() {
+ pair.shutdown();
+ }
+
+ @Test
+ void when_noReqContentType() {
+ HttpResponse res = pair.request().body("MyBodyContent")
+ .POST().asString();
+
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("contentLength:13 type:null");
+ }
+
+ @Test
+ void requestContentLengthAndType_notReqContentType() {
+ HttpResponse res = pair.request()
+ .formParam("a", "my-a-val")
+ .formParam("b", "my-b-val")
+ .POST().asString();
+
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("contentLength:21 type:application/x-www-form-urlencoded");
+ }
+
+ @Test
+ void url() {
+ HttpResponse res = pair.request()
+ .path("url")
+ .queryParam("a", "av")
+ .GET().asString();
+
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("url:http://localhost:" + pair.port() + "/url");
+ }
+
+ @Test
+ void fullUrl_no_queryString() {
+ HttpResponse res = pair.request()
+ .path("fullUrl")
+ .GET().asString();
+
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("fullUrl:http://localhost:" + pair.port() + "/fullUrl");
+ }
+
+ @Test
+ void fullUrl_queryString() {
+ HttpResponse res = pair.request()
+ .path("fullUrl")
+ .queryParam("a", "av")
+ .queryParam("b", "bv")
+ .GET().asString();
+
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("fullUrl:http://localhost:" + pair.port() + "/fullUrl?a=av&b=bv");
+ }
+
+ @Test
+ void contextPath() {
+ HttpResponse res = pair.request()
+ .path("contextPath")
+ .queryParam("a", "av")
+ .GET().asString();
+
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("contextPath:");
+ }
+
+ @Test
+ void userAgent() {
+ HttpResponse res = pair.request()
+ .path("userAgent")
+ .queryParam("a", "av")
+ .GET().asString();
+
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).contains("userAgent:Java-http-client");
+ }
+}
diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/ContextTest.java b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/ContextTest.java
new file mode 100644
index 00000000..478c347b
--- /dev/null
+++ b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/ContextTest.java
@@ -0,0 +1,180 @@
+package io.avaje.jex.jdk;
+
+import io.avaje.jex.Jex;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.http.HttpResponse;
+import java.util.Optional;
+
+import static java.util.Objects.requireNonNull;
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ContextTest {
+
+ static TestPair pair = init();
+
+ static TestPair init() {
+ final Jex app = Jex.create()
+ .routing(routing -> routing
+ .get("/", ctx -> ctx.text("ze-get"))
+ .post("/", ctx -> ctx.text("ze-post"))
+ .get("/header", ctx -> {
+ ctx.header("From-My-Server", "Set-By-Server");
+ ctx.text("req-header[" + ctx.header("From-My-Client") + "]");
+ })
+ .get("/headerMap", ctx -> ctx.text("req-header-map[" + ctx.headerMap() + "]"))
+ .get("/host", ctx -> {
+ final String host = ctx.host();
+ requireNonNull(host);
+ ctx.text("host:" + host);
+ })
+ .get("/ip", ctx -> {
+ final String ip = ctx.ip();
+ requireNonNull(ip);
+ ctx.text("ip:" + ip);
+ })
+ .post("/multipart", ctx -> ctx.text("isMultipart:" + ctx.isMultipart() + " isMultipartFormData:" + ctx.isMultipartFormData()))
+ .get("/method", ctx -> ctx.text("method:" + ctx.method() + " path:" + ctx.path() + " protocol:" + ctx.protocol() + " port:" + ctx.port()))
+ .post("/echo", ctx -> ctx.text("req-body[" + ctx.body() + "]"))
+ .get("/{a}/{b}", ctx -> ctx.text("ze-get-" + ctx.pathParamMap()))
+ .post("/{a}/{b}", ctx -> ctx.text("ze-post-" + ctx.pathParamMap()))
+ .get("/status", ctx -> {
+ ctx.status(201);
+ ctx.text("status:" + ctx.status());
+ }));
+
+ return TestPair.create(app);
+ }
+
+ @AfterAll
+ static void end() {
+ pair.shutdown();
+ }
+
+ @Test
+ void get() {
+ HttpResponse res = pair.request().GET().asString();
+ assertThat(res.body()).isEqualTo("ze-get");
+ }
+
+ @Test
+ void post() {
+ HttpResponse res = pair.request().body("simple").POST().asString();
+ assertThat(res.body()).isEqualTo("ze-post");
+ }
+
+ @Test
+ void ctx_header_getSet() {
+ HttpResponse res = pair.request().path("header")
+ .header("From-My-Client", "client-value")
+ .GET().asString();
+
+ final Optional serverSetHeader = res.headers().firstValue("From-My-Server");
+ assertThat(serverSetHeader.get()).isEqualTo("Set-By-Server");
+ assertThat(res.body()).isEqualTo("req-header[client-value]");
+ }
+
+ @Test
+ void ctx_headerMap() {
+ HttpResponse res = pair.request().path("headerMap")
+ .header("X-Foo", "a")
+ .header("X-Bar", "b")
+ .GET().asString();
+
+ assertThat(res.body()).contains("X-foo=a"); // not maintaining case?
+ assertThat(res.body()).contains("X-bar=b");
+ }
+
+ @Test
+ void ctx_status() {
+ HttpResponse res = pair.request().path("status")
+ .GET().asString();
+
+ assertThat(res.body()).isEqualTo("status:201");
+ }
+
+ @Test
+ void ctx_host() {
+ HttpResponse res = pair.request().path("host")
+ .GET().asString();
+
+ assertThat(res.body()).contains("host:localhost");
+ }
+
+ @Test
+ void ctx_ip() {
+ HttpResponse res = pair.request().path("ip")
+ .GET().asString();
+
+ assertThat(res.body()).isEqualTo("ip:127.0.0.1");
+ }
+
+ @Test
+ void ctx_isMultiPart_when_not() {
+ HttpResponse res = pair.request().path("multipart")
+ .formParam("a", "aval")
+ .POST().asString();
+
+ assertThat(res.body()).isEqualTo("isMultipart:false isMultipartFormData:false");
+ }
+
+
+ @Test
+ void ctx_isMultiPart_when_nothing() {
+ HttpResponse res = pair.request().path("multipart")
+ .body("junk")
+ .POST().asString();
+
+ assertThat(res.body()).isEqualTo("isMultipart:false isMultipartFormData:false");
+ }
+
+// @Test
+// void ctx_isMultiPart_when_isMultipart() {
+// HttpResponse res = pair.request().path("multipart")
+// .header("Content-Type", "multipart/foo")
+// .body("junk")
+// .POST().asString();
+//
+// assertThat(res.body()).isEqualTo("isMultipart:true isMultipartFormData:false");
+// }
+//
+// @Test
+// void ctx_isMultiPart_when_isMultipartFormData() {
+// HttpResponse res = pair.request().path("multipart")
+// .header("Content-Type", "multipart/form-data")
+// .body("junk")
+// .POST().asString();
+//
+// assertThat(res.body()).isEqualTo("isMultipart:true isMultipartFormData:true");
+// }
+
+ @Test
+ void ctx_methodPathPortProtocol() {
+ HttpResponse res = pair.request().path("method")
+ .GET().asString();
+
+ assertThat(res.body()).isEqualTo("method:GET path:/method protocol:HTTP/1.1 port:" + pair.port());
+ }
+
+ @Test
+ void post_body() {
+ HttpResponse res = pair.request().path("echo").body("simple").POST().asString();
+ assertThat(res.body()).isEqualTo("req-body[simple]");
+ }
+
+ @Test
+ void get_path_path() {
+ var res = pair.request()
+ .path("A").path("B").GET().asString();
+
+ assertThat(res.body()).isEqualTo("ze-get-{a=A, b=B}");
+
+ res = pair.request()
+ .path("one").path("bar").body("simple").POST().asString();
+
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("ze-post-{a=one, b=bar}");
+ }
+
+}
diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/CookieParserTest.java b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/CookieParserTest.java
new file mode 100644
index 00000000..46649180
--- /dev/null
+++ b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/CookieParserTest.java
@@ -0,0 +1,53 @@
+package io.avaje.jex.jdk;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class CookieParserTest {
+
+ @Test
+ void emptyAndNull() {
+ assertThat(CookieParser.parse(null)).isEmpty();
+ assertThat(CookieParser.parse("")).isEmpty();
+ }
+
+ @Test
+ void basicMultiValue() {
+ Map cookies = CookieParser.parse("foo=bar; aaa=bbb; c=what_the_hell; aaa=ccc");
+ assertThat(cookies.get("foo")).isEqualTo("bar");
+ assertThat(cookies.get("aaa")).isEqualTo("ccc");
+ assertThat(cookies.get("c")).isEqualTo("what_the_hell");
+ assertThat(cookies).hasSize(3);
+ }
+
+ @Test
+ void rfc2965() {
+ String header = "$version=1; foo=bar; $Domain=google.com, aaa=bbb, c=cool; $Domain=google.com; $Path=\"/foo\"";
+ Map cookies = CookieParser.parse(header);
+ assertThat(cookies.get("foo")).isEqualTo("bar");
+ assertThat(cookies.get("aaa")).isEqualTo("bbb");
+ assertThat(cookies.get("c")).isEqualTo("cool");
+ assertThat(cookies).hasSize(3);
+ }
+
+ @Test
+ void unquote() {
+ Map cookies = CookieParser.parse("foo=\"bar\"; aaa=bbb; c=\"what_the_hell\"; aaa=\"ccc\"");
+ assertThat(cookies.get("foo")).isEqualTo("bar");
+ assertThat(cookies.get("aaa")).isEqualTo("ccc");
+ assertThat(cookies).hasSize(3);
+ }
+
+ @Test
+ void tokenize() {
+ String text = ",aa,,fo\"oooo\",\"bar\",co\"o'l,e\"c,df'hk,lm',";
+ List tokens = CookieParser.tokenize(',', text);
+ assertThat(tokens).contains("aa", "fo\"oooo\"", "\"bar\"", "co\"o'l,e\"c", "df'hk", "lm'");
+ tokens = CookieParser.tokenize(';', text);
+ assertThat(tokens).containsExactly(text);
+ }
+}
diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/CookieServerTest.java b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/CookieServerTest.java
new file mode 100644
index 00000000..20a4610f
--- /dev/null
+++ b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/CookieServerTest.java
@@ -0,0 +1,81 @@
+package io.avaje.jex.jdk;
+
+import io.avaje.jex.Context;
+import io.avaje.jex.Jex;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.http.HttpResponse;
+import java.time.Duration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class CookieServerTest {
+
+ static TestPair pair = init();
+
+ static TestPair init() {
+ var app = Jex.create()
+ .routing(routing -> routing
+ .get("/setCookie", ctx -> ctx.cookie("ck", "val").cookie("ck2", "val2").text("ok"))
+ .get("/readCookie/{name}", ctx -> ctx.text("readCookie:" + ctx.cookie(ctx.pathParam("name"))))
+ .get("/readCookieMap", ctx -> ctx.text("cookieMap:" + ctx.cookieMap()))
+ .get("/removeCookie/{name}", ctx -> ctx.removeCookie(ctx.pathParam("name")).text("ok"))
+ .get("/setCookieAll", ctx -> {
+ final Context.Cookie httpCookie = Context.Cookie.of("ac", "v_all")
+ .path("/").httpOnly(true).maxAge(Duration.ofSeconds(10_000));
+ ctx.cookie(httpCookie).text("ok");
+ })
+ );
+ return TestPair.create(app, 9001);
+ }
+
+ @AfterAll
+ static void end() {
+ pair.shutdown();
+ }
+
+ @Test
+ void set_read_readMap_remove_readMap_remove_readMap() {
+ HttpResponse res = pair.request().path("removeCookie").path("ac").GET().asString();
+ assertThat(res.body()).isEqualTo("ok");
+
+ res = pair.request().path("setCookie").GET().asString();
+ assertThat(res.statusCode()).isEqualTo(200);
+
+ res = pair.request().path("readCookie").path("ck").GET().asString();
+ assertThat(res.body()).isEqualTo("readCookie:val");
+
+ res = pair.request().path("readCookie").path("ck2").GET().asString();
+ assertThat(res.body()).isEqualTo("readCookie:val2");
+
+ res = pair.request().path("readCookieMap").GET().asString();
+ assertThat(res.body()).isEqualTo("cookieMap:{ck=val, ck2=val2}");
+
+ res = pair.request().path("removeCookie").path("ck").GET().asString();
+ assertThat(res.body()).isEqualTo("ok");
+
+ res = pair.request().path("readCookieMap").GET().asString();
+ assertThat(res.body()).isEqualTo("cookieMap:{ck2=val2}");
+
+ res = pair.request().path("removeCookie").path("ck2").GET().asString();
+ assertThat(res.body()).isEqualTo("ok");
+
+ res = pair.request().path("readCookieMap").GET().asString();
+ assertThat(res.body()).isEqualTo("cookieMap:{}");
+ }
+
+ @Test
+ void setAll() {
+ HttpResponse res = pair.request().path("setCookieAll").GET().asString();
+
+ assertThat(res.statusCode()).isEqualTo(200);
+
+ res = pair.request().path("readCookieMap").GET().asString();
+ assertThat(res.body()).isEqualTo("cookieMap:{ac=v_all}");
+
+ res = pair.request().path("readCookie").path("ac").GET().asString();
+ assertThat(res.body()).isEqualTo("readCookie:v_all");
+ }
+
+}
diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/ExceptionManagerTest.java b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/ExceptionManagerTest.java
new file mode 100644
index 00000000..2e6f60ca
--- /dev/null
+++ b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/ExceptionManagerTest.java
@@ -0,0 +1,80 @@
+package io.avaje.jex.jdk;
+
+import io.avaje.jex.Jex;
+import io.avaje.jex.http.ConflictResponse;
+import io.avaje.jex.http.ForbiddenResponse;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.http.HttpResponse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ExceptionManagerTest {
+
+ static TestPair pair = init();
+
+ static TestPair init() {
+ final Jex app = Jex.create()
+ .routing(routing -> routing
+ .get("/", ctx -> {
+ throw new ForbiddenResponse();
+ })
+ .post("/", ctx -> {
+ throw new IllegalStateException("foo");
+ })
+ .get("/conflict", ctx -> {
+ throw new ConflictResponse("Baz");
+ })
+ .get("/fiveHundred", ctx -> {
+ throw new IllegalArgumentException("Bar");
+ }))
+ .exception(NullPointerException.class, (exception, ctx) -> ctx.text("npe"))
+ .exception(IllegalStateException.class, (exception, ctx) -> ctx.status(222).text("Handled IllegalStateException|" + exception.getMessage()))
+ .exception(ForbiddenResponse.class, (exception, ctx) -> ctx.status(223).text("Handled ForbiddenResponse|" + exception.getMessage()));
+
+ return TestPair.create(app);
+ }
+
+ @AfterAll
+ static void end() {
+ pair.shutdown();
+ }
+
+ @Test
+ void get() {
+ HttpResponse res = pair.request().GET().asString();
+ assertThat(res.statusCode()).isEqualTo(223);
+ assertThat(res.body()).isEqualTo("Handled ForbiddenResponse|Forbidden");
+ }
+
+ @Test
+ void post() {
+ HttpResponse res = pair.request().body("simple").POST().asString();
+ assertThat(res.statusCode()).isEqualTo(222);
+ assertThat(res.body()).isEqualTo("Handled IllegalStateException|foo");
+ }
+
+ @Test
+ void expect_fallback_to_default_asPlainText() {
+ HttpResponse res = pair.request().path("conflict").GET().asString();
+ assertThat(res.statusCode()).isEqualTo(409);
+ assertThat(res.body()).isEqualTo("Baz");
+ assertThat(res.headers().firstValue("Content-Type").get()).contains("text/plain");
+ }
+
+ @Test
+ void expect_fallback_to_default_asJson() {
+ HttpResponse res = pair.request().path("conflict").header("Accept", "application/json").GET().asString();
+ assertThat(res.statusCode()).isEqualTo(409);
+ assertThat(res.body()).isEqualTo("{\"title\": Baz, \"status\": 409}");
+ assertThat(res.headers().firstValue("Content-Type").get()).contains("application/json");
+ }
+
+ @Test
+ void expect_fallback_to_internalServerError() {
+ HttpResponse res = pair.request().path("fiveHundred").GET().asString();
+ assertThat(res.statusCode()).isEqualTo(500);
+ assertThat(res.body()).isEqualTo("Internal server error");
+ }
+}
diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/FilterTest.java b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/FilterTest.java
new file mode 100644
index 00000000..9280e929
--- /dev/null
+++ b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/FilterTest.java
@@ -0,0 +1,89 @@
+package io.avaje.jex.jdk;
+
+import io.avaje.jex.Jex;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.http.HttpHeaders;
+import java.net.http.HttpResponse;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class FilterTest {
+
+ static TestPair pair = init();
+ static AtomicReference afterAll = new AtomicReference<>();
+ static AtomicReference afterTwo = new AtomicReference<>();
+
+ static TestPair init() {
+ final Jex app = Jex.create()
+ .routing(routing -> routing
+ .get("/", ctx -> ctx.text("roo"))
+ .get("/one", ctx -> ctx.text("one"))
+ .get("/two", ctx -> ctx.text("two"))
+ .get("/two/{id}", ctx -> ctx.text("two-id"))
+ .before(ctx -> {
+ ctx.header("before-all", "set");
+ })
+ .before("/two/*", ctx -> ctx.header("before-two", "set"))
+ .after(ctx -> {
+ afterAll.set("set");
+ })
+ .after("/two/*", ctx -> afterTwo.set("set"))
+ .get("/dummy", ctx -> ctx.text("dummy"))
+ );
+
+ return TestPair.create(app);
+ }
+
+ @AfterAll
+ static void end() {
+ pair.shutdown();
+ }
+
+ void clearAfter() {
+ afterAll.set(null);
+ afterTwo.set(null);
+ }
+
+ @Test
+ void get() {
+ clearAfter();
+ HttpResponse res = pair.request().GET().asString();
+ assertHasBeforeAfterAll(res);
+ assertNoBeforeAfterTwo(res);
+
+ clearAfter();
+ res = pair.request().path("one").GET().asString();
+ assertHasBeforeAfterAll(res);
+ assertNoBeforeAfterTwo(res);
+
+ clearAfter();
+ res = pair.request().path("two").GET().asString();
+ assertHasBeforeAfterAll(res);
+ assertNoBeforeAfterTwo(res);
+ }
+
+
+ @Test
+ void get_two_expect_extraFilters() {
+ clearAfter();
+ HttpResponse res = pair.request().path("two/42").GET().asString();
+
+ final HttpHeaders headers = res.headers();
+ assertHasBeforeAfterAll(res);
+ assertThat(headers.firstValue("before-two")).get().isEqualTo("set");
+ assertThat(afterTwo.get()).isEqualTo("set");
+ }
+
+ private void assertNoBeforeAfterTwo(HttpResponse res) {
+ assertThat(res.headers().firstValue("before-two")).isEmpty();
+ assertThat(afterTwo.get()).isNull();
+ }
+
+ private void assertHasBeforeAfterAll(HttpResponse res) {
+ assertThat(res.headers().firstValue("before-all")).get().isEqualTo("set");
+ assertThat(afterAll.get()).isEqualTo("set");
+ }
+}
diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/HeadersTest.java b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/HeadersTest.java
new file mode 100644
index 00000000..3ff8226e
--- /dev/null
+++ b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/HeadersTest.java
@@ -0,0 +1,54 @@
+package io.avaje.jex.jdk;
+
+import io.avaje.http.client.HttpClientContext;
+import io.avaje.http.client.JacksonBodyAdapter;
+import io.avaje.jex.Jex;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.http.HttpResponse;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Random;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class HeadersTest {
+
+ static final int port = new Random().nextInt(1000) + 10_000;
+ static Jex.Server server;
+ static HttpClientContext client;
+
+ @BeforeAll
+ static void setup() {
+ server = Jex.create()
+ .routing(routing -> routing
+ .get("/", ctx -> {
+ final String one = ctx.header("one");
+ Map obj = new LinkedHashMap<>();
+ obj.put("one", one);
+ ctx.json(obj);
+ })
+ )
+ .port(port)
+ .start();
+
+ client = HttpClientContext.newBuilder()
+ .baseUrl("http://localhost:"+port)
+ .bodyAdapter(new JacksonBodyAdapter())
+ .build();
+ }
+
+ @Test
+ void get() {
+
+ final HttpResponse hres = client.request()
+ .header("one", "hello")
+ .GET().asString();
+
+ assertThat(hres.statusCode()).isEqualTo(200);
+ assertThat(hres.body()).isEqualTo("{\"one\":\"hello\"}");
+
+ server.shutdown();
+ }
+}
diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/HealthPluginTest.java b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/HealthPluginTest.java
new file mode 100644
index 00000000..d05d1876
--- /dev/null
+++ b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/HealthPluginTest.java
@@ -0,0 +1,99 @@
+package io.avaje.jex.jdk;
+
+import io.avaje.http.client.HttpClientContext;
+import io.avaje.http.client.JacksonBodyAdapter;
+import io.avaje.jex.AppLifecycle;
+import io.avaje.jex.Jex;
+import io.avaje.jex.core.HealthPlugin;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.http.HttpResponse;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Random;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class HealthPluginTest {
+
+ static final int port = new Random().nextInt(1000) + 10_000;
+ static Jex jex;
+ static Jex.Server server;
+ static HttpClientContext client;
+
+ @BeforeAll
+ static void setup() {
+ jex = Jex.create()
+ .routing(routing -> routing
+ .get("/", ctx -> {
+ final String one = ctx.header("one");
+ Map obj = new LinkedHashMap<>();
+ obj.put("one", one);
+ ctx.json(obj);
+ })
+ )
+ .configure(new HealthPlugin())
+ .port(port);
+
+ server = jex.start();
+ client = HttpClientContext.newBuilder()
+ .baseUrl("http://localhost:"+port)
+ .bodyAdapter(new JacksonBodyAdapter())
+ .build();
+ }
+
+ @AfterAll
+ static void end() {
+ server.shutdown();
+ }
+
+ @Test
+ void get() {
+ final HttpResponse hres = client.request()
+ .header("one", "hello")
+ .GET().asString();
+ assertThat(hres.statusCode()).isEqualTo(200);
+ }
+
+ @Test
+ void healthLiveness() {
+ assertThat(ready("health/liveness").statusCode()).isEqualTo(200);
+ }
+
+ @Test
+ void healthLiveness_various() {
+ jex.lifecycle().status(AppLifecycle.Status.STOPPED);
+ assertThat(ready("health/liveness").statusCode()).isEqualTo(500);
+ jex.lifecycle().status(AppLifecycle.Status.STOPPING);
+ assertThat(ready("health/liveness").statusCode()).isEqualTo(500);
+
+ jex.lifecycle().status(AppLifecycle.Status.STARTING);
+ assertThat(ready("health/liveness").statusCode()).isEqualTo(200);
+ jex.lifecycle().status(AppLifecycle.Status.STARTED);
+ assertThat(ready("health/liveness").statusCode()).isEqualTo(200);
+ }
+
+ @Test
+ void healthReadiness() {
+ assertThat(ready("health/readiness").statusCode()).isEqualTo(200);
+ }
+
+ @Test
+ void healthReadiness_various() {
+ jex.lifecycle().status(AppLifecycle.Status.STOPPING);
+ assertThat(ready("health/readiness").statusCode()).isEqualTo(500);
+ jex.lifecycle().status(AppLifecycle.Status.STOPPED);
+ assertThat(ready("health/readiness").statusCode()).isEqualTo(500);
+ jex.lifecycle().status(AppLifecycle.Status.STARTING);
+ assertThat(ready("health/readiness").statusCode()).isEqualTo(500);
+ jex.lifecycle().status(AppLifecycle.Status.STARTED);
+ assertThat(ready("health/readiness").statusCode()).isEqualTo(200);
+ }
+
+ private HttpResponse ready(String s) {
+ return client.request().path(s)
+ .GET().asString();
+ }
+}
diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/HelloBean.java b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/HelloBean.java
new file mode 100644
index 00000000..a89d5f57
--- /dev/null
+++ b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/HelloBean.java
@@ -0,0 +1,16 @@
+package io.avaje.jex.jdk;
+
+public class HelloBean {
+
+ public int id;
+ public String name;
+
+ public HelloBean(int id, String name) {
+ this.id = id;
+ this.name = name;
+ }
+
+ public HelloBean() {
+
+ }
+}
diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/HelloDto.java b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/HelloDto.java
new file mode 100644
index 00000000..e8861f3f
--- /dev/null
+++ b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/HelloDto.java
@@ -0,0 +1,27 @@
+package io.avaje.jex.jdk;
+
+public class HelloDto {
+
+ public long id;
+ public String name;
+
+ @Override
+ public String toString() {
+ return "id:" + id + " name:" + name;
+ }
+
+ public static HelloDto rob() {
+ return create(42, "rob");
+ }
+
+ public static HelloDto fi() {
+ return create(45, "fi");
+ }
+
+ public static HelloDto create(long id, String name) {
+ HelloDto me = new HelloDto();
+ me.id = id;
+ me.name = name;
+ return me;
+ }
+}
diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/JdkJexServerTest.java b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/JdkJexServerTest.java
new file mode 100644
index 00000000..87900cdf
--- /dev/null
+++ b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/JdkJexServerTest.java
@@ -0,0 +1,60 @@
+package io.avaje.jex.jdk;
+
+import io.avaje.http.client.HttpClientContext;
+import io.avaje.http.client.JacksonBodyAdapter;
+import io.avaje.jex.Jex;
+import org.junit.jupiter.api.Test;
+
+import java.net.http.HttpResponse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class JdkJexServerTest {
+
+ @Test
+ void init() {
+
+ HelloBean bean = new HelloBean(42, "rob");
+
+ final Jex.Server server = Jex.create()
+ .routing(routing -> routing
+ .get("/", ctx -> ctx.text("hello world"))
+ .get("/foo", ctx -> ctx.json(bean))
+ .post("/foo", ctx -> {
+ final HelloBean in = ctx.bodyAsClass(HelloBean.class);
+ in.name = in.name + "-out";
+ ctx.json(in);
+ }))
+ .port(8093)
+ .start();
+
+ final HttpClientContext client = HttpClientContext.newBuilder()
+ .baseUrl("http://localhost:8093")
+ .bodyAdapter(new JacksonBodyAdapter())
+ .build();
+
+ final HttpResponse hres = client.request().GET().asString();
+
+ assertThat(hres.statusCode()).isEqualTo(200);
+ assertThat(hres.body()).isEqualTo("hello world");
+
+
+ final HelloBean foo = client.request().path("foo")
+ .GET()
+ .bean(HelloBean.class);
+
+ assertThat(foo.id).isEqualTo(42);
+
+ final HelloBean foo2 = client.request().path("foo")
+ .header("Accepts", "application/json")
+ .body(bean)
+ .POST()
+ .bean(HelloBean.class);
+
+ assertThat(foo2.name).isEqualTo("rob-out");
+
+ System.out.println("done");
+
+ server.shutdown();
+ }
+}
diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/JsonTest.java b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/JsonTest.java
new file mode 100644
index 00000000..673a60a0
--- /dev/null
+++ b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/JsonTest.java
@@ -0,0 +1,121 @@
+package io.avaje.jex.jdk;
+
+import io.avaje.jex.Jex;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.http.HttpHeaders;
+import java.net.http.HttpResponse;
+import java.util.List;
+import java.util.stream.Stream;
+
+import static java.util.Arrays.asList;
+import static java.util.stream.Collectors.toList;
+import static org.assertj.core.api.Assertions.assertThat;
+
+class JsonTest {
+
+ static List HELLO_BEANS = asList(HelloDto.rob(), HelloDto.fi());
+
+ static AutoCloseIterator ITERATOR = createBeanIterator();
+
+ private static AutoCloseIterator createBeanIterator() {
+ return new AutoCloseIterator<>(HELLO_BEANS.iterator());
+ }
+
+ static TestPair pair = init();
+
+ static TestPair init() {
+ Jex app = Jex.create()
+ .routing(routing -> routing
+ .get("/", ctx -> ctx.json(HelloDto.rob()).status(200))
+ .get("/iterate", ctx -> ctx.jsonStream(ITERATOR))
+ .get("/stream", ctx -> ctx.jsonStream(HELLO_BEANS.stream()))
+ .post("/", ctx -> ctx.text("bean[" + ctx.bodyAsClass(HelloDto.class) + "]")));
+
+ return TestPair.create(app);
+ }
+
+ @AfterAll
+ static void end() {
+ pair.shutdown();
+ }
+
+ @Test
+ void get() {
+
+ var bean = pair.request()
+ .GET()
+ .bean(HelloDto.class);
+
+ assertThat(bean.id).isEqualTo(42);
+ assertThat(bean.name).isEqualTo("rob");
+
+ final HttpResponse hres = pair.request()
+ .GET().asString();
+
+ final HttpHeaders headers = hres.headers();
+ assertThat(headers.firstValue("Content-Type").get()).isEqualTo("application/json");
+ }
+
+ @Test
+ void stream_viaIterator() {
+ final Stream beanStream = pair.request()
+ .path("iterate")
+ .GET()
+ .stream(HelloDto.class);
+
+ // assert AutoCloseable iterator on the server-side was closed
+ assertThat(ITERATOR.isClosed()).isTrue();
+ // expect client gets the expected stream of beans
+ assertCollectedStream(beanStream);
+ }
+
+ @Test
+ void stream() {
+ final Stream beanStream = pair.request()
+ .path("stream")
+ .GET()
+ .stream(HelloDto.class);
+
+ assertCollectedStream(beanStream);
+ }
+
+ private void assertCollectedStream(Stream beanStream) {
+ final List collectedBeans = beanStream.collect(toList());
+ assertThat(collectedBeans).hasSize(2);
+
+ final HelloDto first = collectedBeans.get(0);
+ assertThat(first.id).isEqualTo(42);
+ assertThat(first.name).isEqualTo("rob");
+
+ final HelloDto second = collectedBeans.get(1);
+ assertThat(second.id).isEqualTo(45);
+ assertThat(second.name).isEqualTo("fi");
+ }
+
+ @Test
+ void post() {
+ HelloDto dto = new HelloDto();
+ dto.id = 42;
+ dto.name = "rob was here";
+
+ var res = pair.request()
+ .body(dto)
+ .POST().asString();
+
+ assertThat(res.body()).isEqualTo("bean[id:42 name:rob was here]");
+ assertThat(res.statusCode()).isEqualTo(200);
+
+ dto.id = 99;
+ dto.name = "fi";
+
+ res = pair.request()
+ .body(dto)
+ .POST().asString();
+
+ assertThat(res.body()).isEqualTo("bean[id:99 name:fi]");
+ assertThat(res.statusCode()).isEqualTo(200);
+ }
+
+}
diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/Main.java b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/Main.java
new file mode 100644
index 00000000..d1f8068d
--- /dev/null
+++ b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/Main.java
@@ -0,0 +1,18 @@
+package io.avaje.jex.jdk;
+
+import io.avaje.jex.Jex;
+import io.avaje.jex.core.HealthPlugin;
+
+public class Main {
+
+ public static void main(String[] args) {
+
+ Jex.create()
+ .routing(routing -> routing
+ .get("/", ctx -> ctx.text("hello world"))
+ )
+ .configure(new HealthPlugin())
+ .port(9009)
+ .start();
+ }
+}
diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/NestedRoutesTest.java b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/NestedRoutesTest.java
new file mode 100644
index 00000000..f12a1469
--- /dev/null
+++ b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/NestedRoutesTest.java
@@ -0,0 +1,73 @@
+package io.avaje.jex.jdk;
+
+import io.avaje.jex.Jex;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.http.HttpResponse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class NestedRoutesTest {
+
+ static TestPair pair = init();
+
+ static TestPair init() {
+ Jex app = Jex.create()
+ .routing(routing -> routing
+ .get("/", ctx -> ctx.text("hello"))
+ .path("api", () -> {
+ routing.get(ctx -> ctx.text("apiRoot"));
+ routing.get("{id}", ctx -> ctx.text("api-" + ctx.pathParam("id")));
+ })
+ .path("extra", () -> {
+ routing.get(ctx -> ctx.text("extraRoot"));
+ routing.get("{id}", ctx -> ctx.text("extra-id-" + ctx.pathParam("id")));
+ routing.get("more/{id}", ctx -> ctx.text("extraMore-" + ctx.pathParam("id")));
+ }));
+ return TestPair.create(app);
+ }
+
+ @AfterAll
+ static void end() {
+ pair.shutdown();
+ }
+
+ @Test
+ void get() {
+ HttpResponse res = pair.request().GET().asString();
+ assertThat(res.body()).isEqualTo("hello");
+ }
+
+ @Test
+ void get_api_paths() {
+ var res = pair.request()
+ .path("api").GET().asString();
+
+ assertThat(res.body()).isEqualTo("apiRoot");
+
+ res = pair.request()
+ .path("api").path("99").GET().asString();
+
+ assertThat(res.body()).isEqualTo("api-99");
+ }
+
+ @Test
+ void get_extra_paths() {
+ var res = pair.request()
+ .path("extra").GET().asString();
+
+ assertThat(res.body()).isEqualTo("extraRoot");
+
+ res = pair.request()
+ .path("extra").path("99").GET().asString();
+
+ assertThat(res.body()).isEqualTo("extra-id-99");
+
+ res = pair.request()
+ .path("extra").path("more").path("42").GET().asString();
+
+ assertThat(res.body()).isEqualTo("extraMore-42");
+ }
+
+}
diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/QueryParamTest.java b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/QueryParamTest.java
new file mode 100644
index 00000000..64e7ed13
--- /dev/null
+++ b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/QueryParamTest.java
@@ -0,0 +1,150 @@
+package io.avaje.jex.jdk;
+
+import io.avaje.jex.Jex;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.http.HttpResponse;
+import java.util.Map;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class QueryParamTest {
+
+ static TestPair pair = init();
+
+ static TestPair init() {
+ var app = Jex.create()
+ .routing(routing -> routing
+ .get("/", ctx -> ctx.text("hello"))
+ .get("/one/{id}", ctx -> ctx.text("one-" + ctx.pathParam("id") + "|match:" + ctx.matchedPath()))
+ .get("/one/{id}/{b}", ctx -> ctx.text("path:" + ctx.pathParamMap() + "|query:" + ctx.queryParam("z") + "|match:" + ctx.matchedPath()))
+ .get("/queryParamMap", ctx -> ctx.text("qpm: "+ctx.queryParamMap()))
+ .get("/queryParams", ctx -> ctx.text("qps: "+ctx.queryParams("a")))
+ .get("/queryString", ctx -> ctx.text("qs: "+ctx.queryString()))
+ .get("/scheme", ctx -> ctx.text("scheme: "+ctx.scheme()))
+ );
+ return TestPair.create(app);
+ }
+
+ @AfterAll
+ static void end() {
+ pair.shutdown();
+ }
+
+ @Test
+ void get() {
+ HttpResponse res = pair.request().GET().asString();
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("hello");
+ }
+
+ @Test
+ void getOne_path() {
+ var res = pair.request()
+ .path("one").path("foo").GET().asString();
+
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("one-foo|match:/one/{id}");
+
+ res = pair.request()
+ .path("one").path("bar").GET().asString();
+
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("one-bar|match:/one/{id}");
+ }
+
+ @Test
+ void getOne_path_path() {
+ var res = pair.request()
+ .path("one").path("foo").path("bar")
+ .GET().asString();
+
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("path:{id=foo, b=bar}|query:null|match:/one/{id}/{b}");
+
+ res = pair.request()
+ .path("one").path("fo").path("ba").queryParam("z", "42")
+ .GET().asString();
+
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("path:{id=fo, b=ba}|query:42|match:/one/{id}/{b}");
+ }
+
+ @Test
+ void queryParamMap_when_empty() {
+ HttpResponse res = pair.request().path("queryParamMap").GET().asString();
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("qpm: {}");
+ }
+
+ @Test
+ void queryParamMap_keyWithMultiValues_expect_firstValueInMap() {
+ HttpResponse res = pair.request().path("queryParamMap")
+ .queryParam("a","AVal0")
+ .queryParam("a","AVal1")
+ .queryParam("b", "BVal")
+ .GET().asString();
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("qpm: {a=AVal0, b=BVal}");
+ }
+
+ @Test
+ void queryParamMap_basic() {
+ HttpResponse res = pair.request().path("queryParamMap")
+ .queryParam("a","AVal")
+ .queryParam("b", "BVal")
+ .GET().asString();
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("qpm: {a=AVal, b=BVal}");
+ }
+
+ @Test
+ void queryParams_basic() {
+ HttpResponse res = pair.request().path("queryParams")
+ .queryParam("a","one")
+ .queryParam("a", "two")
+ .GET().asString();
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("qps: [one, two]");
+ }
+
+ @Test
+ void queryParams_when_null_expect_emptyList() {
+ HttpResponse res = pair.request().path("queryParams")
+ .queryParam("b","one")
+ .GET().asString();
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("qps: []");
+ }
+
+ @Test
+ void queryString_when_null() {
+ HttpResponse res = pair.request().path("queryString")
+ .GET().asString();
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("qs: null");
+ }
+
+ @Test
+ void queryString_when_set() {
+ HttpResponse res = pair.request().path("queryString")
+ .queryParam("foo","f1")
+ .queryParam("bar","b1")
+ .queryParam("bar","b2")
+ .GET().asString();
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("qs: foo=f1&bar=b1&bar=b2");
+ }
+
+ @Test
+ void scheme() {
+ HttpResponse res = pair.request().path("scheme")
+ .queryParam("foo","f1")
+ .GET().asString();
+ assertThat(res.statusCode()).isEqualTo(200);
+ assertThat(res.body()).isEqualTo("scheme: http");
+ }
+
+}
diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/RedirectTest.java b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/RedirectTest.java
new file mode 100644
index 00000000..418b21be
--- /dev/null
+++ b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/RedirectTest.java
@@ -0,0 +1,46 @@
+package io.avaje.jex.jdk;
+
+import io.avaje.jex.Jex;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.http.HttpResponse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class RedirectTest {
+
+
+ static TestPair pair = init();
+
+ static TestPair init() {
+ var app = Jex.create()
+ .routing(routing -> routing
+ .before("/other/*", ctx -> ctx.redirect("/two?from=filter"))
+ .get("/one", ctx -> ctx.text("one"))
+ .get("/two", ctx -> ctx.text("two"))
+ .get("/redirect/me", ctx -> ctx.redirect("/one?from=handler"))
+ .get("/other/me", ctx -> ctx.text("never hit"))
+ );
+ return TestPair.create(app);
+ }
+
+ @AfterAll
+ static void end() {
+ pair.shutdown();
+ }
+
+ @Test
+ void redirect_via_handler() {
+ HttpResponse res = pair.request().path("redirect/me").GET().asString();
+ assertThat(res.body()).isEqualTo("one");
+ assertThat(res.statusCode()).isEqualTo(200);
+ }
+
+ @Test
+ void redirect_via_beforeHandler() {
+ HttpResponse res = pair.request().path("other/me").GET().asString();
+ assertThat(res.body()).isEqualTo("two");
+ assertThat(res.statusCode()).isEqualTo(200);
+ }
+}
diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/TestPair.java b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/TestPair.java
new file mode 100644
index 00000000..59d7c285
--- /dev/null
+++ b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/TestPair.java
@@ -0,0 +1,65 @@
+package io.avaje.jex.jdk;
+
+import io.avaje.http.client.HttpClientContext;
+import io.avaje.http.client.HttpClientRequest;
+import io.avaje.http.client.JacksonBodyAdapter;
+import io.avaje.jex.Jex;
+
+import java.time.Duration;
+import java.util.Random;
+
+/**
+ * Server and Client pair for a test.
+ */
+public class TestPair {
+
+ private final int port;
+
+ private final Jex.Server server;
+
+ private final HttpClientContext client;
+
+ public TestPair(int port, Jex.Server server, HttpClientContext client) {
+ this.port = port;
+ this.server = server;
+ this.client = client;
+ }
+
+ public void shutdown() {
+ server.shutdown();
+ }
+
+ public HttpClientRequest request() {
+ return client.request();
+ }
+
+ public int port() {
+ return port;
+ }
+
+ public String url() {
+ return client.url().build();
+ }
+
+ /**
+ * Create a Server and Client pair for a given set of tests.
+ */
+ public static TestPair create(Jex app) {
+ int port = 10000 + new Random().nextInt(1000);
+ return create(app, port);
+ }
+
+ public static TestPair create(Jex app, int port) {
+
+ var jexServer = app.port(port).start();
+
+ var url = "http://localhost:" + port;
+ var client = HttpClientContext.newBuilder()
+ .baseUrl(url)
+ .bodyAdapter(new JacksonBodyAdapter())
+ .requestTimeout(Duration.ofMinutes(2))
+ .build();
+
+ return new TestPair(port, jexServer, client);
+ }
+}
diff --git a/avaje-jex-jdk/src/test/resources/logback-test.xml b/avaje-jex-jdk/src/test/resources/logback-test.xml
new file mode 100644
index 00000000..5e5a132a
--- /dev/null
+++ b/avaje-jex-jdk/src/test/resources/logback-test.xml
@@ -0,0 +1,19 @@
+
+
+
+ TRACE
+
+
+ %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/avaje-jex-jetty/pom.xml b/avaje-jex-jetty/pom.xml
index 3e805368..fbec571c 100644
--- a/avaje-jex-jetty/pom.xml
+++ b/avaje-jex-jetty/pom.xml
@@ -2,58 +2,50 @@
-
- 4.0.0
- java11-oss
- org.avaje
- 3.2
+ avaje-jex-parent
+ io.avaje
+ 1.8-SNAPSHOT
+ 4.0.0
avaje-jex-jetty
- io.avaje
- 1.0
- 17
- 17
- 17
- 17
- 11.0.2
+ 11
+ 11
+ 11.0.6
+
+ io.avaje
+ avaje-jex
+ 1.8-SNAPSHOT
+
org.eclipse.jetty
jetty-servlet
${jetty.version}
- provided
-
- org.moditect
- moditect-maven-plugin
- 1.0.0.RC1
-
-
- add-module-infos
- package
-
- add-module-info
-
-
- 9
-
- src/main/java9/module-info.java
-
-
-
-
+ maven-surefire-plugin
+
+
+ --add-modules com.fasterxml.jackson.databind
+ --add-opens io.avaje.jex.jetty/io.avaje.jex.base=com.fasterxml.jackson.databind
+ --add-opens io.avaje.jex.jetty/io.avaje.jex=ALL-UNNAMED
+ --add-opens io.avaje.jex.jetty/io.avaje.jex.base=ALL-UNNAMED
+ --add-opens io.avaje.jex.jetty/io.avaje.jex.core=ALL-UNNAMED
+ --add-opens io.avaje.jex.jetty/io.avaje.jex.routes=ALL-UNNAMED
+ --add-opens io.avaje.jex.jetty/io.avaje.jex.jetty=ALL-UNNAMED
+
+
diff --git a/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/ContextUtil.java b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/ContextUtil.java
new file mode 100644
index 00000000..c986ffbe
--- /dev/null
+++ b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/ContextUtil.java
@@ -0,0 +1,41 @@
+package io.avaje.jex.jetty;
+
+import jakarta.servlet.ServletInputStream;
+import jakarta.servlet.http.HttpServletRequest;
+
+import java.io.*;
+
+class ContextUtil {
+
+ private static final int DEFAULT_BUFFER_SIZE = 8 * 1024;
+
+ private static final int BUFFER_MAX = 65536;
+
+ static byte[] readBody(HttpServletRequest req) {
+ try {
+ final ServletInputStream inputStream = req.getInputStream();
+
+ int bufferSize = inputStream.available();
+ if (bufferSize < DEFAULT_BUFFER_SIZE) {
+ bufferSize = DEFAULT_BUFFER_SIZE;
+ } else if (bufferSize > BUFFER_MAX) {
+ bufferSize = BUFFER_MAX;
+ }
+
+ ByteArrayOutputStream os = new ByteArrayOutputStream(bufferSize);
+ copy(inputStream, os, bufferSize);
+ return os.toByteArray();
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ static void copy(InputStream in, OutputStream out, int bufferSize) throws IOException {
+ byte[] buffer = new byte[bufferSize];
+ int len;
+ while ((len = in.read(buffer, 0, bufferSize)) > 0) {
+ out.write(buffer, 0, len);
+ }
+ }
+
+}
diff --git a/avaje-jex/src/main/java/io/avaje/jex/jetty/JettyBuilder.java b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JettyBuilder.java
similarity index 83%
rename from avaje-jex/src/main/java/io/avaje/jex/jetty/JettyBuilder.java
rename to avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JettyBuilder.java
index 2707131d..1b14bb71 100644
--- a/avaje-jex/src/main/java/io/avaje/jex/jetty/JettyBuilder.java
+++ b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JettyBuilder.java
@@ -19,11 +19,11 @@ class JettyBuilder {
private static final Logger log = LoggerFactory.getLogger(JettyBuilder.class);
private final Jex.Inner inner;
- private final Jex.Jetty config;
+ private final JettyServerConfig config;
- JettyBuilder(Jex jex) {
+ JettyBuilder(Jex jex, JettyServerConfig config) {
this.inner = jex.inner;
- this.config = jex.jetty;
+ this.config = config;
}
Server build() {
@@ -38,10 +38,10 @@ Server build() {
}
private ThreadPool pool() {
- if (config.virtualThreads) {
+ if (config.virtualThreads()) {
return virtualThreadBasePool();
} else {
- return config.maxThreads == 0 ? new QueuedThreadPool() : new QueuedThreadPool(config.maxThreads);
+ return config.maxThreads() == 0 ? new QueuedThreadPool() : new QueuedThreadPool(config.maxThreads());
}
}
diff --git a/avaje-jex/src/main/java/io/avaje/jex/jetty/JettyLaunch.java b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JettyLaunch.java
similarity index 70%
rename from avaje-jex/src/main/java/io/avaje/jex/jetty/JettyLaunch.java
rename to avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JettyLaunch.java
index a1000b08..279c3e13 100644
--- a/avaje-jex/src/main/java/io/avaje/jex/jetty/JettyLaunch.java
+++ b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JettyLaunch.java
@@ -1,10 +1,12 @@
package io.avaje.jex.jetty;
import io.avaje.jex.Jex;
+import io.avaje.jex.ServerConfig;
import io.avaje.jex.StaticFileSource;
-import io.avaje.jex.core.ServiceManager;
-import io.avaje.jex.routes.RoutesBuilder;
+import io.avaje.jex.UploadConfig;
+import io.avaje.jex.spi.SpiServiceManager;
import io.avaje.jex.spi.SpiRoutes;
+import jakarta.servlet.MultipartConfigElement;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -27,11 +29,31 @@ class JettyLaunch implements Jex.Server {
private final Jex jex;
private final SpiRoutes routes;
+ private final ServiceManager serviceManager;
+ private final JettyServerConfig config;
private Server server;
- JettyLaunch(Jex jex) {
+ JettyLaunch(Jex jex, SpiRoutes routes, SpiServiceManager serviceManager) {
this.jex = jex;
- this.routes = new RoutesBuilder(jex.routing(), jex).build();
+ this.routes = routes;
+ this.serviceManager = new ServiceManager(serviceManager, initMultiPart());
+ this.config = initConfig(jex.serverConfig());
+ }
+
+ private JettyServerConfig initConfig(ServerConfig config) {
+ return config == null ? new JettyServerConfig() : (JettyServerConfig)config;
+ }
+
+ MultipartUtil initMultiPart() {
+ return new MultipartUtil(initMultipartConfigElement(jex.inner.multipartConfig));
+ }
+
+ MultipartConfigElement initMultipartConfigElement(UploadConfig uploadConfig) {
+ if (uploadConfig == null) {
+ final int fileThreshold = jex.inner.multipartFileThreshold;
+ return new MultipartConfigElement(System.getProperty("java.io.tmpdir"), -1, -1, fileThreshold);
+ }
+ return new MultipartConfigElement(uploadConfig.location(), uploadConfig.maxFileSize(), uploadConfig.maxRequestSize(), uploadConfig.fileSizeThreshold());
}
@Override
@@ -62,11 +84,11 @@ protected Server createServer() {
}
protected Server initServer() {
- Server server = jex.jetty.server;
+ Server server = config.server();
if (server != null) {
return server;
}
- return new JettyBuilder(jex).build();
+ return new JettyBuilder(jex, config).build();
}
protected ServletContextHandler initContextHandler() {
@@ -77,18 +99,17 @@ protected ServletContextHandler initContextHandler() {
}
protected ServletHolder initServletHolder() {
- final ServiceManager manager = serviceManager();
final StaticHandler staticHandler = initStaticHandler();
- return new ServletHolder(new JexHttpServlet(jex, routes, manager, staticHandler));
+ return new ServletHolder(new JexHttpServlet(jex, routes, serviceManager, staticHandler));
}
protected ServletContextHandler initServletContextHandler() {
- final ServletContextHandler ch = jex.jetty.contextHandler;
- return ch != null ? ch : new ContextHandler(jex.inner.contextPath, jex.jetty.sessions, jex.jetty.security);
+ final ServletContextHandler ch = config.contextHandler();
+ return ch != null ? ch : new ContextHandler(jex.inner.contextPath, config.sessions(), config.security());
}
protected SessionHandler initSessionHandler() {
- SessionHandler sh = jex.jetty.sessionHandler;
+ SessionHandler sh = config.sessionHandler();
return sh == null ? defaultSessionHandler() : sh;
}
@@ -107,13 +128,9 @@ protected StaticHandler initStaticHandler() {
return factory.build(jex, fileSources);
}
- protected ServiceManager serviceManager() {
- return ServiceManager.create(jex);
- }
-
private void logOnStart(org.eclipse.jetty.server.Server server) {
for (Connector c : server.getConnectors()) {
- String virtualThreads = jex.jetty.virtualThreads ? "with virtualThreads" : "";
+ String virtualThreads = config.virtualThreads() ? "with virtualThreads" : "";
if (c instanceof ServerConnector) {
ServerConnector sc = (ServerConnector) c;
String host = (sc.getHost() == null) ? "0.0.0.0" : sc.getHost();
diff --git a/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JettyServerConfig.java b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JettyServerConfig.java
new file mode 100644
index 00000000..f09bc5fa
--- /dev/null
+++ b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JettyServerConfig.java
@@ -0,0 +1,87 @@
+package io.avaje.jex.jetty;
+
+import io.avaje.jex.ServerConfig;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.session.SessionHandler;
+import org.eclipse.jetty.servlet.ServletContextHandler;
+
+public class JettyServerConfig implements ServerConfig {
+
+ private boolean sessions = true;
+ private boolean security = true;
+ /**
+ * Set true to use Loom virtual threads for ThreadPool.
+ * This requires JDK 17 with Loom included.
+ */
+ private boolean virtualThreads;
+ /**
+ * Set maxThreads when using default QueuedThreadPool. Defaults to 200.
+ */
+ private int maxThreads;
+ private SessionHandler sessionHandler;
+ private ServletContextHandler contextHandler;
+ private org.eclipse.jetty.server.Server server;
+
+ public boolean sessions() {
+ return sessions;
+ }
+
+ public JettyServerConfig sessions(boolean sessions) {
+ this.sessions = sessions;
+ return this;
+ }
+
+ public boolean security() {
+ return security;
+ }
+
+ public JettyServerConfig security(boolean security) {
+ this.security = security;
+ return this;
+ }
+
+ public boolean virtualThreads() {
+ return virtualThreads;
+ }
+
+ public JettyServerConfig virtualThreads(boolean virtualThreads) {
+ this.virtualThreads = virtualThreads;
+ return this;
+ }
+
+ public int maxThreads() {
+ return maxThreads;
+ }
+
+ public JettyServerConfig maxThreads(int maxThreads) {
+ this.maxThreads = maxThreads;
+ return this;
+ }
+
+ public SessionHandler sessionHandler() {
+ return sessionHandler;
+ }
+
+ public JettyServerConfig sessionHandler(SessionHandler sessionHandler) {
+ this.sessionHandler = sessionHandler;
+ return this;
+ }
+
+ public ServletContextHandler contextHandler() {
+ return contextHandler;
+ }
+
+ public JettyServerConfig contextHandler(ServletContextHandler contextHandler) {
+ this.contextHandler = contextHandler;
+ return this;
+ }
+
+ public Server server() {
+ return server;
+ }
+
+ public JettyServerConfig server(Server server) {
+ this.server = server;
+ return this;
+ }
+}
diff --git a/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JettyStartServer.java b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JettyStartServer.java
new file mode 100644
index 00000000..390fd419
--- /dev/null
+++ b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JettyStartServer.java
@@ -0,0 +1,18 @@
+package io.avaje.jex.jetty;
+
+import io.avaje.jex.Jex;
+import io.avaje.jex.spi.SpiRoutes;
+import io.avaje.jex.spi.SpiServiceManager;
+import io.avaje.jex.spi.SpiStartServer;
+
+/**
+ * Configure and starts the underlying Jetty server.
+ */
+public class JettyStartServer implements SpiStartServer {
+
+ @Override
+ public Jex.Server start(Jex jex, SpiRoutes routes, SpiServiceManager serviceManager) {
+ return new JettyLaunch(jex, routes, serviceManager)
+ .start();
+ }
+}
diff --git a/avaje-jex/src/main/java/io/avaje/jex/jetty/JexHttpContext.java b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JexHttpContext.java
similarity index 86%
rename from avaje-jex/src/main/java/io/avaje/jex/jetty/JexHttpContext.java
rename to avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JexHttpContext.java
index 6dac57b2..7f021296 100644
--- a/avaje-jex/src/main/java/io/avaje/jex/jetty/JexHttpContext.java
+++ b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JexHttpContext.java
@@ -3,12 +3,10 @@
import io.avaje.jex.Context;
import io.avaje.jex.Routing;
import io.avaje.jex.UploadedFile;
-import io.avaje.jex.core.HeaderKeys;
-import io.avaje.jex.core.ServiceManager;
import io.avaje.jex.http.RedirectResponse;
+import io.avaje.jex.spi.HeaderKeys;
import io.avaje.jex.spi.SpiContext;
import io.avaje.jex.spi.SpiRoutes;
-import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
@@ -18,6 +16,7 @@
import java.io.OutputStream;
import java.io.UncheckedIOException;
import java.nio.charset.Charset;
+import java.time.Duration;
import java.util.*;
import java.util.stream.Stream;
@@ -61,17 +60,15 @@ public void setMode(Routing.Type mode) {
private String characterEncoding() {
if (characterEncoding == null) {
- characterEncoding = ContextUtil.getRequestCharset(this);
+ characterEncoding = mgr.requestCharset(this);
}
return characterEncoding;
}
- @Override
public HttpServletRequest req() {
return req;
}
- @Override
public HttpServletResponse res() {
return res;
}
@@ -101,9 +98,9 @@ public Map attributeMap() {
@Override
public String cookie(String name) {
- final Cookie[] cookies = req.getCookies();
+ final jakarta.servlet.http.Cookie[] cookies = req.getCookies();
if (cookies != null) {
- for (Cookie cookie : cookies) {
+ for (jakarta.servlet.http.Cookie cookie : cookies) {
if (cookie.getName().equals(name)) {
return cookie.getValue();
}
@@ -114,36 +111,50 @@ public String cookie(String name) {
@Override
public Map cookieMap() {
- final Cookie[] cookies = req.getCookies();
+ final jakarta.servlet.http.Cookie[] cookies = req.getCookies();
if (cookies == null) {
return emptyMap();
}
final Map map = new LinkedHashMap<>();
- for (Cookie cookie : cookies) {
+ for (jakarta.servlet.http.Cookie cookie : cookies) {
map.put(cookie.getName(), cookie.getValue());
}
return map;
}
@Override
- public Context cookie(String name, String value) {
- return cookie(name, value, -1);
+ public Context cookie(Cookie cookie) {
+ final jakarta.servlet.http.Cookie newCookie = new jakarta.servlet.http.Cookie(cookie.name(), cookie.value());
+ newCookie.setPath(cookie.path());
+ if (newCookie.getPath() == null) {
+ newCookie.setPath("/");
+ }
+ final String domain = cookie.domain();
+ if (domain != null) {
+ newCookie.setDomain(domain);
+ }
+ final Duration duration = cookie.maxAge();
+ if (duration != null) {
+ newCookie.setMaxAge((int)duration.toSeconds());
+ }
+ newCookie.setHttpOnly(cookie.httpOnly());
+ newCookie.setSecure(cookie.secure());
+ res.addCookie(newCookie);
+ return this;
}
@Override
public Context cookie(String name, String value, int maxAge) {
- final Cookie cookie = new Cookie(name, value);
+ final jakarta.servlet.http.Cookie cookie = new jakarta.servlet.http.Cookie(name, value);
+ cookie.setPath("/");
cookie.setMaxAge(maxAge);
- return cookie(cookie);
+ res.addCookie(cookie);
+ return this;
}
@Override
- public Context cookie(Cookie cookie) {
- if (cookie.getPath() == null) {
- cookie.setPath("/");
- }
- res.addCookie(cookie);
- return this;
+ public Context cookie(String name, String value) {
+ return cookie(name, value, -1);
}
@Override
@@ -156,7 +167,7 @@ public Context removeCookie(String name, String path) {
if (path == null) {
path = "/";
}
- final Cookie cookie = new Cookie(name, "");
+ final jakarta.servlet.http.Cookie cookie = new jakarta.servlet.http.Cookie(name, "");
cookie.setPath(path);
cookie.setMaxAge(0);
res.addCookie(cookie);
@@ -177,6 +188,11 @@ public void redirect(String location, int statusCode) {
}
}
+ @Override
+ public void performRedirect() {
+ // do nothing
+ }
+
@Override
public String matchedPath() {
return matchedPath;
@@ -267,23 +283,6 @@ public String queryString() {
return req.getQueryString();
}
- @Override
- public String formParam(String key) {
- return formParam(key, null);
- }
-
- @Override
- public String formParam(String key, String defaultValue) {
- final List values = formParamMap().get(key);
- return values == null || values.isEmpty() ? defaultValue : values.get(0);
- }
-
- @Override
- public List formParams(String key) {
- final List values = formParamMap().get(key);
- return values != null ? values : emptyList();
- }
-
@Override
public Map> formParamMap() {
if (formParamMap == null) {
@@ -296,7 +295,7 @@ private Map> initFormParamMap() {
if (isMultipartFormData()) {
return mgr.multiPartForm(req);
} else {
- return ContextUtil.formParamMap(body(), characterEncoding());
+ return mgr.formParamMap(this, characterEncoding());
}
}
@@ -336,8 +335,9 @@ public String url() {
@Override
public String fullUrl() {
+ final String url = url();
final String qs = queryString();
- return qs == null ? url() : url() + "?" + qs;
+ return qs == null ? url : url + "?" + qs;
}
@Override
@@ -345,11 +345,6 @@ public String contextPath() {
return req.getContextPath();
}
- @Override
- public String userAgent() {
- return req.getHeader(HeaderKeys.USER_AGENT);
- }
-
@Override
public Context status(int statusCode) {
res.setStatus(statusCode);
@@ -382,14 +377,20 @@ public Map headerMap() {
return map;
}
+ @Override
+ public String responseHeader(String key) {
+ return req.getHeader(key);
+ }
+
@Override
public String header(String key) {
return req.getHeader(key);
}
@Override
- public void header(String key, String value) {
+ public Context header(String key, String value) {
res.setHeader(key, value);
+ return this;
}
@Override
@@ -434,18 +435,6 @@ public String protocol() {
return req.getProtocol();
}
- @Override
- public Context text(String content) {
- res.setContentType(TEXT_PLAIN);
- return write(content);
- }
-
- @Override
- public Context html(String content) {
- res.setContentType(TEXT_HTML);
- return write(content);
- }
-
@Override
public Context json(Object bean) {
contentType(APPLICATION_JSON);
@@ -467,6 +456,24 @@ public Context jsonStream(Iterator iterator) {
return this;
}
+ /**
+ * Write plain text content to the response.
+ */
+ @Override
+ public Context text(String content) {
+ contentType(TEXT_PLAIN);
+ return write(content);
+ }
+
+ /**
+ * Write html content to the response.
+ */
+ @Override
+ public Context html(String content) {
+ contentType(TEXT_HTML);
+ return write(content);
+ }
+
@Override
public Context write(String content) {
try {
@@ -477,11 +484,6 @@ public Context write(String content) {
}
}
- @Override
- public Context render(String name) {
- return render(name, emptyMap());
- }
-
@Override
public Context render(String name, Map model) {
mgr.render(this, name, model);
diff --git a/avaje-jex/src/main/java/io/avaje/jex/jetty/JexHttpServlet.java b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JexHttpServlet.java
similarity index 82%
rename from avaje-jex/src/main/java/io/avaje/jex/jetty/JexHttpServlet.java
rename to avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JexHttpServlet.java
index 69eda78b..a7551c80 100644
--- a/avaje-jex/src/main/java/io/avaje/jex/jetty/JexHttpServlet.java
+++ b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JexHttpServlet.java
@@ -3,17 +3,12 @@
import io.avaje.jex.Context;
import io.avaje.jex.Jex;
import io.avaje.jex.Routing;
-import io.avaje.jex.core.HttpMethodMap;
-import io.avaje.jex.core.ServiceManager;
import io.avaje.jex.http.NotFoundResponse;
import io.avaje.jex.spi.SpiContext;
import io.avaje.jex.spi.SpiRoutes;
-
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
-import java.util.Collections;
-import java.util.Map;
class JexHttpServlet extends HttpServlet {
@@ -21,7 +16,6 @@ class JexHttpServlet extends HttpServlet {
private final SpiRoutes routes;
private final ServiceManager manager;
private final StaticHandler staticHandler;
- private final HttpMethodMap methodMap = new HttpMethodMap();
private final boolean prefer405;
JexHttpServlet(Jex jex, SpiRoutes routes, ServiceManager manager, StaticHandler staticHandler) {
@@ -37,7 +31,7 @@ protected void service(HttpServletRequest req, HttpServletResponse res) {
final String uri = req.getRequestURI();
SpiRoutes.Entry route = routes.match(routeType, uri);
if (route == null) {
- SpiContext ctx = new JexHttpContext(manager, req, res, uri);
+ var ctx = new JexHttpContext(manager, req, res, uri);
try {
processNoRoute(ctx, uri, routeType);
routes.after(uri, ctx);
@@ -46,7 +40,7 @@ protected void service(HttpServletRequest req, HttpServletResponse res) {
}
} else {
final SpiRoutes.Params params = route.pathParams(uri);
- SpiContext ctx = new JexHttpContext(manager, req, res, route.matchPath(), params);
+ var ctx = new JexHttpContext(manager, req, res, route.matchPath(), params);
try {
processRoute(ctx, uri, route);
routes.after(uri, ctx);
@@ -56,17 +50,17 @@ protected void service(HttpServletRequest req, HttpServletResponse res) {
}
}
- private void handleException(Context ctx, Exception e) {
+ private void handleException(SpiContext ctx, Exception e) {
manager.handleException(ctx, e);
}
- private void processRoute(SpiContext ctx, String uri, SpiRoutes.Entry route) {
+ private void processRoute(JexHttpContext ctx, String uri, SpiRoutes.Entry route) {
routes.before(uri, ctx);
ctx.setMode(null);
route.handle(ctx);
}
- private void processNoRoute(SpiContext ctx, String uri, Routing.Type routeType) {
+ private void processNoRoute(JexHttpContext ctx, String uri, Routing.Type routeType) {
routes.before(uri, ctx);
if (routeType == Routing.Type.HEAD && hasGetHandler(uri)) {
processHead(ctx);
@@ -100,6 +94,6 @@ private boolean hasGetHandler(String uri) {
}
private Routing.Type method(HttpServletRequest req) {
- return methodMap.get(req.getMethod());
+ return manager.lookupRoutingType(req.getMethod());
}
}
diff --git a/avaje-jex/src/main/java/io/avaje/jex/core/MultipartUtil.java b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/MultipartUtil.java
similarity index 99%
rename from avaje-jex/src/main/java/io/avaje/jex/core/MultipartUtil.java
rename to avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/MultipartUtil.java
index b417c939..dea863bb 100644
--- a/avaje-jex/src/main/java/io/avaje/jex/core/MultipartUtil.java
+++ b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/MultipartUtil.java
@@ -1,11 +1,11 @@
-package io.avaje.jex.core;
+package io.avaje.jex.jetty;
import io.avaje.jex.UploadedFile;
-
import jakarta.servlet.MultipartConfigElement;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.Part;
+
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
diff --git a/avaje-jex/src/main/java/io/avaje/jex/core/PartUploadedFile.java b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/PartUploadedFile.java
similarity index 97%
rename from avaje-jex/src/main/java/io/avaje/jex/core/PartUploadedFile.java
rename to avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/PartUploadedFile.java
index b499dd28..712ed3b6 100644
--- a/avaje-jex/src/main/java/io/avaje/jex/core/PartUploadedFile.java
+++ b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/PartUploadedFile.java
@@ -1,8 +1,8 @@
-package io.avaje.jex.core;
+package io.avaje.jex.jetty;
import io.avaje.jex.UploadedFile;
-
import jakarta.servlet.http.Part;
+
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
diff --git a/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/ServiceManager.java b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/ServiceManager.java
new file mode 100644
index 00000000..5eb0ddbc
--- /dev/null
+++ b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/ServiceManager.java
@@ -0,0 +1,34 @@
+package io.avaje.jex.jetty;
+
+import io.avaje.jex.UploadedFile;
+import io.avaje.jex.spi.ProxyServiceManager;
+import io.avaje.jex.spi.SpiServiceManager;
+import jakarta.servlet.http.HttpServletRequest;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Jetty specific service manager.
+ */
+class ServiceManager extends ProxyServiceManager {
+
+ private final MultipartUtil multipartUtil;
+
+ ServiceManager(SpiServiceManager delegate, MultipartUtil multipartUtil) {
+ super(delegate);
+ this.multipartUtil = multipartUtil;
+ }
+
+ List uploadedFiles(HttpServletRequest req) {
+ return multipartUtil.uploadedFiles(req);
+ }
+
+ List uploadedFiles(HttpServletRequest req, String name) {
+ return multipartUtil.uploadedFiles(req, name);
+ }
+
+ Map> multiPartForm(HttpServletRequest req) {
+ return multipartUtil.fieldMap(req);
+ }
+}
diff --git a/avaje-jex/src/main/java/io/avaje/jex/jetty/StaticHandler.java b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/StaticHandler.java
similarity index 100%
rename from avaje-jex/src/main/java/io/avaje/jex/jetty/StaticHandler.java
rename to avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/StaticHandler.java
index 52f071be..1238748e 100644
--- a/avaje-jex/src/main/java/io/avaje/jex/jetty/StaticHandler.java
+++ b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/StaticHandler.java
@@ -1,6 +1,8 @@
package io.avaje.jex.jetty;
import io.avaje.jex.StaticFileSource;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.server.handler.ResourceHandler;
import org.eclipse.jetty.util.resource.EmptyResource;
@@ -8,8 +10,6 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
diff --git a/avaje-jex/src/main/java/io/avaje/jex/jetty/StaticHandlerFactory.java b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/StaticHandlerFactory.java
similarity index 100%
rename from avaje-jex/src/main/java/io/avaje/jex/jetty/StaticHandlerFactory.java
rename to avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/StaticHandlerFactory.java
diff --git a/avaje-jex-jetty/src/main/java/module-info.java b/avaje-jex-jetty/src/main/java/module-info.java
new file mode 100644
index 00000000..9f767e38
--- /dev/null
+++ b/avaje-jex-jetty/src/main/java/module-info.java
@@ -0,0 +1,22 @@
+import io.avaje.jex.jetty.JettyStartServer;
+import io.avaje.jex.spi.SpiStartServer;
+
+module io.avaje.jex.jetty {
+
+ exports io.avaje.jex.jetty;
+
+ requires transitive io.avaje.jex;
+ //requires io.avaje.jex.jettyx;
+ requires java.net.http;
+ requires transitive jetty.servlet.api;
+ requires transitive org.slf4j;
+ requires transitive org.eclipse.jetty.http;
+ requires transitive org.eclipse.jetty.servlet;
+ requires transitive org.eclipse.jetty.server;
+ requires transitive org.eclipse.jetty.io;
+ requires transitive org.eclipse.jetty.util;
+
+ requires transitive com.fasterxml.jackson.databind;
+
+ provides SpiStartServer with JettyStartServer;
+}
diff --git a/avaje-jex-jetty/src/main/resources/META-INF/services/io.avaje.jex.spi.SpiStartServer b/avaje-jex-jetty/src/main/resources/META-INF/services/io.avaje.jex.spi.SpiStartServer
new file mode 100644
index 00000000..8adaab64
--- /dev/null
+++ b/avaje-jex-jetty/src/main/resources/META-INF/services/io.avaje.jex.spi.SpiStartServer
@@ -0,0 +1 @@
+io.avaje.jex.jetty.JettyStartServer
diff --git a/avaje-jex/src/test/java/io/avaje/jex/base/AppRoles.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/AppRoles.java
similarity index 100%
rename from avaje-jex/src/test/java/io/avaje/jex/base/AppRoles.java
rename to avaje-jex-jetty/src/test/java/io/avaje/jex/base/AppRoles.java
diff --git a/avaje-jex/src/test/java/io/avaje/jex/base/AutoCloseIterator.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/AutoCloseIterator.java
similarity index 100%
rename from avaje-jex/src/test/java/io/avaje/jex/base/AutoCloseIterator.java
rename to avaje-jex-jetty/src/test/java/io/avaje/jex/base/AutoCloseIterator.java
diff --git a/avaje-jex/src/test/java/io/avaje/jex/base/CharacterEncodingTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/CharacterEncodingTest.java
similarity index 100%
rename from avaje-jex/src/test/java/io/avaje/jex/base/CharacterEncodingTest.java
rename to avaje-jex-jetty/src/test/java/io/avaje/jex/base/CharacterEncodingTest.java
diff --git a/avaje-jex/src/test/java/io/avaje/jex/base/ContextAttributeTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/ContextAttributeTest.java
similarity index 100%
rename from avaje-jex/src/test/java/io/avaje/jex/base/ContextAttributeTest.java
rename to avaje-jex-jetty/src/test/java/io/avaje/jex/base/ContextAttributeTest.java
diff --git a/avaje-jex/src/test/java/io/avaje/jex/base/ContextCookieTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/ContextCookieTest.java
similarity index 64%
rename from avaje-jex/src/test/java/io/avaje/jex/base/ContextCookieTest.java
rename to avaje-jex-jetty/src/test/java/io/avaje/jex/base/ContextCookieTest.java
index b3fefff2..a01e72f1 100644
--- a/avaje-jex/src/test/java/io/avaje/jex/base/ContextCookieTest.java
+++ b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/ContextCookieTest.java
@@ -1,10 +1,13 @@
package io.avaje.jex.base;
+import io.avaje.jex.Context;
import io.avaje.jex.Jex;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Test;
import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.time.temporal.ChronoUnit;
import static org.assertj.core.api.Assertions.assertThat;
@@ -19,8 +22,13 @@ static TestPair init() {
.get("/readCookie/{name}", ctx -> ctx.text("readCookie:" + ctx.cookie(ctx.pathParam("name"))))
.get("/readCookieMap", ctx -> ctx.text("cookieMap:" + ctx.cookieMap()))
.get("/removeCookie/{name}", ctx -> ctx.removeCookie(ctx.pathParam("name")).text("ok"))
+ .get("/setCookieAll", ctx -> {
+ final Context.Cookie cookie = Context.Cookie.of("ac", "v_all")
+ .path("/").httpOnly(true).maxAge(Duration.of(10, ChronoUnit.DAYS));
+ ctx.cookie(cookie);
+ })
);
- return TestPair.create(app);
+ return TestPair.create(app, 9001);
}
@AfterAll
@@ -30,9 +38,10 @@ static void end() {
@Test
void set_read_readMap_remove_readMap_remove_readMap() {
- HttpResponse res = pair.request()
- .path("setCookie").GET().asString();
+ HttpResponse res = pair.request().path("removeCookie").path("ac").GET().asString();
+ assertThat(res.body()).isEqualTo("ok");
+ res = pair.request().path("setCookie").GET().asString();
assertThat(res.statusCode()).isEqualTo(200);
res = pair.request().path("readCookie").path("ck").GET().asString();
@@ -57,4 +66,17 @@ void set_read_readMap_remove_readMap_remove_readMap() {
assertThat(res.body()).isEqualTo("cookieMap:{}");
}
+ @Test
+ void setAll() {
+ HttpResponse res = pair.request().path("setCookieAll").GET().asString();
+
+ assertThat(res.statusCode()).isEqualTo(200);
+
+ res = pair.request().path("readCookieMap").GET().asString();
+ assertThat(res.body()).isEqualTo("cookieMap:{ac=v_all}");
+
+ res = pair.request().path("readCookie").path("ac").GET().asString();
+ assertThat(res.body()).isEqualTo("readCookie:v_all");
+ }
+
}
diff --git a/avaje-jex/src/test/java/io/avaje/jex/base/ContextFormParamTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/ContextFormParamTest.java
similarity index 100%
rename from avaje-jex/src/test/java/io/avaje/jex/base/ContextFormParamTest.java
rename to avaje-jex-jetty/src/test/java/io/avaje/jex/base/ContextFormParamTest.java
diff --git a/avaje-jex/src/test/java/io/avaje/jex/base/ContextLengthTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/ContextLengthTest.java
similarity index 100%
rename from avaje-jex/src/test/java/io/avaje/jex/base/ContextLengthTest.java
rename to avaje-jex-jetty/src/test/java/io/avaje/jex/base/ContextLengthTest.java
diff --git a/avaje-jex/src/test/java/io/avaje/jex/base/ContextTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/ContextTest.java
similarity index 100%
rename from avaje-jex/src/test/java/io/avaje/jex/base/ContextTest.java
rename to avaje-jex-jetty/src/test/java/io/avaje/jex/base/ContextTest.java
diff --git a/avaje-jex/src/test/java/io/avaje/jex/base/ExceptionManagerTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/ExceptionManagerTest.java
similarity index 100%
rename from avaje-jex/src/test/java/io/avaje/jex/base/ExceptionManagerTest.java
rename to avaje-jex-jetty/src/test/java/io/avaje/jex/base/ExceptionManagerTest.java
diff --git a/avaje-jex/src/test/java/io/avaje/jex/base/FilterTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/FilterTest.java
similarity index 100%
rename from avaje-jex/src/test/java/io/avaje/jex/base/FilterTest.java
rename to avaje-jex-jetty/src/test/java/io/avaje/jex/base/FilterTest.java
diff --git a/avaje-jex/src/test/java/io/avaje/jex/base/HelloDto.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/HelloDto.java
similarity index 100%
rename from avaje-jex/src/test/java/io/avaje/jex/base/HelloDto.java
rename to avaje-jex-jetty/src/test/java/io/avaje/jex/base/HelloDto.java
diff --git a/avaje-jex/src/test/java/io/avaje/jex/base/JsonTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/JsonTest.java
similarity index 100%
rename from avaje-jex/src/test/java/io/avaje/jex/base/JsonTest.java
rename to avaje-jex-jetty/src/test/java/io/avaje/jex/base/JsonTest.java
diff --git a/avaje-jex/src/test/java/io/avaje/jex/base/MultipartFormPostTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/MultipartFormPostTest.java
similarity index 100%
rename from avaje-jex/src/test/java/io/avaje/jex/base/MultipartFormPostTest.java
rename to avaje-jex-jetty/src/test/java/io/avaje/jex/base/MultipartFormPostTest.java
diff --git a/avaje-jex/src/test/java/io/avaje/jex/base/NestedRoutesTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/NestedRoutesTest.java
similarity index 100%
rename from avaje-jex/src/test/java/io/avaje/jex/base/NestedRoutesTest.java
rename to avaje-jex-jetty/src/test/java/io/avaje/jex/base/NestedRoutesTest.java
diff --git a/avaje-jex/src/test/java/io/avaje/jex/base/RedirectTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/RedirectTest.java
similarity index 100%
rename from avaje-jex/src/test/java/io/avaje/jex/base/RedirectTest.java
rename to avaje-jex-jetty/src/test/java/io/avaje/jex/base/RedirectTest.java
diff --git a/avaje-jex/src/test/java/io/avaje/jex/base/Roles.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/Roles.java
similarity index 100%
rename from avaje-jex/src/test/java/io/avaje/jex/base/Roles.java
rename to avaje-jex-jetty/src/test/java/io/avaje/jex/base/Roles.java
diff --git a/avaje-jex/src/test/java/io/avaje/jex/base/RolesTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/RolesTest.java
similarity index 100%
rename from avaje-jex/src/test/java/io/avaje/jex/base/RolesTest.java
rename to avaje-jex-jetty/src/test/java/io/avaje/jex/base/RolesTest.java
diff --git a/avaje-jex/src/test/java/io/avaje/jex/base/RouteRegexTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/RouteRegexTest.java
similarity index 100%
rename from avaje-jex/src/test/java/io/avaje/jex/base/RouteRegexTest.java
rename to avaje-jex-jetty/src/test/java/io/avaje/jex/base/RouteRegexTest.java
diff --git a/avaje-jex/src/test/java/io/avaje/jex/base/RouteSplatTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/RouteSplatTest.java
similarity index 97%
rename from avaje-jex/src/test/java/io/avaje/jex/base/RouteSplatTest.java
rename to avaje-jex-jetty/src/test/java/io/avaje/jex/base/RouteSplatTest.java
index 1ea61c5e..220cf00e 100644
--- a/avaje-jex/src/test/java/io/avaje/jex/base/RouteSplatTest.java
+++ b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/RouteSplatTest.java
@@ -4,9 +4,7 @@
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Test;
-import java.net.URLEncoder;
import java.net.http.HttpResponse;
-import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
diff --git a/avaje-jex/src/test/java/io/avaje/jex/base/SimpleTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/SimpleTest.java
similarity index 100%
rename from avaje-jex/src/test/java/io/avaje/jex/base/SimpleTest.java
rename to avaje-jex-jetty/src/test/java/io/avaje/jex/base/SimpleTest.java
diff --git a/avaje-jex/src/test/java/io/avaje/jex/base/StaticContentTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/StaticContentTest.java
similarity index 100%
rename from avaje-jex/src/test/java/io/avaje/jex/base/StaticContentTest.java
rename to avaje-jex-jetty/src/test/java/io/avaje/jex/base/StaticContentTest.java
diff --git a/avaje-jex/src/test/java/io/avaje/jex/base/TestPair.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/TestPair.java
similarity index 93%
rename from avaje-jex/src/test/java/io/avaje/jex/base/TestPair.java
rename to avaje-jex-jetty/src/test/java/io/avaje/jex/base/TestPair.java
index 25e5b9be..5ce2290e 100644
--- a/avaje-jex/src/test/java/io/avaje/jex/base/TestPair.java
+++ b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/TestPair.java
@@ -40,12 +40,15 @@ public String url() {
return client.url().build();
}
+ public static TestPair create(Jex app) {
+ int port = 10000 + new Random().nextInt(1000);
+ return create(app, port);
+ }
+
/**
* Create a Server and Client pair for a given set of tests.
*/
- public static TestPair create(Jex app) {
-
- int port = 10000 + new Random().nextInt(1000);
+ public static TestPair create(Jex app, int port) {
var jexServer = app.port(port).start();
var url = "http://localhost:" + port;
diff --git a/avaje-jex/src/test/java/io/avaje/jex/base/VerbTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/VerbTest.java
similarity index 100%
rename from avaje-jex/src/test/java/io/avaje/jex/base/VerbTest.java
rename to avaje-jex-jetty/src/test/java/io/avaje/jex/base/VerbTest.java
diff --git a/avaje-jex-jetty/src/test/resources/logback-test.xml b/avaje-jex-jetty/src/test/resources/logback-test.xml
new file mode 100644
index 00000000..ddb21350
--- /dev/null
+++ b/avaje-jex-jetty/src/test/resources/logback-test.xml
@@ -0,0 +1,19 @@
+
+
+
+ TRACE
+
+
+ %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/avaje-jex/src/test/resources/static-a/goodbye.html b/avaje-jex-jetty/src/test/resources/static-a/goodbye.html
similarity index 100%
rename from avaje-jex/src/test/resources/static-a/goodbye.html
rename to avaje-jex-jetty/src/test/resources/static-a/goodbye.html
diff --git a/avaje-jex/src/test/resources/static-a/hello.txt b/avaje-jex-jetty/src/test/resources/static-a/hello.txt
similarity index 100%
rename from avaje-jex/src/test/resources/static-a/hello.txt
rename to avaje-jex-jetty/src/test/resources/static-a/hello.txt
diff --git a/avaje-jex/src/test/resources/static-a/hello2.txt b/avaje-jex-jetty/src/test/resources/static-a/hello2.txt
similarity index 100%
rename from avaje-jex/src/test/resources/static-a/hello2.txt
rename to avaje-jex-jetty/src/test/resources/static-a/hello2.txt
diff --git a/avaje-jex/test-static-files/basic.html b/avaje-jex-jetty/test-static-files/basic.html
similarity index 100%
rename from avaje-jex/test-static-files/basic.html
rename to avaje-jex-jetty/test-static-files/basic.html
diff --git a/avaje-jex/test-static-files/index.html b/avaje-jex-jetty/test-static-files/index.html
similarity index 100%
rename from avaje-jex/test-static-files/index.html
rename to avaje-jex-jetty/test-static-files/index.html
diff --git a/avaje-jex/test-static-files/plain-file.txt b/avaje-jex-jetty/test-static-files/plain-file.txt
similarity index 100%
rename from avaje-jex/test-static-files/plain-file.txt
rename to avaje-jex-jetty/test-static-files/plain-file.txt
diff --git a/avaje-jex-mustache/pom.xml b/avaje-jex-mustache/pom.xml
index 2365599e..4c07054c 100644
--- a/avaje-jex-mustache/pom.xml
+++ b/avaje-jex-mustache/pom.xml
@@ -28,6 +28,13 @@
true
+
+ io.avaje
+ avaje-jex-jetty
+ 1.8-SNAPSHOT
+ provided
+
+
io.avaje
avaje-jex-test
@@ -37,4 +44,17 @@
+
+
+
+ maven-surefire-plugin
+
+
+ --add-modules io.avaje.jex.jetty
+
+
+
+
+
+
diff --git a/avaje-jex-mustache/src/main/java/module-info.java b/avaje-jex-mustache/src/main/java/module-info.java
index 77d4f013..d2edc28c 100644
--- a/avaje-jex-mustache/src/main/java/module-info.java
+++ b/avaje-jex-mustache/src/main/java/module-info.java
@@ -3,6 +3,7 @@
requires transitive io.avaje.jex;
requires transitive com.github.mustachejava;
requires java.net.http;
+ requires static io.avaje.jex.jetty;
provides io.avaje.jex.TemplateRender with io.avaje.jex.render.mustache.MustacheRender;
}
diff --git a/avaje-jex/pom.xml b/avaje-jex/pom.xml
index 6412992a..4743dc2e 100644
--- a/avaje-jex/pom.xml
+++ b/avaje-jex/pom.xml
@@ -21,15 +21,10 @@
- org.eclipse.jetty
- jetty-servlet
- ${jetty.version}
-
-
-
- io.avaje
- avaje-jex-jetty
- 1.0
+ com.fasterxml.jackson.core
+ jackson-databind
+ 2.12.3
+ true
diff --git a/avaje-jex/src/main/java/io/avaje/jex/AppLifecycle.java b/avaje-jex/src/main/java/io/avaje/jex/AppLifecycle.java
new file mode 100644
index 00000000..8e30a065
--- /dev/null
+++ b/avaje-jex/src/main/java/io/avaje/jex/AppLifecycle.java
@@ -0,0 +1,56 @@
+package io.avaje.jex;
+
+/**
+ * Application lifecycle support.
+ */
+public interface AppLifecycle {
+
+ enum Status {
+ STARTING,
+ STARTED,
+ STOPPING,
+ STOPPED
+ }
+
+ /**
+ * Register the runnable with the Runtime as a shutdown hook.
+ */
+ void registerShutdownHook(Runnable onShutdown);
+
+ /**
+ * Return the current status.
+ */
+ Status status();
+
+ /**
+ * Set the current status.
+ */
+ void status(Status newStatus);
+
+ /**
+ * Return true if status starting or started (the server is coming up).
+ */
+ default boolean isAlive() {
+ Status status = status();
+ return status == Status.STARTING || status == Status.STARTED;
+ }
+
+ /**
+ * Return true the server has started.
+ */
+ default boolean isReady() {
+ Status status = status();
+ return status == Status.STARTED;
+ }
+
+// void register(Listener listener);
+//
+// interface Listener {
+// void onChange(StatusChange change);
+// }
+//
+// interface StatusChange {
+// Status newStatus();
+// Status oldStatus();
+// }
+}
diff --git a/avaje-jex/src/main/java/io/avaje/jex/Context.java b/avaje-jex/src/main/java/io/avaje/jex/Context.java
index 8dad68a8..3383006a 100644
--- a/avaje-jex/src/main/java/io/avaje/jex/Context.java
+++ b/avaje-jex/src/main/java/io/avaje/jex/Context.java
@@ -1,14 +1,20 @@
package io.avaje.jex;
-import jakarta.servlet.http.Cookie;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
+import io.avaje.jex.spi.HeaderKeys;
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
+import static java.util.Collections.emptyList;
+import static java.util.Collections.emptyMap;
+
/**
* Provides access to functions for handling the request and response.
*/
@@ -21,6 +27,7 @@ public interface Context {
/**
* Sets an attribute on the request.
+ *
* Attributes are available to other handlers in the request lifecycle
*/
Context attribute(String key, Object value);
@@ -31,8 +38,11 @@ public interface Context {
T attribute(String key);
/**
+ * Deprecated for removal - not supported by JDK http server.
+ *
* Gets a map with all the attribute keys and values on the request.
*/
+ @Deprecated
Map attributeMap();
/**
@@ -163,17 +173,25 @@ public interface Context {
/**
* Return the first form param value for the specified key or null.
*/
- String formParam(String key);
+ default String formParam(String key) {
+ return formParam(key, null);
+ }
/**
* Return the first form param value for the specified key or the default value.
*/
- String formParam(String key, String defaultValue);
+ default String formParam(String key, String defaultValue) {
+ final List values = formParamMap().get(key);
+ return values == null || values.isEmpty() ? defaultValue : values.get(0);
+ }
/**
* Return the form params for the specified key, or empty list.
*/
- List formParams(String key);
+ default List formParams(String key) {
+ final List values = formParamMap().get(key);
+ return values != null ? values : emptyList();
+ }
/**
* Returns a map with all the form param keys and values.
@@ -208,7 +226,11 @@ public interface Context {
/**
* Return the full request url, including query string (if present)
*/
- String fullUrl();
+ default String fullUrl() {
+ final String url = url();
+ final String qs = queryString();
+ return qs == null ? url : url + "?" + qs;
+ }
/**
* Return the request context path.
@@ -218,7 +240,9 @@ public interface Context {
/**
* Return the request user agent, or null.
*/
- String userAgent();
+ default String userAgent() {
+ return header(HeaderKeys.USER_AGENT);
+ }
/**
* Set the status code on the response.
@@ -271,7 +295,9 @@ public interface Context {
*
* @param name The template name
*/
- Context render(String name);
+ default Context render(String name) {
+ return render(name, emptyMap());
+ }
/**
* Render a template typically as html with the given model.
@@ -299,7 +325,12 @@ public interface Context {
* @param key The header key
* @param value The header value
*/
- void header(String key, String value);
+ Context header(String key, String value);
+
+ /**
+ * Return the response header.
+ */
+ String responseHeader(String key);
/**
* Returns the request host, or null.
@@ -341,16 +372,6 @@ public interface Context {
*/
String protocol();
- /**
- * Return the underlying http servlet request.
- */
- HttpServletRequest req();
-
- /**
- * Return the underlying http servlet response.
- */
- HttpServletResponse res();
-
/**
* Return the first UploadedFile for the specified name or null.
*/
@@ -366,4 +387,134 @@ public interface Context {
*/
List uploadedFiles();
+ class Cookie {
+ private static final ZonedDateTime EXPIRED = ZonedDateTime.of(LocalDateTime.of(2000, 1, 1, 0, 0, 0), ZoneId.of("GMT"));
+ private static final DateTimeFormatter RFC_1123_DATE_TIME = DateTimeFormatter.RFC_1123_DATE_TIME;
+ private static final String PARAM_SEPARATOR = "; ";
+ private final String name; // NAME= ... "$Name" style is reserved
+ private final String value; // value of NAME
+ private String domain; // ;Domain=VALUE ... domain that sees cookie
+ private ZonedDateTime expires;
+ private Duration maxAge;// = -1; // ;Max-Age=VALUE ... cookies auto-expire
+ private String path; // ;Path=VALUE ... URLs that see the cookie
+ private boolean secure; // ;Secure ... e.g. use SSL
+ private boolean httpOnly;
+
+ private Cookie(String name, String value) {
+ if (name == null || name.length() == 0) {
+ throw new IllegalArgumentException("name required");
+ }
+ this.name = name;
+ this.value = value;
+ }
+
+ public static Cookie expired(String name) {
+ return new Cookie(name, "").expires(EXPIRED);
+ }
+
+ public static Cookie of(String name, String value) {
+ return new Cookie(name, value);
+ }
+
+ public String name() {
+ return name;
+ }
+
+ public String value() {
+ return value;
+ }
+
+ public String domain() {
+ return domain;
+ }
+
+ public Cookie domain(String domain) {
+ this.domain = domain;
+ return this;
+ }
+
+ public Duration maxAge() {
+ return maxAge;
+ }
+
+ public Cookie maxAge(Duration maxAge) {
+ this.maxAge = maxAge;
+ return this;
+ }
+
+ public ZonedDateTime expires() {
+ return expires;
+ }
+
+ public Cookie expires(ZonedDateTime expires) {
+ this.expires = expires;
+ return this;
+ }
+
+ public String path() {
+ return path;
+ }
+
+ public Cookie path(String path) {
+ this.path = path;
+ return this;
+ }
+
+ public boolean secure() {
+ return secure;
+ }
+
+ public Cookie secure(boolean secure) {
+ this.secure = secure;
+ return this;
+ }
+
+ public boolean httpOnly() {
+ return httpOnly;
+ }
+
+ public Cookie httpOnly(boolean httpOnly) {
+ this.httpOnly = httpOnly;
+ return this;
+ }
+
+ /**
+ * Returns content of this instance as a 'Set-Cookie:' header value specified
+ * by RFC6265.
+ */
+ @Override
+ public String toString() {
+ StringBuilder result = new StringBuilder(60);
+ result.append(name).append('=').append(value);
+ if (expires != null) {
+ result.append(PARAM_SEPARATOR);
+ result.append("Expires=");
+ result.append(expires.format(RFC_1123_DATE_TIME));
+ }
+ if ((maxAge != null) && !maxAge.isNegative() && !maxAge.isZero()) {
+ result.append(PARAM_SEPARATOR);
+ result.append("Max-Age=");
+ result.append(maxAge.getSeconds());
+ }
+ if (domain != null) {
+ result.append(PARAM_SEPARATOR);
+ result.append("Domain=");
+ result.append(domain);
+ }
+ if (path != null) {
+ result.append(PARAM_SEPARATOR);
+ result.append("Path=");
+ result.append(path);
+ }
+ if (secure) {
+ result.append(PARAM_SEPARATOR);
+ result.append("Secure");
+ }
+ if (httpOnly) {
+ result.append(PARAM_SEPARATOR);
+ result.append("HttpOnly");
+ }
+ return result.toString();
+ }
+ }
}
diff --git a/avaje-jex/src/main/java/io/avaje/jex/DefaultLifecycle.java b/avaje-jex/src/main/java/io/avaje/jex/DefaultLifecycle.java
new file mode 100644
index 00000000..120cae89
--- /dev/null
+++ b/avaje-jex/src/main/java/io/avaje/jex/DefaultLifecycle.java
@@ -0,0 +1,89 @@
+package io.avaje.jex;
+
+import java.util.concurrent.locks.ReentrantLock;
+
+class DefaultLifecycle implements AppLifecycle {
+
+ //private final List listeners = new ArrayList<>();
+
+ private final ReentrantLock lock = new ReentrantLock();
+
+ private Status status = Status.STARTING;
+
+ private Hook hook;
+
+ @Override
+ public void registerShutdownHook(Runnable onShutdown) {
+// final Thread unstarted = Thread.ofVirtual().unstarted(onShutdown);
+ hook = new Hook(onShutdown);
+ Runtime.getRuntime().addShutdownHook(hook);
+ }
+
+// public void removeShutdownHook() {
+// if (hook != null) {
+// Runtime.getRuntime().removeShutdownHook(hook);
+// }
+// }
+
+ static class Hook extends Thread {
+ Hook(Runnable runnable) {
+ super(runnable);
+ }
+ }
+
+ @Override
+ public Status status() {
+ return status;
+ }
+
+ @Override
+ public void status(Status newStatus) {
+ lock.lock();
+ try {
+ if (status != newStatus) {
+ //Status oldStatus = status;
+ status = newStatus;
+ //onChange(new Event(newStatus, oldStatus));
+ }
+ } finally {
+ lock.unlock();
+ }
+ }
+
+// @Override
+// public void register(Listener listener) {
+// lock.lock();
+// try {
+// listeners.add(listener);
+// } finally {
+// lock.unlock();
+// }
+// }
+//
+// private void onChange(Event event) {
+// for (Listener listener : listeners) {
+// listener.onChange(event);
+// }
+// }
+//
+// private static class Event implements AppLifecycle.StatusChange {
+//
+// final Status newStatus;
+// final Status oldStatus;
+//
+// Event(Status newStatus, Status oldStatus) {
+// this.newStatus = newStatus;
+// this.oldStatus = oldStatus;
+// }
+//
+// @Override
+// public Status newStatus() {
+// return newStatus;
+// }
+//
+// @Override
+// public Status oldStatus() {
+// return oldStatus;
+// }
+// }
+}
diff --git a/avaje-jex/src/main/java/io/avaje/jex/Jex.java b/avaje-jex/src/main/java/io/avaje/jex/Jex.java
index 0ef22693..08a47095 100644
--- a/avaje-jex/src/main/java/io/avaje/jex/Jex.java
+++ b/avaje-jex/src/main/java/io/avaje/jex/Jex.java
@@ -1,17 +1,8 @@
package io.avaje.jex;
-import io.avaje.jex.jetty.JettyStartServer;
-import io.avaje.jex.spi.JsonService;
-import io.avaje.jex.spi.SpiStartServer;
-import org.eclipse.jetty.server.session.SessionHandler;
-import org.eclipse.jetty.servlet.ServletContextHandler;
-
-import jakarta.servlet.MultipartConfigElement;
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Optional;
-import java.util.ServiceLoader;
+import io.avaje.jex.spi.*;
+
+import java.util.*;
import java.util.function.Consumer;
/**
@@ -34,10 +25,12 @@ public class Jex {
private final Routing routing = new DefaultRouting();
private final ErrorHandling errorHandling = new DefaultErrorHandling();
+ private final AppLifecycle lifecycle = new DefaultLifecycle();
private final StaticFileConfig staticFiles;
+ private final Map, Object> attributes = new HashMap<>();
public final Inner inner = new Inner();
- public final Jetty jetty = new Jetty();
+ private ServerConfig serverConfig;
private Jex() {
this.staticFiles = new DefaultStaticFileConfig(this);
@@ -50,6 +43,22 @@ public static Jex create() {
return new Jex();
}
+ /**
+ * Set a custom attribute that can be used by an implementation.
+ */
+ public Jex attribute(Class cls, T instance) {
+ attributes.put(cls, instance);
+ return this;
+ }
+
+ /**
+ * Return a custom attribute.
+ */
+ @SuppressWarnings("unchecked")
+ public T attribute(Class cls) {
+ return (T) attributes.get(cls);
+ }
+
public static class Inner {
public int port = 7001;
public String host;
@@ -60,30 +69,11 @@ public static class Inner {
public boolean preCompressStaticFiles;
public JsonService jsonService;
public AccessManager accessManager;
- public MultipartConfigElement multipartConfig;
+ public UploadConfig multipartConfig;
public int multipartFileThreshold = 8 * 1024;
public final Map renderers = new HashMap<>();
}
- /**
- * Jetty specific configuration options.
- */
- public static class Jetty {
- public boolean sessions = true;
- public boolean security = true;
- /**
- * Set true to use Loom virtual threads for ThreadPool.
- * This requires JDK 17 with Loom included.
- */
- public boolean virtualThreads;
- /**
- * Set maxThreads when using default QueuedThreadPool. Defaults to 200.
- */
- public int maxThreads;
- public SessionHandler sessionHandler;
- public ServletContextHandler contextHandler;
- public org.eclipse.jetty.server.Server server;
- }
/**
* Configure error handlers.
@@ -100,6 +90,21 @@ public ErrorHandling errorHandling() {
return errorHandling;
}
+ /**
+ * Return the server specific configuration.
+ */
+ public ServerConfig serverConfig() {
+ return serverConfig;
+ }
+
+ /**
+ * Set the server specific configuration.
+ */
+ public Jex serverConfig(ServerConfig serverConfig) {
+ this.serverConfig = serverConfig;
+ return this;
+ }
+
/**
* Add routes and handlers to the routing.
*/
@@ -123,7 +128,7 @@ public Routing routing() {
return routing;
}
- /***
+ /**
* Set the AccessManager.
*/
public Jex accessManager(AccessManager accessManager) {
@@ -131,7 +136,7 @@ public Jex accessManager(AccessManager accessManager) {
return this;
}
- /***
+ /**
* Set the JsonService.
*/
public Jex jsonService(JsonService jsonService) {
@@ -139,6 +144,14 @@ public Jex jsonService(JsonService jsonService) {
return this;
}
+ /**
+ * Add Plugin functionality.
+ */
+ public Jex configure(Plugin plugin) {
+ plugin.apply(this);
+ return this;
+ }
+
/**
* Configure via a lambda taking the jex instance.
*/
@@ -199,11 +212,26 @@ public Jex register(TemplateRender renderer, String... extensions) {
* Start the server.
*/
public Server start() {
+ final SpiRoutes routes = ServiceLoader.load(SpiRoutesProvider.class)
+ .findFirst().get()
+ .create(this.routing, this.inner.accessManager, this.inner.ignoreTrailingSlashes);
+
+ final SpiServiceManager serviceManager = ServiceLoader.load(SpiServiceManagerProvider.class)
+ .findFirst().get()
+ .create(this);
+
final Optional start = ServiceLoader.load(SpiStartServer.class).findFirst();
if (start.isEmpty()) {
- return new JettyStartServer().start(this);
+ throw new IllegalStateException("There is no SpiStartServer? Missing dependency on jex-jetty?");
}
- return start.get().start(this);
+ return start.get().start(this, routes, serviceManager);
+ }
+
+ /**
+ * Return the application lifecycle support.
+ */
+ public AppLifecycle lifecycle() {
+ return lifecycle;
}
/**
diff --git a/avaje-jex/src/main/java/io/avaje/jex/Plugin.java b/avaje-jex/src/main/java/io/avaje/jex/Plugin.java
new file mode 100644
index 00000000..a9feb473
--- /dev/null
+++ b/avaje-jex/src/main/java/io/avaje/jex/Plugin.java
@@ -0,0 +1,12 @@
+package io.avaje.jex;
+
+/**
+ * A plugin that can register things like routes, exception handlers etc.
+ */
+public interface Plugin {
+
+ /**
+ * Register the plugin features with jex.
+ */
+ void apply(Jex jex);
+}
diff --git a/avaje-jex/src/main/java/io/avaje/jex/ServerConfig.java b/avaje-jex/src/main/java/io/avaje/jex/ServerConfig.java
new file mode 100644
index 00000000..7f860f57
--- /dev/null
+++ b/avaje-jex/src/main/java/io/avaje/jex/ServerConfig.java
@@ -0,0 +1,7 @@
+package io.avaje.jex;
+
+/**
+ * Marker for server specific configuration.
+ */
+public interface ServerConfig {
+}
diff --git a/avaje-jex/src/main/java/io/avaje/jex/UploadConfig.java b/avaje-jex/src/main/java/io/avaje/jex/UploadConfig.java
new file mode 100644
index 00000000..640bc08a
--- /dev/null
+++ b/avaje-jex/src/main/java/io/avaje/jex/UploadConfig.java
@@ -0,0 +1,58 @@
+package io.avaje.jex;
+
+/**
+ * Configuration for server handling of Multipart file uploads etc.
+ */
+public class UploadConfig {
+
+ private String location;
+ private long maxFileSize;
+ private long maxRequestSize;
+ private int fileSizeThreshold;
+
+ public UploadConfig() {
+ }
+
+ public UploadConfig(String location, long maxFileSize, long maxRequestSize, int fileSizeThreshold) {
+ this.location = location;
+ this.maxFileSize = maxFileSize;
+ this.maxRequestSize = maxRequestSize;
+ this.fileSizeThreshold = fileSizeThreshold;
+ }
+
+ public String location() {
+ return location;
+ }
+
+ public UploadConfig location(String location) {
+ this.location = location;
+ return this;
+ }
+
+ public long maxFileSize() {
+ return maxFileSize;
+ }
+
+ public UploadConfig maxFileSize(long maxFileSize) {
+ this.maxFileSize = maxFileSize;
+ return this;
+ }
+
+ public long maxRequestSize() {
+ return maxRequestSize;
+ }
+
+ public UploadConfig maxRequestSize(long maxRequestSize) {
+ this.maxRequestSize = maxRequestSize;
+ return this;
+ }
+
+ public int fileSizeThreshold() {
+ return fileSizeThreshold;
+ }
+
+ public UploadConfig fileSizeThreshold(int fileSizeThreshold) {
+ this.fileSizeThreshold = fileSizeThreshold;
+ return this;
+ }
+}
diff --git a/avaje-jex/src/main/java/io/avaje/jex/core/BootstapServiceManager.java b/avaje-jex/src/main/java/io/avaje/jex/core/BootstapServiceManager.java
new file mode 100644
index 00000000..1895a6e5
--- /dev/null
+++ b/avaje-jex/src/main/java/io/avaje/jex/core/BootstapServiceManager.java
@@ -0,0 +1,12 @@
+package io.avaje.jex.core;
+
+import io.avaje.jex.Jex;
+import io.avaje.jex.spi.SpiServiceManager;
+import io.avaje.jex.spi.SpiServiceManagerProvider;
+
+public class BootstapServiceManager implements SpiServiceManagerProvider {
+ @Override
+ public SpiServiceManager create(Jex jex) {
+ return CoreServiceManager.create(jex);
+ }
+}
diff --git a/avaje-jex/src/main/java/io/avaje/jex/core/ServiceManager.java b/avaje-jex/src/main/java/io/avaje/jex/core/CoreServiceManager.java
similarity index 51%
rename from avaje-jex/src/main/java/io/avaje/jex/core/ServiceManager.java
rename to avaje-jex/src/main/java/io/avaje/jex/core/CoreServiceManager.java
index f1992399..fba4735f 100644
--- a/avaje-jex/src/main/java/io/avaje/jex/core/ServiceManager.java
+++ b/avaje-jex/src/main/java/io/avaje/jex/core/CoreServiceManager.java
@@ -1,23 +1,25 @@
package io.avaje.jex.core;
-import io.avaje.jex.Context;
-import io.avaje.jex.ErrorHandling;
-import io.avaje.jex.Jex;
-import io.avaje.jex.TemplateRender;
-import io.avaje.jex.UploadedFile;
+import io.avaje.jex.*;
+import io.avaje.jex.spi.HeaderKeys;
import io.avaje.jex.spi.JsonService;
import io.avaje.jex.spi.SpiContext;
+import io.avaje.jex.spi.SpiServiceManager;
-import jakarta.servlet.MultipartConfigElement;
-import jakarta.servlet.http.HttpServletRequest;
-
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map;
-import java.util.ServiceLoader;
+import java.io.UncheckedIOException;
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+import java.util.*;
import java.util.stream.Stream;
-public class ServiceManager {
+/**
+ * Core implementation of SpiServiceManager provided to specific implementations like jetty etc.
+ */
+class CoreServiceManager implements SpiServiceManager {
+
+ public static final String UTF_8 = "UTF-8";
+
+ private final HttpMethodMap methodMap = new HttpMethodMap();
private final JsonService jsonService;
@@ -25,33 +27,34 @@ public class ServiceManager {
private final TemplateManager templateManager;
- private final MultipartUtil multipartUtil;
-
- public static ServiceManager create(Jex jex) {
+ static SpiServiceManager create(Jex jex) {
return new Builder(jex).build();
}
- ServiceManager(JsonService jsonService, ErrorHandling errorHandling, TemplateManager templateManager, MultipartUtil multipartUtil) {
+ CoreServiceManager(JsonService jsonService, ErrorHandling errorHandling, TemplateManager templateManager) {
this.jsonService = jsonService;
this.exceptionHandler = new ExceptionManager(errorHandling);
this.templateManager = templateManager;
- this.multipartUtil = multipartUtil;
}
+ @Override
public T jsonRead(Class clazz, SpiContext ctx) {
return jsonService.jsonRead(clazz, ctx);
}
+ @Override
public void jsonWrite(Object bean, SpiContext ctx) {
jsonService.jsonWrite(bean, ctx);
}
+ @Override
public void jsonWriteStream(Stream stream, SpiContext ctx) {
try (stream) {
jsonService.jsonWriteStream(stream.iterator(), ctx);
}
}
+ @Override
public void jsonWriteStream(Iterator iterator, SpiContext ctx) {
try {
jsonService.jsonWriteStream(iterator, ctx);
@@ -60,7 +63,8 @@ public void jsonWriteStream(Iterator iterator, SpiContext ctx) {
}
}
- private void maybeClose(Object iterator) {
+ @Override
+ public void maybeClose(Object iterator) {
if (AutoCloseable.class.isAssignableFrom(iterator.getClass())) {
try {
((AutoCloseable) iterator).close();
@@ -70,24 +74,61 @@ private void maybeClose(Object iterator) {
}
}
- public void handleException(Context ctx, Exception e) {
+ @Override
+ public Routing.Type lookupRoutingType(String method) {
+ return methodMap.get(method);
+ }
+
+ @Override
+ public void handleException(SpiContext ctx, Exception e) {
exceptionHandler.handle(ctx, e);
}
+ @Override
public void render(Context ctx, String name, Map model) {
templateManager.render(ctx, name, model);
}
- public List uploadedFiles(HttpServletRequest req) {
- return multipartUtil.uploadedFiles(req);
+
+ @Override
+ public String requestCharset(Context ctx) {
+ return parseCharset(ctx.header(HeaderKeys.CONTENT_TYPE));
}
- public List uploadedFiles(HttpServletRequest req, String name) {
- return multipartUtil.uploadedFiles(req, name);
+ static String parseCharset(String header) {
+ if (header != null) {
+ for (String val : header.split(";")) {
+ val = val.trim();
+ if (val.regionMatches(true, 0, "charset", 0, "charset".length())) {
+ return val.split("=")[1].trim();
+ }
+ }
+ }
+ return UTF_8;
}
- public Map> multiPartForm(HttpServletRequest req) {
- return multipartUtil.fieldMap(req);
+ @Override
+ public Map> formParamMap(Context ctx, String charset) {
+ return parseParamMap(ctx.body(), charset);
+ }
+
+ @Override
+ public Map> parseParamMap(String body, String charset) {
+ if (body == null || body.isEmpty()) {
+ return Collections.emptyMap();
+ }
+ try {
+ Map> map = new LinkedHashMap<>();
+ for (String pair : body.split("&")) {
+ final String[] split1 = pair.split("=", 2);
+ String key = URLDecoder.decode(split1[0], charset);
+ String val = split1.length > 1 ? URLDecoder.decode(split1[1], charset) : "";
+ map.computeIfAbsent(key, s -> new ArrayList<>()).add(val);
+ }
+ return map;
+ } catch (UnsupportedEncodingException e) {
+ throw new UncheckedIOException(e);
+ }
}
private static class Builder {
@@ -97,8 +138,8 @@ private static class Builder {
this.jex = jex;
}
- ServiceManager build() {
- return new ServiceManager(initJsonService(), jex.errorHandling(), initTemplateMgr(), initMultiPart());
+ SpiServiceManager build() {
+ return new CoreServiceManager(initJsonService(), jex.errorHandling(), initTemplateMgr());
}
JsonService initJsonService() {
@@ -131,13 +172,5 @@ TemplateManager initTemplateMgr() {
return mgr;
}
- MultipartUtil initMultiPart() {
- MultipartConfigElement config = jex.inner.multipartConfig;
- if (config == null) {
- final int fileThreshold = jex.inner.multipartFileThreshold;
- config = new MultipartConfigElement(System.getProperty("java.io.tmpdir"), -1, -1, fileThreshold);
- }
- return new MultipartUtil(config);
- }
}
}
diff --git a/avaje-jex/src/main/java/io/avaje/jex/core/ExceptionManager.java b/avaje-jex/src/main/java/io/avaje/jex/core/ExceptionManager.java
index 0a5efec4..553ff720 100644
--- a/avaje-jex/src/main/java/io/avaje/jex/core/ExceptionManager.java
+++ b/avaje-jex/src/main/java/io/avaje/jex/core/ExceptionManager.java
@@ -1,25 +1,26 @@
package io.avaje.jex.core;
-import io.avaje.jex.Context;
import io.avaje.jex.ErrorHandling;
import io.avaje.jex.ExceptionHandler;
import io.avaje.jex.http.HttpResponseException;
import io.avaje.jex.http.InternalServerErrorResponse;
import io.avaje.jex.http.RedirectResponse;
+import io.avaje.jex.spi.HeaderKeys;
+import io.avaje.jex.spi.SpiContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-public class ExceptionManager {
+class ExceptionManager {
private static final Logger log = LoggerFactory.getLogger(ExceptionManager.class);
private final ErrorHandling errorHandling;
- public ExceptionManager(ErrorHandling errorHandling) {
+ ExceptionManager(ErrorHandling errorHandling) {
this.errorHandling = errorHandling;
}
- public void handle(Context ctx, Exception e) {
+ void handle(SpiContext ctx, Exception e) {
final ExceptionHandler handler = errorHandling.find(e.getClass());
if (handler != null) {
handler.handle(e, ctx);
@@ -32,7 +33,7 @@ public void handle(Context ctx, Exception e) {
}
}
- private void unhandledException(Context ctx, Exception e) {
+ private void unhandledException(SpiContext ctx, Exception e) {
log.warn("Uncaught exception", e);
defaultHandling(ctx, new InternalServerErrorResponse());
}
@@ -45,12 +46,11 @@ private boolean isRedirect(Exception e) {
return RedirectResponse.class.isAssignableFrom(e.getClass());
}
- private void defaultHandling(Context ctx, Exception exception) {
+ private void defaultHandling(SpiContext ctx, Exception exception) {
final HttpResponseException e = unwrap(exception);
ctx.status(e.getStatus());
if (isRedirect(e)) {
- // no content
- log.trace("redirect");
+ ctx.performRedirect();
} else if (useJson(ctx)) {
ctx.contentType("application/json").write(asJsonContent(e));
} else {
@@ -83,10 +83,10 @@ private HttpResponseException unwrap(Exception e) {
//(if (e is CompletionException) e.cause else e) as HttpResponseException
}
- private boolean useJson(Context ctx) {
+ private boolean useJson(SpiContext ctx) {
final String acceptHeader = ctx.header(HeaderKeys.ACCEPT);
return (acceptHeader != null && acceptHeader.contains("application/json")
- || "application/json".equals(ctx.res().getContentType()));
+ || "application/json".equals(ctx.responseHeader(HeaderKeys.CONTENT_TYPE)));
}
}
diff --git a/avaje-jex/src/main/java/io/avaje/jex/core/HealthPlugin.java b/avaje-jex/src/main/java/io/avaje/jex/core/HealthPlugin.java
new file mode 100644
index 00000000..8ea2da11
--- /dev/null
+++ b/avaje-jex/src/main/java/io/avaje/jex/core/HealthPlugin.java
@@ -0,0 +1,38 @@
+package io.avaje.jex.core;
+
+import io.avaje.jex.AppLifecycle;
+import io.avaje.jex.Context;
+import io.avaje.jex.Jex;
+import io.avaje.jex.Plugin;
+
+/**
+ * Health plugin with liveness and readiness support based on
+ * the application lifecycle support.
+ */
+public class HealthPlugin implements Plugin {
+
+ private AppLifecycle lifecycle;
+
+ @Override
+ public void apply(Jex jex) {
+ lifecycle = jex.lifecycle();
+ jex.routing().get("/health/liveness", this::liveness);
+ jex.routing().get("/health/readiness", this::readiness);
+ }
+
+ private void readiness(Context context) {
+ if (lifecycle.isReady()) {
+ context.text("ok");
+ } else {
+ context.status(500).text("not-ready");
+ }
+ }
+
+ private void liveness(Context context) {
+ if (lifecycle.isAlive()) {
+ context.text("ok");
+ } else {
+ context.status(500).text("not-alive");
+ }
+ }
+}
diff --git a/avaje-jex/src/main/java/io/avaje/jex/core/HttpMethodMap.java b/avaje-jex/src/main/java/io/avaje/jex/core/HttpMethodMap.java
index e0a9884a..d44dca71 100644
--- a/avaje-jex/src/main/java/io/avaje/jex/core/HttpMethodMap.java
+++ b/avaje-jex/src/main/java/io/avaje/jex/core/HttpMethodMap.java
@@ -5,17 +5,17 @@
import java.util.HashMap;
import java.util.Map;
-public final class HttpMethodMap {
+final class HttpMethodMap {
private final Map map = new HashMap<>();
- public HttpMethodMap() {
+ HttpMethodMap() {
for (Routing.Type value : Routing.Type.values()) {
map.put(value.name(), value);
}
}
- public Routing.Type get(String method) {
+ Routing.Type get(String method) {
return map.get(method);
}
}
diff --git a/avaje-jex/src/main/java/io/avaje/jex/core/JacksonJsonService.java b/avaje-jex/src/main/java/io/avaje/jex/core/JacksonJsonService.java
index 800cb244..0c14cdf9 100644
--- a/avaje-jex/src/main/java/io/avaje/jex/core/JacksonJsonService.java
+++ b/avaje-jex/src/main/java/io/avaje/jex/core/JacksonJsonService.java
@@ -52,16 +52,21 @@ public void jsonWriteStream(Iterator iterator, SpiContext ctx) {
try {
generator = mapper.createGenerator(ctx.outputStream());
generator.setPrettyPrinter(null);
- } catch (IOException e) {
- throw new UncheckedIOException(e);
- }
- while (iterator.hasNext()) {
try {
- mapper.writeValue(generator, iterator.next());
- generator.writeRaw('\n');
- } catch (IOException e) {
- throw new UncheckedIOException(e);
+ while (iterator.hasNext()) {
+ try {
+ mapper.writeValue(generator, iterator.next());
+ generator.writeRaw('\n');
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+ } finally {
+ generator.flush();
+ generator.close();
}
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
}
}
}
diff --git a/avaje-jex/src/main/java/io/avaje/jex/jetty/ContextUtil.java b/avaje-jex/src/main/java/io/avaje/jex/jetty/ContextUtil.java
deleted file mode 100644
index cec20c6b..00000000
--- a/avaje-jex/src/main/java/io/avaje/jex/jetty/ContextUtil.java
+++ /dev/null
@@ -1,90 +0,0 @@
-package io.avaje.jex.jetty;
-
-import io.avaje.jex.core.HeaderKeys;
-
-import jakarta.servlet.ServletInputStream;
-import jakarta.servlet.http.HttpServletRequest;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.io.UncheckedIOException;
-import java.io.UnsupportedEncodingException;
-import java.net.URLDecoder;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-
-class ContextUtil {
-
- public static final String UTF_8 = "UTF-8";
-
- private static final int DEFAULT_BUFFER_SIZE = 8 * 1024;
-
- private static final int BUFFER_MAX = 65536;
-
- static String getRequestCharset(JexHttpContext ctx) {
- final String header = ctx.req.getHeader(HeaderKeys.CONTENT_TYPE);
- if (header != null) {
- return parseCharset(header);
- }
- return UTF_8;
- }
-
- static String parseCharset(String header) {
- for (String val : header.split(";")) {
- val = val.trim();
- if (val.regionMatches(true, 0, "charset", 0, "charset".length())) {
- return val.split("=")[1].trim();
- }
- }
- return "UTF-8";
- }
-
- static byte[] readBody(HttpServletRequest req) {
- try {
- final ServletInputStream inputStream = req.getInputStream();
-
- int bufferSize = inputStream.available();
- if (bufferSize < DEFAULT_BUFFER_SIZE) {
- bufferSize = DEFAULT_BUFFER_SIZE;
- } else if (bufferSize > BUFFER_MAX) {
- bufferSize = BUFFER_MAX;
- }
-
- ByteArrayOutputStream os = new ByteArrayOutputStream(bufferSize);
- copy(inputStream, os, bufferSize);
- return os.toByteArray();
- } catch (IOException e) {
- throw new UncheckedIOException(e);
- }
- }
-
- static void copy(InputStream in, OutputStream out, int bufferSize) throws IOException {
- byte[] buffer = new byte[bufferSize];
- int len;
- while ((len = in.read(buffer, 0, bufferSize)) > 0) {
- out.write(buffer, 0, len);
- }
- }
-
- static Map> formParamMap(String body, String charset) {
- if (body.isEmpty()) {
- return Collections.emptyMap();
- }
- try {
- Map> map = new LinkedHashMap<>();
- for (String pair : body.split("&")) {
- final String[] split1 = pair.split("=", 2);
- String key = URLDecoder.decode(split1[0], charset);
- String val = split1.length > 1 ? URLDecoder.decode(split1[1], charset) : "";
- map.computeIfAbsent(key, s -> new ArrayList<>()).add(val);
- }
- return map;
- } catch (UnsupportedEncodingException e) {
- throw new UncheckedIOException(e);
- }
- }
-}
diff --git a/avaje-jex/src/main/java/io/avaje/jex/jetty/JettyStartServer.java b/avaje-jex/src/main/java/io/avaje/jex/jetty/JettyStartServer.java
deleted file mode 100644
index 787566ac..00000000
--- a/avaje-jex/src/main/java/io/avaje/jex/jetty/JettyStartServer.java
+++ /dev/null
@@ -1,15 +0,0 @@
-package io.avaje.jex.jetty;
-
-import io.avaje.jex.Jex;
-import io.avaje.jex.spi.SpiStartServer;
-
-/**
- * Configure and starts the underlying Jetty server.
- */
-public class JettyStartServer implements SpiStartServer {
-
- @Override
- public Jex.Server start(Jex jex) {
- return new JettyLaunch(jex).start();
- }
-}
diff --git a/avaje-jex/src/main/java/io/avaje/jex/routes/BootstrapRoutes.java b/avaje-jex/src/main/java/io/avaje/jex/routes/BootstrapRoutes.java
new file mode 100644
index 00000000..49b6449b
--- /dev/null
+++ b/avaje-jex/src/main/java/io/avaje/jex/routes/BootstrapRoutes.java
@@ -0,0 +1,14 @@
+package io.avaje.jex.routes;
+
+import io.avaje.jex.AccessManager;
+import io.avaje.jex.Routing;
+import io.avaje.jex.spi.SpiRoutes;
+import io.avaje.jex.spi.SpiRoutesProvider;
+
+public class BootstrapRoutes implements SpiRoutesProvider {
+
+ @Override
+ public SpiRoutes create(Routing routing, AccessManager accessManager, boolean ignoreTrailingSlashes) {
+ return new RoutesBuilder(routing, accessManager, ignoreTrailingSlashes).build();
+ }
+}
diff --git a/avaje-jex/src/main/java/io/avaje/jex/routes/FilterEntry.java b/avaje-jex/src/main/java/io/avaje/jex/routes/FilterEntry.java
index b2cbdcae..50f82d4d 100644
--- a/avaje-jex/src/main/java/io/avaje/jex/routes/FilterEntry.java
+++ b/avaje-jex/src/main/java/io/avaje/jex/routes/FilterEntry.java
@@ -24,6 +24,22 @@ class FilterEntry implements SpiRoutes.Entry {
this.handler = entry.getHandler();
}
+ @Override
+ public void inc() {
+ // do nothing
+ }
+
+ @Override
+ public void dec() {
+ // do nothing
+ }
+
+ @Override
+ public long activeRequests() {
+ // always zero for filters
+ return 0;
+ }
+
@Override
public String matchPath() {
return path;
diff --git a/avaje-jex/src/main/java/io/avaje/jex/routes/RouteEntry.java b/avaje-jex/src/main/java/io/avaje/jex/routes/RouteEntry.java
index a8d442d8..ef080914 100644
--- a/avaje-jex/src/main/java/io/avaje/jex/routes/RouteEntry.java
+++ b/avaje-jex/src/main/java/io/avaje/jex/routes/RouteEntry.java
@@ -4,8 +4,11 @@
import io.avaje.jex.Handler;
import io.avaje.jex.spi.SpiRoutes;
+import java.util.concurrent.atomic.AtomicLong;
+
class RouteEntry implements SpiRoutes.Entry {
+ private final AtomicLong active = new AtomicLong();
private final PathParser path;
private final Handler handler;
@@ -14,6 +17,21 @@ class RouteEntry implements SpiRoutes.Entry {
this.handler = handler;
}
+ @Override
+ public void inc() {
+ active.incrementAndGet();
+ }
+
+ @Override
+ public void dec() {
+ active.decrementAndGet();
+ }
+
+ @Override
+ public long activeRequests() {
+ return active.get();
+ }
+
@Override
public boolean matches(String requestUri) {
return path.matches(requestUri);
diff --git a/avaje-jex/src/main/java/io/avaje/jex/routes/RouteIndex.java b/avaje-jex/src/main/java/io/avaje/jex/routes/RouteIndex.java
index aa94aeb9..cbd02b04 100644
--- a/avaje-jex/src/main/java/io/avaje/jex/routes/RouteIndex.java
+++ b/avaje-jex/src/main/java/io/avaje/jex/routes/RouteIndex.java
@@ -10,16 +10,16 @@ class RouteIndex {
/**
* Partition entries by the number of path segments.
*/
- private final Entry[] entries = new Entry[6];
+ private final RouteIndex.Entry[] entries = new RouteIndex.Entry[6];
/**
* Wildcard/splat based route entries.
*/
- private List wildcardEntries = new ArrayList<>();
+ private final List wildcardEntries = new ArrayList<>();
RouteIndex() {
for (int i = 0; i < entries.length; i++) {
- entries[i] = new Entry();
+ entries[i] = new RouteIndex.Entry();
}
}
@@ -63,6 +63,17 @@ private int segmentCount(String pathInfo) {
return count;
}
+ long activeRequests() {
+ long total = 0;
+ for (RouteIndex.Entry entry : entries) {
+ total += entry.activeRequests();
+ }
+ for (SpiRoutes.Entry entry : wildcardEntries) {
+ total += entry.activeRequests();
+ }
+ return total;
+ }
+
private static class Entry {
private final List list = new ArrayList<>();
@@ -79,5 +90,13 @@ SpiRoutes.Entry match(String pathInfo) {
}
return null;
}
+
+ long activeRequests() {
+ long total = 0;
+ for (SpiRoutes.Entry entry : list) {
+ total += entry.activeRequests();
+ }
+ return total;
+ }
}
}
diff --git a/avaje-jex/src/main/java/io/avaje/jex/routes/Routes.java b/avaje-jex/src/main/java/io/avaje/jex/routes/Routes.java
index a640ef1d..d223b50e 100644
--- a/avaje-jex/src/main/java/io/avaje/jex/routes/Routes.java
+++ b/avaje-jex/src/main/java/io/avaje/jex/routes/Routes.java
@@ -6,6 +6,7 @@
import java.util.EnumMap;
import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
class Routes implements SpiRoutes {
@@ -24,12 +25,33 @@ class Routes implements SpiRoutes {
*/
private final List after;
+ private final AtomicLong noRouteCounter = new AtomicLong();
+
Routes(EnumMap