diff --git a/avaje-jetty-loom/pom.xml b/avaje-jetty-loom/pom.xml new file mode 100644 index 00000000..b713869e --- /dev/null +++ b/avaje-jetty-loom/pom.xml @@ -0,0 +1,61 @@ + + + + 4.0.0 + + java11-oss + org.avaje + 3.2 + + + avaje-jex-loomjetty + io.avaje + 1.0 + + + 17 + 17 + 17 + 17 + 11.0.2 + + + + + + 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 + + + + + + + + + diff --git a/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/threadpool/VirtualThreadPool.java b/avaje-jetty-loom/src/main/java/io/avaje/jex/jetty/threadpool/VirtualThreadPool.java similarity index 100% rename from avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/threadpool/VirtualThreadPool.java rename to avaje-jetty-loom/src/main/java/io/avaje/jex/jetty/threadpool/VirtualThreadPool.java diff --git a/avaje-jex-jetty/src/main/java9/module-info.java b/avaje-jetty-loom/src/main/java9/module-info.java similarity index 100% rename from avaje-jex-jetty/src/main/java9/module-info.java rename to avaje-jetty-loom/src/main/java9/module-info.java diff --git a/avaje-jex-freemarker/pom.xml b/avaje-jex-freemarker/pom.xml index f95e249d..c842b1bf 100644 --- a/avaje-jex-freemarker/pom.xml +++ b/avaje-jex-freemarker/pom.xml @@ -1,5 +1,6 @@ - + 4.0.0 avaje-jex-parent @@ -21,6 +22,13 @@ 1.8-SNAPSHOT + + io.avaje + avaje-jex-jetty + 1.8-SNAPSHOT + provided + + org.freemarker freemarker @@ -36,4 +44,17 @@ + + + + maven-surefire-plugin + + + --add-modules io.avaje.jex.jetty + + + + + + diff --git a/avaje-jex-freemarker/src/main/java/module-info.java b/avaje-jex-freemarker/src/main/java/module-info.java index c854fc42..46e85230 100644 --- a/avaje-jex-freemarker/src/main/java/module-info.java +++ b/avaje-jex-freemarker/src/main/java/module-info.java @@ -4,5 +4,7 @@ requires transitive freemarker; requires java.net.http; + requires static io.avaje.jex.jetty; + provides io.avaje.jex.TemplateRender with io.avaje.jex.render.freemarker.FreeMarkerRender; } diff --git a/avaje-jex-grizzly/pom.xml b/avaje-jex-grizzly/pom.xml new file mode 100644 index 00000000..ba000877 --- /dev/null +++ b/avaje-jex-grizzly/pom.xml @@ -0,0 +1,50 @@ + + + + avaje-jex-parent + io.avaje + 1.8-SNAPSHOT + + 4.0.0 + + avaje-jex-grizzly + + + + + + + + + io.avaje + avaje-jex + 1.8-SNAPSHOT + + + + org.glassfish.grizzly + grizzly-http-server + 3.0.0 + + + + org.glassfish.grizzly + grizzly-http2 + 3.0.0 + true + + + + org.slf4j + jul-to-slf4j + 1.7.30 + test + + + + + + + diff --git a/avaje-jex-grizzly/src/main/java/io/avaje/jex/grizzly/ContextUtil.java b/avaje-jex-grizzly/src/main/java/io/avaje/jex/grizzly/ContextUtil.java new file mode 100644 index 00000000..82705c7d --- /dev/null +++ b/avaje-jex-grizzly/src/main/java/io/avaje/jex/grizzly/ContextUtil.java @@ -0,0 +1,65 @@ +package io.avaje.jex.grizzly; + +import org.glassfish.grizzly.http.server.Request; + +import java.io.*; + +class ContextUtil { + + private static final int DEFAULT_BUFFER_SIZE = 8 * 1024; + + private static final int BUFFER_MAX = 65536; + + static byte[] requestBodyAsBytes(Request req) { + final int len = req.getContentLength(); + try (final InputStream inputStream = req.getInputStream()) { + + int bufferSize = len > -1 ? len : DEFAULT_BUFFER_SIZE; + 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 String requestBodyAsString(Request request) { + final long requestLength = request.getContentLengthLong(); + if (requestLength == 0) { + return ""; + } + if (requestLength < 0) { + throw new IllegalStateException("No content-length set?"); + } + final int bufferSize = requestLength > 512 ? 512 : (int)requestLength; + + StringWriter writer = new StringWriter((int)requestLength); + final Reader reader = request.getReader(); + try { + long transferred = 0; + char[] buffer = new char[bufferSize]; + int nRead; + while ((nRead = reader.read(buffer, 0, bufferSize)) >= 0) { + writer.write(buffer, 0, nRead); + transferred += nRead; + if (transferred == requestLength) { + break; + } + } + return writer.toString(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/avaje-jex-grizzly/src/main/java/io/avaje/jex/grizzly/GrizzlyContext.java b/avaje-jex-grizzly/src/main/java/io/avaje/jex/grizzly/GrizzlyContext.java new file mode 100644 index 00000000..3b908640 --- /dev/null +++ b/avaje-jex-grizzly/src/main/java/io/avaje/jex/grizzly/GrizzlyContext.java @@ -0,0 +1,482 @@ +package io.avaje.jex.grizzly; + +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 org.glassfish.grizzly.http.server.Request; +import org.glassfish.grizzly.http.server.Response; +import org.glassfish.grizzly.http.util.ContentType; + +import java.io.*; +import java.util.*; +import java.util.stream.Stream; + +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; + +class GrizzlyContext implements Context, SpiContext { + + private static final ContentType JSON = ContentType.newContentType(APPLICATION_JSON); + private static final ContentType JSON_STREAM = ContentType.newContentType(APPLICATION_X_JSON_STREAM); + private static final ContentType HTML_UTF8 = ContentType.newContentType("text/html","utf-8"); + private static final ContentType PLAIN_UTF8 = ContentType.newContentType("text/plain","utf-8"); + + private static final String UTF8 = "UTF8"; + private static final int SC_MOVED_TEMPORARILY = 302; + private final ServiceManager mgr; + private final String path; + private final SpiRoutes.Params params; + private final Request request; + private final Response response; + private Routing.Type mode; + private Map> formParams; + private Map> queryParams; + private Map cookieMap; + + GrizzlyContext(ServiceManager mgr, Request request, Response response, String path, SpiRoutes.Params params) { + this.mgr = mgr; + this.request = request; + this.response = response; + this.path = path; + this.params = params; + } + + /** + * Create when no route matched. + */ + GrizzlyContext(ServiceManager mgr, Request request, Response response, String path) { + this.mgr = mgr; + this.request = request; + this.response = response; + this.path = path; + this.params = null; + } + + @Override + public String matchedPath() { + return path; + } + + @Override + public Context attribute(String key, Object value) { + request.setAttribute(key, value); + return this; + } + + @Override + @SuppressWarnings("unchecked") + public T attribute(String key) { + return (T) request.getAttribute(key); + } + + @Override + public Map attributeMap() { + throw new UnsupportedOperationException(); + } + + @Override + public Map cookieMap() { + if (cookieMap == null) { + cookieMap = new LinkedHashMap<>(); + final org.glassfish.grizzly.http.Cookie[] cookies = request.getCookies(); + for (org.glassfish.grizzly.http.Cookie cookie : cookies) { + cookieMap.put(cookie.getName(), cookie.getValue()); + } + } + return cookieMap; + } + + @Override + public String cookie(String name) { + return cookieMap().get(name); + } + + @Override + public Context cookie(Cookie cookie) { + throw new UnsupportedOperationException(); + } + + @Override + public Context cookie(String name, String value) { + throw new UnsupportedOperationException(); + } + + @Override + public Context cookie(String name, String value, int maxAge) { + throw new UnsupportedOperationException(); + } + + @Override + public Context removeCookie(String name) { + throw new UnsupportedOperationException(); + } + + @Override + public Context removeCookie(String name, String path) { + throw new UnsupportedOperationException(); + } + + @Override + public void redirect(String location) { + redirect(location, SC_MOVED_TEMPORARILY); + } + + @Override + public void redirect(String location, int statusCode) { + status(statusCode); + if (mode == Routing.Type.BEFORE) { + header(HeaderKeys.LOCATION, location); + throw new RedirectResponse(statusCode); + } else { + try { + response.sendRedirect(location); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + } + + @Override + public void performRedirect() { + // TODO check this + } + + @Override + public T bodyAsClass(Class beanType) { + return mgr.jsonRead(beanType, this); + } + + @Override + public byte[] bodyAsBytes() { + return ContextUtil.requestBodyAsBytes(request); + } + + private String characterEncoding() { + String encoding = request.getCharacterEncoding(); + return encoding != null ? encoding : UTF8; + } + + @Override + public String body() { + return ContextUtil.requestBodyAsString(request); + } + + @Override + public long contentLength() { + return request.getContentLengthLong(); + } + + @Override + public String contentType() { + return request.getContentType(); + } + + @Override + public String responseHeader(String key) { + return response.getHeader(key); + } + + @Override + public Context contentType(String contentType) { + response.setContentType(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 values = queryParams(name); + return values == null || values.isEmpty() ? null : values.get(0); + } + + private Map> queryParams() { + if (queryParams == null) { + queryParams = mgr.parseParamMap(queryString(), characterEncoding()); + } + return queryParams; + } + + @Override + public List queryParams(String name) { + final List values = queryParams().get(name); + return values == null ? emptyList() : values; + } + + @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 request.getQueryString(); + } + + /** + * Return the first form param value for the specified key or null. + */ + @Override + public String formParam(String key) { + return request.getParameter(key); + } + + /** + * Return the first form param value for the specified key or the default value. + */ + @Override + public String formParam(String key, String defaultValue) { + String value = request.getParameter(key); + return value == null ? defaultValue : value; + } + + /** + * Return the form params for the specified key, or empty list. + */ + @Override + public List formParams(String key) { + final String[] values = request.getParameterValues(key); + return values == null ? emptyList() : asList(values); + } + + @Override + public Map> formParamMap() { + if (formParams == null) { + formParams = initFormParamMap(); + } + return formParams; + } + + private Map> initFormParamMap() { + final Map parameterMap = request.getParameterMap(); + if (parameterMap.isEmpty()) { + return emptyMap(); + } + final Set> entries = parameterMap.entrySet(); + Map> map = new LinkedHashMap<>(entries.size()); + for (Map.Entry entry : entries) { + map.put(entry.getKey(), asList(entry.getValue())); + } + return map; + } + + @Override + public String scheme() { + return request.getScheme(); + } + + @Override + public Context sessionAttribute(String key, Object value) { + request.getSession().setAttribute(key, value); + return this; + } + + @Override + @SuppressWarnings("unchecked") + public T sessionAttribute(String key) { + return (T) request.getSession().getAttribute(key); + } + + @Override + public Map sessionAttributeMap() { + return request.getSession().attributes(); + } + + @Override + public String url() { + return scheme() + "://" + host() + ":" + port() + path; + } + + @Override + public String contextPath() { + return mgr.contextPath(); + } + + @Override + public Context status(int statusCode) { + response.setStatus(statusCode); + return this; + } + + @Override + public int status() { + return response.getStatus(); + } + + + @Override + public Context json(Object bean) { + response.setContentType(JSON); + mgr.jsonWrite(bean, this); + return this; + } + + @Override + public Context jsonStream(Stream stream) { + response.setContentType(JSON_STREAM); + mgr.jsonWriteStream(stream, this); + return this; + } + + @Override + public Context jsonStream(Iterator iterator) { + response.setContentType(JSON_STREAM); + mgr.jsonWriteStream(iterator, this); + return this; + } + + @Override + public Context text(String content) { + response.setContentType(PLAIN_UTF8); + return write(content); + } + + @Override + public Context html(String content) { + response.setContentType(HTML_UTF8); + return write(content); + } + + @Override + public Context write(String content) { + try { + response.getOutputBuffer().write(content); + return this; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public Context render(String name, Map model) { + mgr.render(this, name, model); + return this; + } + + @Override + public Map headerMap() { + Map map = new LinkedHashMap<>(); + for (String headerName : request.getHeaderNames()) { + map.put(headerName, request.getHeader(headerName)); + } + return map; + } + + @Override + public String header(String key) { + return request.getHeader(key); + } + + @Override + public Context header(String key, String value) { + response.setHeader(key, value); + return this; + } + + @Override + public String host() { + return request.getRemoteHost(); + } + + @Override + public String ip() { + return request.getRemoteAddr(); + } + + @Override + public boolean isMultipart() { + // TODO + return false; + } + + @Override + public boolean isMultipartFormData() { + // TODO + return false; + } + + @Override + public String method() { + return request.getMethod().getMethodString(); + } + + @Override + public String path() { + return path; + } + + @Override + public int port() { + return request.getServerPort(); + } + + @Override + public String protocol() { + return request.getProtocol().getProtocolString(); + } + + @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 response.getOutputStream(); + } + + @Override + public InputStream inputStream() { + return request.getInputStream(); + } + + @Override + public void setMode(Routing.Type type) { + this.mode = type; + } + +} diff --git a/avaje-jex-grizzly/src/main/java/io/avaje/jex/grizzly/GrizzlyJexServer.java b/avaje-jex-grizzly/src/main/java/io/avaje/jex/grizzly/GrizzlyJexServer.java new file mode 100644 index 00000000..5c8d1828 --- /dev/null +++ b/avaje-jex-grizzly/src/main/java/io/avaje/jex/grizzly/GrizzlyJexServer.java @@ -0,0 +1,51 @@ +package io.avaje.jex.grizzly; + +import io.avaje.jex.AppLifecycle; +import io.avaje.jex.Jex; +import org.glassfish.grizzly.http.server.HttpServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; + +class GrizzlyJexServer implements Jex.Server { + + private static final Logger log = LoggerFactory.getLogger(GrizzlyJexServer.class); + + private final HttpServer server; + private final AppLifecycle lifecycle; + private final ReentrantLock lock = new ReentrantLock(); + private final int maxWaitSeconds = 30; + private boolean shutdown; + + GrizzlyJexServer(HttpServer server, AppLifecycle lifecycle) { + this.server = server; + this.lifecycle = lifecycle; + lifecycle.registerShutdownHook(this::shutdown); + } + + @Override + public void shutdown() { + lock.lock(); + try { + if (shutdown) { + log.trace("shutdown in progress"); + } else { + shutdown = true; + lifecycle.status(AppLifecycle.Status.STOPPING); + log.debug("initiate shutdown with maxWaitSeconds {}", maxWaitSeconds); + try { + server.shutdown(maxWaitSeconds, TimeUnit.SECONDS).get(); + } catch (InterruptedException |ExecutionException e) { + log.error("Error during server shutdown", e); + } + lifecycle.status(AppLifecycle.Status.STOPPED); + log.info("shutdown complete"); + } + } finally { + lock.unlock(); + } + } +} diff --git a/avaje-jex-grizzly/src/main/java/io/avaje/jex/grizzly/GrizzlyServerStart.java b/avaje-jex-grizzly/src/main/java/io/avaje/jex/grizzly/GrizzlyServerStart.java new file mode 100644 index 00000000..9572daa9 --- /dev/null +++ b/avaje-jex-grizzly/src/main/java/io/avaje/jex/grizzly/GrizzlyServerStart.java @@ -0,0 +1,42 @@ +package io.avaje.jex.grizzly; + +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.glassfish.grizzly.http.server.HttpServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.UncheckedIOException; + +public class GrizzlyServerStart implements SpiStartServer { + + private static final Logger log = LoggerFactory.getLogger(GrizzlyJexServer.class); + + @Override + public Jex.Server start(Jex jex, SpiRoutes routes, SpiServiceManager serviceManager) { + + final ServiceManager manager = new ServiceManager(serviceManager, "http", ""); + RouteHandler handler = new RouteHandler(routes, manager); + + final int port = jex.inner.port; + final HttpServer httpServer = new HttpServerBuilder() + //.addHandler(clStaticHttpHandler, "cl") + //.addHandler(staticHttpHandler, "static") + .handler(handler) + .setPort(port) + .build(); + + try { + log.debug("starting server on port {}", port); + httpServer.start(); + log.info("server started on port {}", port); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + + return new GrizzlyJexServer(httpServer, jex.lifecycle()); + } +} diff --git a/avaje-jex-grizzly/src/main/java/io/avaje/jex/grizzly/HttpServerBuilder.java b/avaje-jex-grizzly/src/main/java/io/avaje/jex/grizzly/HttpServerBuilder.java new file mode 100644 index 00000000..f09f5c9f --- /dev/null +++ b/avaje-jex-grizzly/src/main/java/io/avaje/jex/grizzly/HttpServerBuilder.java @@ -0,0 +1,97 @@ +package io.avaje.jex.grizzly; + +import org.glassfish.grizzly.http.server.*; +import org.glassfish.grizzly.ssl.SSLEngineConfigurator; +import org.glassfish.grizzly.utils.Charsets; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class HttpServerBuilder { + + private static final Logger log = LoggerFactory.getLogger(GrizzlyJexServer.class); + + private int port = -1; + private String host = "0.0.0.0"; + private boolean secure; + private SSLEngineConfigurator sslEngineConfigurator; + + private final HttpServer server = new HttpServer(); + + public HttpServerBuilder setPort(int port) { + this.port = port; + return this; + } + + public HttpServerBuilder host(String host) { + this.host = host; + return this; + } + + public HttpServerBuilder sslEngineConfigurator(SSLEngineConfigurator sslEngineConfigurator) { + this.sslEngineConfigurator = sslEngineConfigurator; + return this; + } + + public HttpServerBuilder secure(boolean secure) { + this.secure = secure; + return this; + } + + /** + * Add a handler using root context. + */ + public HttpServerBuilder handler(HttpHandler handler) { + return handler(handler, ""); + } + + /** + * Add a handler with the given context. + */ + public HttpServerBuilder handler(HttpHandler handler, String context) { + handler(handler, HttpHandlerRegistration.fromString("/" + context + "/*")); + return this; + } + + /** + * Add a handler given the paths. + */ + public HttpServerBuilder handler(HttpHandler handler, HttpHandlerRegistration... paths) { + server.getServerConfiguration().addHttpHandler(handler, paths); + return this; + } + + /** + * Build and return the grizzly http server. + */ + public HttpServer build() { + + int serverPort = serverPort(); + NetworkListener listener = new NetworkListener("grizzly", host, serverPort); + + // TODO: Configure to use loom thread factory + // listener.getTransport().getWorkerThreadPoolConfig().setThreadFactory() + listener.setSecure(secure); + if (sslEngineConfigurator != null) { + listener.setSSLEngineConfig(sslEngineConfigurator); + } + addHttp2Support(listener); + server.addListener(listener); + ServerConfiguration config = server.getServerConfiguration(); + config.setPassTraceRequest(true); + config.setDefaultQueryEncoding(Charsets.UTF8_CHARSET); + return server; + } + + protected void addHttp2Support(NetworkListener listener) { + try { + Class.forName("org.glassfish.grizzly.http2.Http2AddOn"); +// listener.registerAddOn(new org.glassfish.grizzly.http2.Http2AddOn()); + } catch (Throwable e) { + log.trace("Http2AddOn was not registered"); + } + } + + protected int serverPort() { + return port != -1 ? port : (secure ? 8443 : 7001); + } +} diff --git a/avaje-jex-grizzly/src/main/java/io/avaje/jex/grizzly/RouteHandler.java b/avaje-jex-grizzly/src/main/java/io/avaje/jex/grizzly/RouteHandler.java new file mode 100644 index 00000000..b74ed4bb --- /dev/null +++ b/avaje-jex-grizzly/src/main/java/io/avaje/jex/grizzly/RouteHandler.java @@ -0,0 +1,80 @@ +package io.avaje.jex.grizzly; + +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.glassfish.grizzly.http.server.HttpHandler; +import org.glassfish.grizzly.http.server.Request; +import org.glassfish.grizzly.http.server.Response; + +class RouteHandler extends HttpHandler { + + private final SpiRoutes routes; + private final ServiceManager mgr; + + RouteHandler(SpiRoutes routes, ServiceManager mgr) { + this.mgr = mgr; + this.routes = routes; + } + + @Override + public void service(Request request, Response response) { + + final String uri = request.getRequestURI(); + final Routing.Type routeType = mgr.lookupRoutingType(request.getMethod().getMethodString()); + final SpiRoutes.Entry route = routes.match(routeType, uri); + + if (route == null) { + var ctx = new GrizzlyContext(mgr, request, response, uri); + try { + processNoRoute(ctx, uri, routeType); + routes.after(uri, ctx); + } catch (Exception e) { + handleException(ctx, e); + } + } else { + final SpiRoutes.Params params = route.pathParams(uri); + var ctx = new GrizzlyContext(mgr, request, response, route.matchPath(), params); + try { + processRoute(ctx, uri, route); + routes.after(uri, ctx); + } catch (Exception e) { + handleException(ctx, e); + } + } + } + + private void handleException(SpiContext ctx, Exception e) { + mgr.handleException(ctx, e); + } + + private void processRoute(GrizzlyContext ctx, String uri, SpiRoutes.Entry route) { + routes.before(uri, ctx); + ctx.setMode(null); + route.handle(ctx); + } + + private void processNoRoute(GrizzlyContext 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-grizzly/src/main/java/io/avaje/jex/grizzly/ServiceManager.java b/avaje-jex-grizzly/src/main/java/io/avaje/jex/grizzly/ServiceManager.java new file mode 100644 index 00000000..b7be3145 --- /dev/null +++ b/avaje-jex-grizzly/src/main/java/io/avaje/jex/grizzly/ServiceManager.java @@ -0,0 +1,22 @@ +package io.avaje.jex.grizzly; + +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; + + ServiceManager(SpiServiceManager delegate, String scheme, String contextPath) { + super(delegate); + this.scheme = scheme; + this.contextPath = contextPath; + } + + String contextPath() { + return contextPath; + } +} diff --git a/avaje-jex-grizzly/src/main/resources/META-INF/services/io.avaje.jex.spi.SpiStartServer b/avaje-jex-grizzly/src/main/resources/META-INF/services/io.avaje.jex.spi.SpiStartServer new file mode 100644 index 00000000..855a5fe8 --- /dev/null +++ b/avaje-jex-grizzly/src/main/resources/META-INF/services/io.avaje.jex.spi.SpiStartServer @@ -0,0 +1 @@ +io.avaje.jex.grizzly.GrizzlyServerStart diff --git a/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/AutoCloseIterator.java b/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/AutoCloseIterator.java new file mode 100644 index 00000000..819941a7 --- /dev/null +++ b/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/AutoCloseIterator.java @@ -0,0 +1,32 @@ +package io.avaje.jex.grizzly; + +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-grizzly/src/test/java/io/avaje/jex/grizzly/ContextFormParamTest.java b/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/ContextFormParamTest.java new file mode 100644 index 00000000..11ed44c4 --- /dev/null +++ b/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/ContextFormParamTest.java @@ -0,0 +1,135 @@ +package io.avaje.jex.grizzly; + +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-grizzly/src/test/java/io/avaje/jex/grizzly/ContextLengthTest.java b/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/ContextLengthTest.java new file mode 100644 index 00000000..19097ddf --- /dev/null +++ b/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/ContextLengthTest.java @@ -0,0 +1,106 @@ +package io.avaje.jex.grizzly; + +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-grizzly/src/test/java/io/avaje/jex/grizzly/ContextTest.java b/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/ContextTest.java new file mode 100644 index 00000000..ec2cc637 --- /dev/null +++ b/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/ContextTest.java @@ -0,0 +1,192 @@ +package io.avaje.jex.grizzly; + +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.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() { + + var me = new ContextTest(); + + final Jex app = Jex.create() + .routing(routing -> routing + .get("/", ctx -> ctx.text("ze-get")) + .post("/", ctx -> ctx.text("ze-post")) + .get("/header", me::doHeader) + .get("/headerMap", ctx -> ctx.text("req-header-map[" + ctx.headerMap() + "]")) + .get("/host", me::doHost) + .get("/ip", me::doIp) + .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", me::doStatus)); + + return TestPair.create(app); + } + + private void doStatus(Context ctx) { + ctx.status(201); + ctx.text("status:" + ctx.status()); + } + + private void doIp(Context ctx) { + final String ip = ctx.ip(); + requireNonNull(ip); + ctx.text("ip:" + ip); + } + + private void doHost(Context ctx) { + final String host = ctx.host(); + requireNonNull(host); + ctx.text("host:" + host); + } + + private void doHeader(Context ctx) { + ctx.header("From-My-Server", "Set-By-Server"); + ctx.text("req-header[" + ctx.header("From-My-Client") + "]"); + } + + @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-grizzly/src/test/java/io/avaje/jex/grizzly/HelloDto.java b/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/HelloDto.java new file mode 100644 index 00000000..6beeb95e --- /dev/null +++ b/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/HelloDto.java @@ -0,0 +1,27 @@ +package io.avaje.jex.grizzly; + +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-grizzly/src/test/java/io/avaje/jex/grizzly/HelloWorldTest.java b/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/HelloWorldTest.java new file mode 100644 index 00000000..af91f00d --- /dev/null +++ b/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/HelloWorldTest.java @@ -0,0 +1,50 @@ +package io.avaje.jex.grizzly; + +import io.avaje.jex.Jex; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.slf4j.bridge.SLF4JBridgeHandler; + +import java.net.http.HttpResponse; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class HelloWorldTest { + + static { + SLF4JBridgeHandler.removeHandlersForRootLogger(); + SLF4JBridgeHandler.install(); + } + + static TestPair pair = init(); + + static TestPair init() { + var app = Jex.create() + .routing(routing -> routing + .get("/", ctx -> ctx.text("hello")) + ); + 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 getAgain() { + HttpResponse res = pair.request().GET().asString(); + assertThat(res.statusCode()).isEqualTo(200); + assertThat(res.body()).isEqualTo("hello"); + } + +} diff --git a/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/JsonTest.java b/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/JsonTest.java new file mode 100644 index 00000000..73f56469 --- /dev/null +++ b/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/JsonTest.java @@ -0,0 +1,121 @@ +package io.avaje.jex.grizzly; + +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())) //.header("x2-foo","asd") + .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-grizzly/src/test/java/io/avaje/jex/grizzly/QueryParamTest.java b/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/QueryParamTest.java new file mode 100644 index 00000000..3ebd8b96 --- /dev/null +++ b/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/QueryParamTest.java @@ -0,0 +1,148 @@ +package io.avaje.jex.grizzly; + +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 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-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 typeMap, List before, List after) { this.typeMap = typeMap; this.before = before; this.after = after; } + @Override + public void inc() { + noRouteCounter.incrementAndGet(); + } + + @Override + public void dec() { + noRouteCounter.decrementAndGet(); + } + + @Override + public long activeRequests() { + long total = noRouteCounter.get(); + for (RouteIndex value : typeMap.values()) { + total += value.activeRequests(); + } + return total; + } + @Override public Entry match(Routing.Type type, String pathInfo) { return typeMap.get(type).match(pathInfo); diff --git a/avaje-jex/src/main/java/io/avaje/jex/routes/RoutesBuilder.java b/avaje-jex/src/main/java/io/avaje/jex/routes/RoutesBuilder.java index aa3ad6c2..96fd816f 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/routes/RoutesBuilder.java +++ b/avaje-jex/src/main/java/io/avaje/jex/routes/RoutesBuilder.java @@ -8,7 +8,7 @@ import java.util.List; import java.util.Set; -public class RoutesBuilder { +class RoutesBuilder { private final EnumMap typeMap = new EnumMap<>(Routing.Type.class); private final List before = new ArrayList<>(); @@ -16,9 +16,9 @@ public class RoutesBuilder { private final boolean ignoreTrailingSlashes; private final AccessManager accessManager; - public RoutesBuilder(Routing routing, Jex jex) { - this.accessManager = jex.inner.accessManager; - this.ignoreTrailingSlashes = jex.inner.ignoreTrailingSlashes; + RoutesBuilder(Routing routing, AccessManager accessManager, boolean ignoreTrailingSlashes) { + this.accessManager = accessManager; + this.ignoreTrailingSlashes = ignoreTrailingSlashes; for (Routing.Entry handler : routing.all()) { switch (handler.getType()) { case BEFORE: diff --git a/avaje-jex/src/main/java/io/avaje/jex/core/HeaderKeys.java b/avaje-jex/src/main/java/io/avaje/jex/spi/HeaderKeys.java similarity index 96% rename from avaje-jex/src/main/java/io/avaje/jex/core/HeaderKeys.java rename to avaje-jex/src/main/java/io/avaje/jex/spi/HeaderKeys.java index bf7a298f..e99047ab 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/core/HeaderKeys.java +++ b/avaje-jex/src/main/java/io/avaje/jex/spi/HeaderKeys.java @@ -1,4 +1,4 @@ -package io.avaje.jex.core; +package io.avaje.jex.spi; public class HeaderKeys { diff --git a/avaje-jex/src/main/java/io/avaje/jex/spi/ProxyServiceManager.java b/avaje-jex/src/main/java/io/avaje/jex/spi/ProxyServiceManager.java new file mode 100644 index 00000000..994b850e --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/spi/ProxyServiceManager.java @@ -0,0 +1,79 @@ +package io.avaje.jex.spi; + +import io.avaje.jex.Context; +import io.avaje.jex.Routing; + +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +/** + * Provides a delegating proxy to a SpiServiceManager. + *

+ * Can be used by specific implementations like Jetty and JDK Http Server to add core functionality + * to provide to the specific context implementation. + */ +public abstract class ProxyServiceManager implements SpiServiceManager { + + protected final SpiServiceManager delegate; + + public ProxyServiceManager(SpiServiceManager delegate) { + this.delegate = delegate; + } + + @Override + public T jsonRead(Class clazz, SpiContext ctx) { + return delegate.jsonRead(clazz, ctx); + } + + @Override + public void jsonWrite(Object bean, SpiContext ctx) { + delegate.jsonWrite(bean, ctx); + } + + @Override + public void jsonWriteStream(Stream stream, SpiContext ctx) { + delegate.jsonWriteStream(stream, ctx); + } + + @Override + public void jsonWriteStream(Iterator iterator, SpiContext ctx) { + delegate.jsonWriteStream(iterator, ctx); + } + + @Override + public void maybeClose(Object iterator) { + delegate.maybeClose(iterator); + } + + @Override + public Routing.Type lookupRoutingType(String method) { + return delegate.lookupRoutingType(method); + } + + @Override + public void handleException(SpiContext ctx, Exception e) { + delegate.handleException(ctx, e); + } + + @Override + public void render(Context ctx, String name, Map model) { + delegate.render(ctx, name, model); + } + + @Override + public String requestCharset(Context ctx) { + return delegate.requestCharset(ctx); + } + + @Override + public Map> formParamMap(Context ctx, String charset) { + return delegate.formParamMap(ctx, charset); + } + + @Override + public Map> parseParamMap(String body, String charset) { + return delegate.parseParamMap(body, charset); + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/spi/SpiContext.java b/avaje-jex/src/main/java/io/avaje/jex/spi/SpiContext.java index 753e6e10..0f8d46a6 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/spi/SpiContext.java +++ b/avaje-jex/src/main/java/io/avaje/jex/spi/SpiContext.java @@ -13,6 +13,8 @@ public interface SpiContext extends Context { String TEXT_HTML = "text/html"; String TEXT_PLAIN = "text/plain"; + String TEXT_HTML_UTF8 = "text/html;charset=utf-8"; + String TEXT_PLAIN_UTF8 = "text/plain;charset=utf-8"; String APPLICATION_JSON = "application/json"; String APPLICATION_X_JSON_STREAM = "application/x-json-stream"; @@ -30,4 +32,9 @@ public interface SpiContext extends Context { * Set to indicate BEFORE, Handler and AFTER modes of the request. */ void setMode(Routing.Type type); + + /** + * Preform the redirect as part of Exception handling typically due to before handler. + */ + void performRedirect(); } diff --git a/avaje-jex/src/main/java/io/avaje/jex/spi/SpiRoutes.java b/avaje-jex/src/main/java/io/avaje/jex/spi/SpiRoutes.java index df059e16..2d2786ea 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/spi/SpiRoutes.java +++ b/avaje-jex/src/main/java/io/avaje/jex/spi/SpiRoutes.java @@ -26,6 +26,21 @@ public interface SpiRoutes { */ void after(String pathInfo, SpiContext ctx); + /** + * Increment active request count for no route match. + */ + void inc(); + + /** + * Decrement active request count for no route match. + */ + void dec(); + + /** + * Return the active request count. + */ + long activeRequests(); + /** * A route entry. */ @@ -60,6 +75,21 @@ interface Entry { * Return true if one of the segments is the wildcard match. */ boolean includesWildcard(); + + /** + * Increment active request count for the route. + */ + void inc(); + + /** + * Decrement active request count for the route. + */ + void dec(); + + /** + * Return the active request count for the route. + */ + long activeRequests(); } /** diff --git a/avaje-jex/src/main/java/io/avaje/jex/spi/SpiRoutesProvider.java b/avaje-jex/src/main/java/io/avaje/jex/spi/SpiRoutesProvider.java new file mode 100644 index 00000000..d6a4e118 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/spi/SpiRoutesProvider.java @@ -0,0 +1,13 @@ +package io.avaje.jex.spi; + +import io.avaje.jex.AccessManager; +import io.avaje.jex.Routing; + +public interface SpiRoutesProvider { + + /** + * Build and return the Routing. + */ + SpiRoutes create(Routing routing, AccessManager accessManager, boolean ignoreTrailingSlashes); + +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/spi/SpiServiceManager.java b/avaje-jex/src/main/java/io/avaje/jex/spi/SpiServiceManager.java new file mode 100644 index 00000000..017f23ba --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/spi/SpiServiceManager.java @@ -0,0 +1,70 @@ +package io.avaje.jex.spi; + +import io.avaje.jex.Context; +import io.avaje.jex.Routing; + +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +/** + * Core service methods available to Context implementations. + */ +public interface SpiServiceManager { + + /** + * Read and return the type from json request content. + */ + T jsonRead(Class clazz, SpiContext ctx); + + /** + * Write as json to response content. + */ + void jsonWrite(Object bean, SpiContext ctx); + + /** + * Write as json stream to response content. + */ + void jsonWriteStream(Stream stream, SpiContext ctx); + + /** + * Write as json stream to response content. + */ + void jsonWriteStream(Iterator iterator, SpiContext ctx); + + /** + * Maybe close if iterator is a AutoClosable. + */ + void maybeClose(Object iterator); + + /** + * Return the routing type given the http method. + */ + Routing.Type lookupRoutingType(String method); + + /** + * Handle the exception. + */ + void handleException(SpiContext ctx, Exception e); + + /** + * Render using template manager. + */ + void render(Context ctx, String name, Map model); + + /** + * Return the character set of the request. + */ + String requestCharset(Context ctx); + + /** + * Parse and return the body as form parameters. + */ + Map> formParamMap(Context ctx, String charset); + + /** + * Parse and return the content as url encoded parameters. + */ + Map> parseParamMap(String body, String charset); +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/spi/SpiServiceManagerProvider.java b/avaje-jex/src/main/java/io/avaje/jex/spi/SpiServiceManagerProvider.java new file mode 100644 index 00000000..90449ce5 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/spi/SpiServiceManagerProvider.java @@ -0,0 +1,8 @@ +package io.avaje.jex.spi; + +import io.avaje.jex.Jex; + +public interface SpiServiceManagerProvider { + + SpiServiceManager create(Jex jex); +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/spi/SpiStartServer.java b/avaje-jex/src/main/java/io/avaje/jex/spi/SpiStartServer.java index 2d937b4f..fa068a15 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/spi/SpiStartServer.java +++ b/avaje-jex/src/main/java/io/avaje/jex/spi/SpiStartServer.java @@ -10,6 +10,6 @@ public interface SpiStartServer { /** * Return the started server. */ - Jex.Server start(Jex jex); + Jex.Server start(Jex jex, SpiRoutes routes, SpiServiceManager serviceManager); } diff --git a/avaje-jex/src/main/java/module-info.java b/avaje-jex/src/main/java/module-info.java index 41db453a..7bc3c75c 100644 --- a/avaje-jex/src/main/java/module-info.java +++ b/avaje-jex/src/main/java/module-info.java @@ -1,21 +1,26 @@ +import io.avaje.jex.TemplateRender; +import io.avaje.jex.core.BootstapServiceManager; +import io.avaje.jex.routes.BootstrapRoutes; +import io.avaje.jex.spi.SpiRoutesProvider; +import io.avaje.jex.spi.SpiServiceManagerProvider; +import io.avaje.jex.spi.SpiStartServer; + module io.avaje.jex { exports io.avaje.jex; exports io.avaje.jex.http; exports io.avaje.jex.spi; + exports io.avaje.jex.core; - requires io.avaje.jex.jettyx; - requires java.net.http; - requires transitive jetty.servlet.api; + requires transitive java.net.http; 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; - uses io.avaje.jex.spi.SpiStartServer; - uses io.avaje.jex.TemplateRender; + uses TemplateRender; + uses SpiRoutesProvider; + uses SpiServiceManagerProvider; + uses SpiStartServer; + + provides SpiRoutesProvider with BootstrapRoutes; + provides SpiServiceManagerProvider with BootstapServiceManager; } diff --git a/avaje-jex/src/main/resources/META-INF/services/io.avaje.jex.spi.SpiRoutesProvider b/avaje-jex/src/main/resources/META-INF/services/io.avaje.jex.spi.SpiRoutesProvider new file mode 100644 index 00000000..8d8e8739 --- /dev/null +++ b/avaje-jex/src/main/resources/META-INF/services/io.avaje.jex.spi.SpiRoutesProvider @@ -0,0 +1 @@ +io.avaje.jex.routes.BootstrapRoutes diff --git a/avaje-jex/src/main/resources/META-INF/services/io.avaje.jex.spi.SpiServiceManagerProvider b/avaje-jex/src/main/resources/META-INF/services/io.avaje.jex.spi.SpiServiceManagerProvider new file mode 100644 index 00000000..deef4fcd --- /dev/null +++ b/avaje-jex/src/main/resources/META-INF/services/io.avaje.jex.spi.SpiServiceManagerProvider @@ -0,0 +1 @@ +io.avaje.jex.core.BootstapServiceManager diff --git a/avaje-jex/src/test/java/io/avaje/jex/CookieTest.java b/avaje-jex/src/test/java/io/avaje/jex/CookieTest.java new file mode 100644 index 00000000..03f2dff9 --- /dev/null +++ b/avaje-jex/src/test/java/io/avaje/jex/CookieTest.java @@ -0,0 +1,26 @@ +package io.avaje.jex; + +import io.avaje.jex.Context.Cookie; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class CookieTest { + + @Test + void format() { + assertEquals("key=val", Cookie.of("key", "val").toString()); + assertEquals("key=val; Domain=dom", Cookie.of("key", "val").domain("dom").toString()); + assertEquals("key=val; Path=/pt", Cookie.of("key", "val").path("/pt").toString()); + //assertEquals("key=val; Path=/; Max-Age=10", Cookie.of("key", "val").maxAge(10).format()); + assertEquals("key=val; Secure", Cookie.of("key", "val").secure(true).toString()); + assertEquals("key=val; HttpOnly", Cookie.of("key", "val").httpOnly(true).toString()); + assertEquals("key=val; Secure; HttpOnly", Cookie.of("key", "val").httpOnly(true).secure(true).toString()); + } + + @Test + void format_all() { + assertEquals("key=val; Domain=dom; Path=/pt; Secure; HttpOnly", Cookie.of("key", "val") + .domain("dom").path("/pt").secure(true).httpOnly(true).toString()); + } +} diff --git a/avaje-jex/src/test/java/io/avaje/jex/core/ContextUtilTest.java b/avaje-jex/src/test/java/io/avaje/jex/core/ContextUtilTest.java new file mode 100644 index 00000000..944bcc8b --- /dev/null +++ b/avaje-jex/src/test/java/io/avaje/jex/core/ContextUtilTest.java @@ -0,0 +1,23 @@ +package io.avaje.jex.core; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ContextUtilTest { + + @Test + void parseCharset_defaults() { + assertThat(CoreServiceManager.parseCharset("")).isEqualTo(CoreServiceManager.UTF_8); + assertThat(CoreServiceManager.parseCharset("junk")).isEqualTo(CoreServiceManager.UTF_8); + } + + @Test + void parseCharset_caseCheck() { + assertThat(CoreServiceManager.parseCharset("app/foo; charset=ME")).isEqualTo("ME"); + assertThat(CoreServiceManager.parseCharset("app/foo;charset=ME")).isEqualTo("ME"); + assertThat(CoreServiceManager.parseCharset("app/foo;charset = ME ")).isEqualTo("ME"); + assertThat(CoreServiceManager.parseCharset("app/foo;charset = ME;")).isEqualTo("ME"); + assertThat(CoreServiceManager.parseCharset("app/foo;charset = ME;other=junk")).isEqualTo("ME"); + } +} diff --git a/avaje-jex/src/test/java/io/avaje/jex/jetty/ContextUtilTest.java b/avaje-jex/src/test/java/io/avaje/jex/jetty/ContextUtilTest.java deleted file mode 100644 index be4f54f4..00000000 --- a/avaje-jex/src/test/java/io/avaje/jex/jetty/ContextUtilTest.java +++ /dev/null @@ -1,25 +0,0 @@ -package io.avaje.jex.jetty; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -class ContextUtilTest { - - @Test - void parseCharset_defaults() { - - assertThat(ContextUtil.parseCharset("")).isEqualTo(ContextUtil.UTF_8); - assertThat(ContextUtil.parseCharset("junk")).isEqualTo(ContextUtil.UTF_8); - } - - @Test - void parseCharset_caseCheck() { - - assertThat(ContextUtil.parseCharset("app/foo; charset=ME")).isEqualTo("ME"); - assertThat(ContextUtil.parseCharset("app/foo;charset=ME")).isEqualTo("ME"); - assertThat(ContextUtil.parseCharset("app/foo;charset = ME ")).isEqualTo("ME"); - assertThat(ContextUtil.parseCharset("app/foo;charset = ME;")).isEqualTo("ME"); - assertThat(ContextUtil.parseCharset("app/foo;charset = ME;other=junk")).isEqualTo("ME"); - } -} diff --git a/examples/example-grizzly/pom.xml b/examples/example-grizzly/pom.xml new file mode 100644 index 00000000..d24cfda7 --- /dev/null +++ b/examples/example-grizzly/pom.xml @@ -0,0 +1,53 @@ + + + 4.0.0 + + org.avaje + java11-oss + 3.2 + + + org.example + example-grizzly + 1 + + + 11 + + + + + + io.avaje + avaje-jex-grizzly + 1.8-SNAPSHOT + + + + com.fasterxml.jackson.core + jackson-databind + 2.12.3 + + + + io.avaje + avaje-http-client + 1.11 + + + + org.slf4j + slf4j-api + 1.7.30 + + + + ch.qos.logback + logback-classic + 1.2.3 + + + + diff --git a/examples/example-grizzly/src/main/java/org/example/GMain.java b/examples/example-grizzly/src/main/java/org/example/GMain.java new file mode 100644 index 00000000..4a4682ab --- /dev/null +++ b/examples/example-grizzly/src/main/java/org/example/GMain.java @@ -0,0 +1,56 @@ +package org.example; + +import io.avaje.jex.Jex; +import io.avaje.jex.core.HealthPlugin; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +public class GMain { + + private static final Logger log = LoggerFactory.getLogger(GMain.class); + + public static void main(String[] args) throws InterruptedException { + + Jex.create() + //.attribute(Executor.class, Executors.newVirtualThreadExecutor()) + .routing(routing -> routing + //.get("/", ctx -> ctx.text("hello world")) + .get("/", ctx -> ctx.json(HelloDto.rob())) //.header("x2-foo","asd") + .get("/foo/{id}", ctx -> { + HelloDto bean = new HelloDto(); + bean.id = Integer.parseInt(ctx.pathParam("id")); + bean.name = "Rob"; + ctx.json(bean); + }) + .get("/delay", ctx -> { + log.info("delay start"); + try { + Thread.sleep(5_000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + e.printStackTrace(); + } + ctx.text("delay done"); + log.info("delay done"); + }) + .get("/dump", ctx -> dumpThreadCount()) + ) + .configure(new HealthPlugin()) + .port(7003) + .start(); + + Thread.currentThread().join(); + } + + private static void dumpThreadCount() { + Map allStackTraces = Thread.getAllStackTraces(); + System.out.println("Thread count: " + allStackTraces.size()); + Set threads = allStackTraces.keySet(); + System.out.println("Threads: " + threads); + } +} diff --git a/examples/example-grizzly/src/main/java/org/example/HelloDto.java b/examples/example-grizzly/src/main/java/org/example/HelloDto.java new file mode 100644 index 00000000..de0c8524 --- /dev/null +++ b/examples/example-grizzly/src/main/java/org/example/HelloDto.java @@ -0,0 +1,14 @@ +package org.example; + +public class HelloDto { + + public long id; + public String name; + + public static HelloDto rob() { + HelloDto bean = new HelloDto(); + bean.id = 42; + bean.name = "rob"; + return bean; + } +} diff --git a/examples/example-grizzly/src/test/java/org/example/ClientMain.java b/examples/example-grizzly/src/test/java/org/example/ClientMain.java new file mode 100644 index 00000000..ccef9d5f --- /dev/null +++ b/examples/example-grizzly/src/test/java/org/example/ClientMain.java @@ -0,0 +1,28 @@ +package org.example; + +import io.avaje.http.client.HttpClientContext; +import io.avaje.http.client.JacksonBodyAdapter; + +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpResponse; + +public class ClientMain { + + public static void main(String[] args) { + + final HttpClientContext ctx = HttpClientContext.newBuilder() + .baseUrl("http://localhost:7003") + .bodyAdapter(new JacksonBodyAdapter()) + .version(HttpClient.Version.HTTP_1_1) + .build(); + + final HttpResponse res = ctx.request() + .path("foo/99") + .GET() + .asPlainString(); + final HttpHeaders headers = res.headers(); + System.out.println("got " + res.body()); + + } +} diff --git a/examples/example-grizzly/src/test/resources/logback-test.xml b/examples/example-grizzly/src/test/resources/logback-test.xml new file mode 100644 index 00000000..2c7f5454 --- /dev/null +++ b/examples/example-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/examples/example-jdk/pom.xml b/examples/example-jdk/pom.xml new file mode 100644 index 00000000..f5ed20da --- /dev/null +++ b/examples/example-jdk/pom.xml @@ -0,0 +1,73 @@ + + + 4.0.0 + + org.avaje + java11-oss + 3.2 + + + org.example + example-jdk + 1 + + + 11 + 11 + + + + + + io.avaje + avaje-jex-jdk + 1.8-SNAPSHOT + + + + com.fasterxml.jackson.core + jackson-databind + 2.12.3 + + + + org.slf4j + slf4j-api + 1.7.30 + + + + ch.qos.logback + logback-classic + 1.2.3 + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + ${java.release} + + + + io.repaint.maven + tiles-maven-plugin + 2.22 + true + + + org.avaje.tile:lib-classpath:1.1 + + + + + + + diff --git a/examples/example-jdk/src/main/java/module-info.java b/examples/example-jdk/src/main/java/module-info.java new file mode 100644 index 00000000..c4284407 --- /dev/null +++ b/examples/example-jdk/src/main/java/module-info.java @@ -0,0 +1,7 @@ +module example.jdk { + + requires transitive io.avaje.jex.jdk; + requires transitive org.slf4j; + + exports org.example to com.fasterxml.jackson.databind; +} diff --git a/example/src/main/java/org/example/HelloDto.java b/examples/example-jdk/src/main/java/org/example/HelloDto.java similarity index 100% rename from example/src/main/java/org/example/HelloDto.java rename to examples/example-jdk/src/main/java/org/example/HelloDto.java diff --git a/examples/example-jdk/src/main/java/org/example/Main.java b/examples/example-jdk/src/main/java/org/example/Main.java new file mode 100644 index 00000000..54e31fb7 --- /dev/null +++ b/examples/example-jdk/src/main/java/org/example/Main.java @@ -0,0 +1,53 @@ +package org.example; + +import io.avaje.jex.Jex; +import io.avaje.jex.core.HealthPlugin; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +public class Main { + + private static final Logger log = LoggerFactory.getLogger(Main.class); + + public static void main(String[] args) { + + Jex.create() + //.attribute(Executor.class, Executors.newVirtualThreadExecutor()) + .routing(routing -> routing + .get("/", ctx -> ctx.text("hello world")) + .get("/foo/{id}", ctx -> { + HelloDto bean = new HelloDto(); + bean.id = Integer.parseInt(ctx.pathParam("id")); + bean.name = "Rob"; + ctx.json(bean); + }) + .get("/delay", ctx -> { + log.info("delay start"); + try { + Thread.sleep(5_000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + e.printStackTrace(); + } + ctx.text("delay done"); + log.info("delay done"); + }) + .get("/dump", ctx -> dumpThreadCount()) + ) + .configure(new HealthPlugin()) + .port(7003) + .start(); + } + + private static void dumpThreadCount() { + Map allStackTraces = Thread.getAllStackTraces(); + System.out.println("Thread count: " + allStackTraces.size()); + Set threads = allStackTraces.keySet(); + System.out.println("Threads: " + threads); + } +} diff --git a/example/src/main/resources/content/basic.html b/examples/example-jdk/src/main/resources/content/basic.html similarity index 100% rename from example/src/main/resources/content/basic.html rename to examples/example-jdk/src/main/resources/content/basic.html diff --git a/example/src/main/resources/content/index.html b/examples/example-jdk/src/main/resources/content/index.html similarity index 100% rename from example/src/main/resources/content/index.html rename to examples/example-jdk/src/main/resources/content/index.html diff --git a/example/src/main/resources/content/plain-file.txt b/examples/example-jdk/src/main/resources/content/plain-file.txt similarity index 100% rename from example/src/main/resources/content/plain-file.txt rename to examples/example-jdk/src/main/resources/content/plain-file.txt diff --git a/examples/example-jdk/src/main/resources/logback.xml b/examples/example-jdk/src/main/resources/logback.xml new file mode 100644 index 00000000..2c7f5454 --- /dev/null +++ b/examples/example-jdk/src/main/resources/logback.xml @@ -0,0 +1,19 @@ + + + + TRACE + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + diff --git a/examples/example-jdk/src/test/resources/logback-test.xml b/examples/example-jdk/src/test/resources/logback-test.xml new file mode 100644 index 00000000..2c7f5454 --- /dev/null +++ b/examples/example-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/example/pom.xml b/examples/example-jetty/pom.xml similarity index 81% rename from example/pom.xml rename to examples/example-jetty/pom.xml index ab251363..6d116649 100644 --- a/example/pom.xml +++ b/examples/example-jetty/pom.xml @@ -2,15 +2,16 @@ + 4.0.0 - io.avaje - avaje-jex-parent - 1.3-SNAPSHOT + org.avaje + java11-oss + 3.2 - 4.0.0 - - example + org.example + example-jetty + 1 @@ -22,14 +23,14 @@ io.avaje - avaje-jex - 1.3-SNAPSHOT + avaje-jex-jetty + 1.8-SNAPSHOT com.fasterxml.jackson.core jackson-databind - 2.12.0 + 2.12.3 diff --git a/examples/example-jetty/src/main/java/org/example/HelloDto.java b/examples/example-jetty/src/main/java/org/example/HelloDto.java new file mode 100644 index 00000000..20141e62 --- /dev/null +++ b/examples/example-jetty/src/main/java/org/example/HelloDto.java @@ -0,0 +1,7 @@ +package org.example; + +public class HelloDto { + + public long id; + public String name; +} diff --git a/example/src/main/java/org/example/Main.java b/examples/example-jetty/src/main/java/org/example/Main.java similarity index 55% rename from example/src/main/java/org/example/Main.java rename to examples/example-jetty/src/main/java/org/example/Main.java index 5cce7bbf..4da31012 100644 --- a/example/src/main/java/org/example/Main.java +++ b/examples/example-jetty/src/main/java/org/example/Main.java @@ -1,9 +1,13 @@ package org.example; import io.avaje.jex.Jex; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class Main { + private static final Logger log = LoggerFactory.getLogger(Main.class); + public static void main(String[] args) { Jex.create() @@ -15,6 +19,17 @@ public static void main(String[] args) { bean.name = "Rob"; ctx.json(bean); }) + .get("/delay", ctx -> { + log.info("delay start"); + try { + Thread.sleep(5_000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + e.printStackTrace(); + } + ctx.text("delay done"); + log.info("delay done"); + }) ) .staticFiles().addClasspath("/static", "content") // .staticFiles().addExternal("/", "/tmp/junk") diff --git a/examples/example-jetty/src/main/resources/content/basic.html b/examples/example-jetty/src/main/resources/content/basic.html new file mode 100644 index 00000000..8b4e34d7 --- /dev/null +++ b/examples/example-jetty/src/main/resources/content/basic.html @@ -0,0 +1 @@ +basic diff --git a/examples/example-jetty/src/main/resources/content/index.html b/examples/example-jetty/src/main/resources/content/index.html new file mode 100644 index 00000000..0ce384c3 --- /dev/null +++ b/examples/example-jetty/src/main/resources/content/index.html @@ -0,0 +1 @@ +index diff --git a/examples/example-jetty/src/main/resources/content/plain-file.txt b/examples/example-jetty/src/main/resources/content/plain-file.txt new file mode 100644 index 00000000..6be11da0 --- /dev/null +++ b/examples/example-jetty/src/main/resources/content/plain-file.txt @@ -0,0 +1 @@ +plain-file diff --git a/example/src/main/resources/logback.xml b/examples/example-jetty/src/main/resources/logback.xml similarity index 100% rename from example/src/main/resources/logback.xml rename to examples/example-jetty/src/main/resources/logback.xml diff --git a/examples/pom.xml b/examples/pom.xml new file mode 100644 index 00000000..3b788365 --- /dev/null +++ b/examples/pom.xml @@ -0,0 +1,23 @@ + + + 4.0.0 + + avaje-jex-parent + io.avaje + 1.8-SNAPSHOT + + + examples + 0.1 + pom + + + example-jdk + example-jetty + example-grizzly + + + + + diff --git a/pom.xml b/pom.xml index 899b54c5..6c5fedf1 100644 --- a/pom.xml +++ b/pom.xml @@ -1,5 +1,6 @@ - + 4.0.0 org.avaje @@ -26,9 +27,11 @@ avaje-jex-test avaje-jex-freemarker avaje-jex-mustache - - - + avaje-jex-jetty + avaje-jex-jdk + avaje-jex-grizzly + examples + @@ -69,6 +72,13 @@ test + + org.slf4j + jul-to-slf4j + 1.7.30 + test + +