diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..8178f991 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +version: 2 +updates: + - package-ecosystem: "maven" + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 + groups: + dependencies: + patterns: + - "*" + labels: + - "dependencies" + target-branch: "master" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + commit-message: + prefix: "[workflow]" + labels: + - "dependencies" + target-branch: "master" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e3c763b7..4864ad95 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,18 +13,18 @@ jobs: strategy: fail-fast: false matrix: - java_version: [11, 17] + java_version: [21] os: [ubuntu-latest] steps: - - uses: actions/checkout@v2 - - name: Set up JDK 11 - uses: actions/setup-java@v2 + - uses: actions/checkout@v4 + - name: Set up JDK + uses: actions/setup-java@v4 with: java-version: ${{ matrix.java_version }} distribution: 'zulu' - name: Maven cache - uses: actions/cache@v2 + uses: actions/cache@v4 env: cache-name: maven-cache with: diff --git a/.github/workflows/dependabot-merge.yml b/.github/workflows/dependabot-merge.yml new file mode 100644 index 00000000..a6524841 --- /dev/null +++ b/.github/workflows/dependabot-merge.yml @@ -0,0 +1,28 @@ +name: Dependabot auto-merge +on: pull_request + +permissions: + contents: write + pull-requests: write + +jobs: + dependabot: + runs-on: ubuntu-latest + if: ${{ github.actor == 'dependabot[bot]' }} + steps: + - name: Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v2 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + - name: Approve a PR + run: gh pr review --approve "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + # Enable for automerge + - name: Enable auto-merge for Dependabot PRs + run: gh pr merge --auto --squash "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/jdk-ea.yml b/.github/workflows/jdk-ea.yml index 49b05c71..23727c1d 100644 --- a/.github/workflows/jdk-ea.yml +++ b/.github/workflows/jdk-ea.yml @@ -2,6 +2,8 @@ name: JDK EA on: + push: + pull_request: workflow_dispatch: schedule: - cron: '12 8 * * 1,3,5' @@ -16,18 +18,18 @@ jobs: strategy: fail-fast: false matrix: - java_version: [EA,GA] ## valhalla (fails javadoc) + java_version: [EA, GA] ## valhalla (fails javadoc) os: [ubuntu-latest] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Java uses: oracle-actions/setup-java@v1 with: website: jdk.java.net release: ${{ matrix.java_version }} - name: Maven cache - uses: actions/cache@v2 + uses: actions/cache@v4 env: cache-name: maven-cache with: diff --git a/.github/workflows/robaho.yml b/.github/workflows/robaho.yml new file mode 100644 index 00000000..fdf06229 --- /dev/null +++ b/.github/workflows/robaho.yml @@ -0,0 +1,39 @@ + +name: Robaho + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + build: + runs-on: ${{ matrix.os }} + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + java_version: [21] + os: [ubuntu-latest] + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK + uses: actions/setup-java@v4 + with: + java-version: ${{ matrix.java_version }} + distribution: 'zulu' + - name: Maven cache + uses: actions/cache@v4 + env: + cache-name: maven-cache + with: + path: + ~/.m2 + key: build-${{ env.cache-name }} + - name: Build with Maven + run: mvn clean test -Probaho + + diff --git a/.gitignore b/.gitignore index 0c9d542d..b373cb31 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,10 @@ build/ .idea/ *.iml .gradle +.project +*.prefs +*.classpath +*.prefs +*.factorypath +bin/ +.DS_Store diff --git a/README.md b/README.md index 1d68ee95..0d34ddb3 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,211 @@ +![Supported JVM Versions](https://img.shields.io/badge/JVM-21+-brightgreen.svg?&logo=openjdk) +[![Discord](https://img.shields.io/discord/1074074312421683250?color=%237289da&label=discord)](https://discord.gg/Qcqf9R27BR) [![Build](https://github.com/avaje/avaje-jex/actions/workflows/build.yml/badge.svg)](https://github.com/avaje/avaje-jex/actions/workflows/build.yml) -[![Maven Central](https://img.shields.io/maven-central/v/io.avaje/avaje-jex-parent.svg?label=Maven%20Central)](https://mvnrepository.com/artifact/io.avaje/avaje-jex-parent) -[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/avaje/avaje-jex/blob/master/LICENSE) [![JDK EA](https://github.com/avaje/avaje-jex/actions/workflows/jdk-ea.yml/badge.svg)](https://github.com/avaje/avaje-jex/actions/workflows/jdk-ea.yml) +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/avaje/avaje-jex/blob/master/LICENSE) +[![Maven Central](https://img.shields.io/maven-central/v/io.avaje/avaje-jex.svg?label=Maven%20Central)](https://mvnrepository.com/artifact/io.avaje/avaje-jex) +[![javadoc](https://javadoc.io/badge2/io.avaje/avaje-jex/javadoc.svg?color=purple)](https://javadoc.io/doc/io.avaje/avaje-jex) + +# [Avaje-Jex](https://avaje.io/jex/) +Lightweight (~120KB) wrapper over the JDK's built-in [HTTP server](https://docs.oracle.com/en/java/javase/23/docs/api/jdk.httpserver/module-summary.html). + +Features: + +- [Context](https://javadoc.io/doc/io.avaje/avaje-jex/latest/io.avaje.jex/io/avaje/jex/http/Context.html) abstraction over `HttpExchange` to easily retrieve and send request/response data. +- Fluent API +- Static resource handling +- Server Sent Events +- Compression SPI +- Json SPI +- Virtual threads enabled by default +- Multi-Server with any implementation of `jdk.httpserver` (Jetty, Robaho, built-in, etc) + +## Quick Start + +Add dependency: +```xml + + io.avaje + avaje-jex + ${jex.version} + +``` +Create Server: +```java + Jex.create() + .get("/", ctx -> ctx.text("hello")) + .get("/one/{id}", ctx -> ctx.text("one-" + ctx.pathParam("id"))) + .filter( + (ctx, chain) -> { + System.out.println("before request"); + chain.proceed(); + System.out.println("after request"); + }) + .error( + IllegalStateException.class, + (ctx, exception) -> ctx.status(500).text(exception.getMessage())) + .port(8080) + .start(); +``` + +## Use with [Avaje Http](https://avaje.io/http/) + +If you find yourself pining for the JAX-RS style of controllers, you can have avaje http generate jex adapters for your annotated classes. + +### Add dependencies + +[![Avaje-HTTP](https://img.shields.io/maven-central/v/io.avaje/avaje-http-api.svg?label=avaje.http.version)](https://mvnrepository.com/artifact/io.avaje/avaje-jex) +```xml + + io.avaje + avaje-jex + ${jex.version} + + + + io.avaje + avaje-http-api + ${avaje.http.version} + + + + + io.avaje + avaje-http-jex-generator + ${avaje.http.version} + provided + true + +``` + +#### JDK 23+ + +In JDK 23+, annotation processors are disabled by default, you will need to add a flag to re-enable. +```xml + + full + +``` + +### Define a Controller +```java +package org.example.hello; + +import io.avaje.http.api.Controller; +import io.avaje.http.api.Get; +import java.util.List; + +@Controller("/widgets") +public class WidgetController { + private final HelloComponent hello; + public WidgetController(HelloComponent hello) { + this.hello = hello; + } + + @Get("/{id}") + Widget getById(int id) { + return new Widget(id, "you got it"+ hello.hello()); + } + + @Get() + List getAll() { + return List.of(new Widget(1, "Rob"), new Widget(2, "Fi")); + } -# avaje-jex + record Widget(int id, String name){}; +} +``` -Java cut down version of https://javalin.io +This will generate routing code that we can register using any JSR-330 compliant DI: ```java -var app = Jex.create() - .routing(routing -> routing - .get("/", ctx -> ctx.text("hello")) - .get("/one/{id}", ctx -> ctx.text("one-" + ctx.pathParam("id"))) - ) - .staticFiles().addClasspath("/static", "content") - .staticFiles().addExternal("/other", "/external") - .port(8080) - .start(); +@Generated("avaje-jex-generator") +@Singleton +public class WidgetController$Route implements Routing.HttpService { + + private final WidgetController controller; + + public WidgetController$Route(WidgetController controller) { + this.controller = controller; + } + + @Override + public void add(Routing routing) { + routing.get("/widgets/{id}", this::_getById); + routing.get("/widgets", this::_getAll); + } + + private void _getById(Context ctx) throws IOException { + ctx.status(200); + var id = asInt(ctx.pathParam("id")); + ctx.json(controller.getById(id)); + } + + private void _getAll(Context ctx) throws IOException { + ctx.status(200); + ctx.json(controller.getAll()); + } + +} +``` + +### JSR-330 DI Usage +You can use whatever DI library you like. + +```java +public class Main { + + public static void main(String[] args ) { + + List services = // Retrieve HttpServices via DI; + Jex.create().routing(services).start(); + } +} +``` + + +## Alternate `HttpServer` Implementations + +The JDK provides an SPI to swap the underlying `HttpServer`, so you can easily use jex with alternate implementations by adding them as a dependency. + +### Eclipse Jetty + +[Jetty](https://jetty.org/) is a classic embedded server with a long and distinguished history. + +[![Maven Central](https://img.shields.io/maven-central/v/org.eclipse.jetty/jetty-http-spi.svg?label=jetty.version)](https://mvnrepository.com/artifact/org.eclipse.jetty/jetty-http-spi) +```xml + + io.avaje + avaje-jex + ${jex.version} + + + org.eclipse.jetty + jetty-http-spi + ${jetty.version} + ``` -### Goals / intention - -- Help progress converting Javalin internals from Kotlin to Java - - Convert bits of Javalin internals to Java - - Maybe get feedback from David if there is design impact - - Prepare small PR's to Javalin (this is going to take time) - -- Another goal is to explore some options for Javalin along the lines of - - matching routes (making use of path segment count) - - organisation of internals to reduce some statics (JavalinJson) - - modularisation of internals using ServiceLoader (for templating implementation, websockets and sse - make these all optional dependencies keeping core small) - -### Design Notes (different to Javalin): -- Context is an interface -- Routing, ErrorHandling, StaticFileConfig are interfaces -- PathParser - Has segment count which we use with RouteIndex -- RouteIndex - matching paths by method + number of segments -- Immutable routes on startup - no adding/removing routes after start() -- Context json() - call through to "ServiceManager" which has the JsonService (no static JavalinJson) - -### Differences to Javalin -- Uses `{}` rather than `:` for defining path parameters -- Supports use of regex in path segments e.g `{id:[0-9]+}` (provides tighter path matching) -- Added ctx.text(...) for plain text response -- Method name change to use ctx.write(...) rather than ctx.result(...) - -### TODO -- cookie store -- app attributes -- basicAuthCredentials/basicAuthCredentialsExist -- plugin api -- render in progress - FreeMarker and Mustache done -- web sockets -- sse - -### Intentionally excluded features -- - - -### To Review -- Javalin uses int getContentLength() rather than long getContentLengthLong() -- Javalin removeCookie should set null path to "/" -- endpointHandlerPath() -- bodyValidator +### Robaho + +[@robaho's httpserver](https://github.com/robaho/httpserver?tab=readme-ov-file#performance) is a zero-dependency implementation that seems to increase performance by 10x over the built-in implementation, and 5x over Jetty in certain benchmarks. + +[![Maven Central](https://img.shields.io/maven-central/v/io.github.robaho/httpserver.svg?label=robaho.version)](https://mvnrepository.com/artifact/io.github.robaho/httpserver) +```xml + + io.avaje + avaje-jex + ${jex.version} + + + + io.github.robaho + httpserver + ${robaho.version} + +``` + +See also: + +- [Javalin](https://github.com/javalin/javalin) (A lightweight wrapper over Jetty) diff --git a/avaje-jetty-loom/pom.xml b/avaje-jetty-loom/pom.xml deleted file mode 100644 index 613c1736..00000000 --- a/avaje-jetty-loom/pom.xml +++ /dev/null @@ -1,92 +0,0 @@ - - - - 4.0.0 - - java11-oss - org.avaje - 3.3 - - - avaje-jex-loomjetty - io.avaje - 1.1 - - - 18 - 18 - 18 - 18 - 11.0.13 - - - - - - org.eclipse.jetty - jetty-servlet - ${jetty.version} - provided - - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.8.1 - - 18 - - --enable-preview - - - - - - org.sonatype.plugins - nexus-staging-maven-plugin - 1.6.8 - true - - ossrh - https://oss.sonatype.org/ - ${nexus.staging.autoReleaseAfterClose} - - - - com.thoughtworks.xstream - xstream - 1.4.15 - - - - - - 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-jetty-loom/src/main/java/io/avaje/jex/jetty/threadpool/VirtualThreadPool.java b/avaje-jetty-loom/src/main/java/io/avaje/jex/jetty/threadpool/VirtualThreadPool.java deleted file mode 100644 index c59bd85f..00000000 --- a/avaje-jetty-loom/src/main/java/io/avaje/jex/jetty/threadpool/VirtualThreadPool.java +++ /dev/null @@ -1,40 +0,0 @@ -package io.avaje.jex.jetty.threadpool; - -import org.eclipse.jetty.util.thread.ThreadPool; - -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -/** - * Loom Virtual threads based Jetty ThreadPool. - */ -public class VirtualThreadPool implements ThreadPool { - - private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor(); - - @Override - public void execute(Runnable command) { - executorService.submit(command); - } - - @Override - public void join() { - // do nothing - } - - @Override - public int getThreads() { - return 1; - } - - @Override - public int getIdleThreads() { - return 1; - } - - @Override - public boolean isLowOnThreads() { - return false; - } - -} diff --git a/avaje-jetty-loom/src/main/java9/module-info.java b/avaje-jetty-loom/src/main/java9/module-info.java deleted file mode 100644 index aa763208..00000000 --- a/avaje-jetty-loom/src/main/java9/module-info.java +++ /dev/null @@ -1,6 +0,0 @@ -module io.avaje.jex.jettyx { - - exports io.avaje.jex.jetty.threadpool; - - requires transitive org.eclipse.jetty.util; -} diff --git a/avaje-jex-freemarker/pom.xml b/avaje-jex-freemarker/pom.xml index 9255274f..65fe95e8 100644 --- a/avaje-jex-freemarker/pom.xml +++ b/avaje-jex-freemarker/pom.xml @@ -4,13 +4,13 @@ avaje-jex-parent io.avaje - 2.5 + 3.0 avaje-jex-freemarker - 2.3.31 + 2.3.34 @@ -18,7 +18,6 @@ io.avaje avaje-jex - 2.5 provided @@ -28,25 +27,23 @@ ${freemarker.version} - io.avaje - avaje-jex-jetty - 2.5 - test + avaje-spi-service + provided + com.fasterxml.jackson.core jackson-databind - 2.13.4.2 + ${jackson.version} test io.avaje avaje-jex-test - 2.5 test diff --git a/avaje-jex-freemarker/src/main/java/io/avaje/jex/render/freemarker/FreeMarkerRender.java b/avaje-jex-freemarker/src/main/java/io/avaje/jex/render/freemarker/FreeMarkerRender.java index a5878059..1c5af89c 100644 --- a/avaje-jex-freemarker/src/main/java/io/avaje/jex/render/freemarker/FreeMarkerRender.java +++ b/avaje-jex-freemarker/src/main/java/io/avaje/jex/render/freemarker/FreeMarkerRender.java @@ -1,18 +1,19 @@ package io.avaje.jex.render.freemarker; +import java.io.IOException; +import java.io.StringWriter; +import java.io.UncheckedIOException; +import java.util.Map; import freemarker.template.Configuration; import freemarker.template.Template; import freemarker.template.TemplateException; import freemarker.template.Version; -import io.avaje.jex.Context; -import io.avaje.jex.TemplateRender; - -import java.io.IOException; -import java.io.StringWriter; -import java.io.UncheckedIOException; -import java.util.Map; +import io.avaje.jex.http.Context; +import io.avaje.jex.spi.TemplateRender; +import io.avaje.spi.ServiceProvider; +@ServiceProvider public class FreeMarkerRender implements TemplateRender { private final Configuration configuration; diff --git a/avaje-jex-freemarker/src/main/java/module-info.java b/avaje-jex-freemarker/src/main/java/module-info.java index 8c422744..7e07d87a 100644 --- a/avaje-jex-freemarker/src/main/java/module-info.java +++ b/avaje-jex-freemarker/src/main/java/module-info.java @@ -1,9 +1,13 @@ -open module io.avaje.jex.freemarker { +import io.avaje.jex.render.freemarker.FreeMarkerRender; +import io.avaje.jex.spi.JexExtension; + +module io.avaje.jex.freemarker { requires transitive io.avaje.jex; requires transitive freemarker; requires java.net.http; + requires static io.avaje.spi; - provides io.avaje.jex.TemplateRender with io.avaje.jex.render.freemarker.FreeMarkerRender; + provides JexExtension with FreeMarkerRender; } diff --git a/avaje-jex-freemarker/src/main/resources/META-INF/services/io.avaje.jex.TemplateRender b/avaje-jex-freemarker/src/main/resources/META-INF/services/io.avaje.jex.TemplateRender deleted file mode 100644 index d9fbcc87..00000000 --- a/avaje-jex-freemarker/src/main/resources/META-INF/services/io.avaje.jex.TemplateRender +++ /dev/null @@ -1 +0,0 @@ -io.avaje.jex.render.freemarker.FreeMarkerRender diff --git a/avaje-jex-freemarker/src/test/java/io/avaje/jex/render/freemarker/FreeMarkerRenderTest.java b/avaje-jex-freemarker/src/test/java/io/avaje/jex/render/freemarker/FreeMarkerRenderTest.java index 73017de3..2b8cdff8 100644 --- a/avaje-jex-freemarker/src/test/java/io/avaje/jex/render/freemarker/FreeMarkerRenderTest.java +++ b/avaje-jex-freemarker/src/test/java/io/avaje/jex/render/freemarker/FreeMarkerRenderTest.java @@ -17,21 +17,21 @@ class FreeMarkerRenderTest { static TestPair pair = init(); static TestPair init() { - final List services = List.of(new NoModel(), new WithModel()); + final List services = List.of(new NoModel(), new WithModel()); var app = Jex.create() .routing(services) .register(new FreeMarkerRender(), "ftl"); return TestPair.create(app); } - static class NoModel implements Routing.Service { + static class NoModel implements Routing.HttpService { @Override public void add(Routing routing) { routing.get("/noModel", ctx -> ctx.render("one.ftl")); } } - static class WithModel implements Routing.Service { + static class WithModel implements Routing.HttpService { @Override public void add(Routing routing) { routing.get("/withModel", ctx -> ctx.render("two.ftl", Map.of("message", "hello"))); diff --git a/avaje-jex-grizzly-spi/pom.xml b/avaje-jex-grizzly-spi/pom.xml new file mode 100644 index 00000000..70b6f7a3 --- /dev/null +++ b/avaje-jex-grizzly-spi/pom.xml @@ -0,0 +1,39 @@ + + 4.0.0 + + io.avaje + avaje-jex-parent + 3.0 + + 0.2 + avaje-jex-grizzly-spi + + + + + io.avaje + avaje-jex + + + + org.glassfish.grizzly + grizzly-http-server + 4.1.0-M1 + + + io.avaje + avaje-spi-service + provided + true + + + + io.avaje + avaje-jex-test + test + + + + diff --git a/avaje-jex-grizzly-spi/src/main/java/io/avaje/jex/grizzly/spi/GrizzlyExchange.java b/avaje-jex-grizzly-spi/src/main/java/io/avaje/jex/grizzly/spi/GrizzlyExchange.java new file mode 100644 index 00000000..10c7fb4f --- /dev/null +++ b/avaje-jex-grizzly-spi/src/main/java/io/avaje/jex/grizzly/spi/GrizzlyExchange.java @@ -0,0 +1,10 @@ +package io.avaje.jex.grizzly.spi; + +import com.sun.net.httpserver.HttpPrincipal; + +sealed interface GrizzlyExchange permits GrizzlyHttpExchange, GrizzlyHttpsExchange { + + HttpPrincipal getPrincipal(); + + void setPrincipal(HttpPrincipal principal); +} diff --git a/avaje-jex-grizzly-spi/src/main/java/io/avaje/jex/grizzly/spi/GrizzlyHandler.java b/avaje-jex-grizzly-spi/src/main/java/io/avaje/jex/grizzly/spi/GrizzlyHandler.java new file mode 100644 index 00000000..4ea79c85 --- /dev/null +++ b/avaje-jex-grizzly-spi/src/main/java/io/avaje/jex/grizzly/spi/GrizzlyHandler.java @@ -0,0 +1,48 @@ +package io.avaje.jex.grizzly.spi; + +import java.io.IOException; +import java.io.UncheckedIOException; + +import org.glassfish.grizzly.http.server.Request; +import org.glassfish.grizzly.http.server.Response; + +import com.sun.net.httpserver.Filter.Chain; +import com.sun.net.httpserver.HttpContext; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; + +final class GrizzlyHandler extends org.glassfish.grizzly.http.server.HttpHandler { + + private final HttpContext httpContext; + + private HttpHandler handler; + + GrizzlyHandler(HttpContext httpContext, HttpHandler httpHandler) { + super(httpContext.getPath()); + this.httpContext = httpContext; + this.handler = httpHandler; + } + + @Override + public void service(Request request, Response response) { + + try (HttpExchange exchange = + request.isSecure() + ? new GrizzlyHttpsExchange(httpContext, request, response) + : new GrizzlyHttpExchange(httpContext, request, response)) { + + new Chain(httpContext.getFilters(), handler).doFilter(exchange); + + } catch (IOException ex) { + throw new UncheckedIOException(null); + } + } + + public HttpHandler getHttpHandler() { + return handler; + } + + public void setHttpHandler(HttpHandler handler) { + this.handler = handler; + } +} diff --git a/avaje-jex-grizzly-spi/src/main/java/io/avaje/jex/grizzly/spi/GrizzlyHttpContext.java b/avaje-jex-grizzly-spi/src/main/java/io/avaje/jex/grizzly/spi/GrizzlyHttpContext.java new file mode 100644 index 00000000..5ede6a56 --- /dev/null +++ b/avaje-jex-grizzly-spi/src/main/java/io/avaje/jex/grizzly/spi/GrizzlyHttpContext.java @@ -0,0 +1,77 @@ +package io.avaje.jex.grizzly.spi; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.sun.net.httpserver.Authenticator; +import com.sun.net.httpserver.Filter; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +final class GrizzlyHttpContext extends com.sun.net.httpserver.HttpContext { + + private final GrizzlyHandler grizzlyHandler; + private final HttpServer server; + + private final Map attributes = new HashMap<>(); + + private final List filters = new ArrayList<>(); + + private Authenticator authenticator; + + private String contextPath; + + protected GrizzlyHttpContext(HttpServer server, String contextPath, HttpHandler handler) { + this.server = server; + this.grizzlyHandler = new GrizzlyHandler(this, handler); + this.contextPath = contextPath; + } + + GrizzlyHandler getGrizzlyHandler() { + return grizzlyHandler; + } + + @Override + public HttpHandler getHandler() { + return grizzlyHandler.getHttpHandler(); + } + + @Override + public void setHandler(HttpHandler h) { + grizzlyHandler.setHttpHandler(h); + } + + @Override + public String getPath() { + return contextPath; + } + + @Override + public HttpServer getServer() { + return server; + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public List getFilters() { + return filters; + } + + @Override + public Authenticator setAuthenticator(Authenticator auth) { + Authenticator previous = authenticator; + authenticator = auth; + return previous; + } + + @Override + public Authenticator getAuthenticator() { + return authenticator; + } +} diff --git a/avaje-jex-grizzly-spi/src/main/java/io/avaje/jex/grizzly/spi/GrizzlyHttpExchange.java b/avaje-jex-grizzly-spi/src/main/java/io/avaje/jex/grizzly/spi/GrizzlyHttpExchange.java new file mode 100644 index 00000000..38fc59c8 --- /dev/null +++ b/avaje-jex-grizzly-spi/src/main/java/io/avaje/jex/grizzly/spi/GrizzlyHttpExchange.java @@ -0,0 +1,129 @@ +package io.avaje.jex.grizzly.spi; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; + +import org.glassfish.grizzly.http.server.Request; +import org.glassfish.grizzly.http.server.Response; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpContext; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpPrincipal; + +final class GrizzlyHttpExchange extends HttpExchange implements GrizzlyExchange { + private final GrizzlyHttpExchangeDelegate delegate; + + public GrizzlyHttpExchange(HttpContext context, Request req, Response resp) { + + delegate = new GrizzlyHttpExchangeDelegate(context, req, resp); + } + + @Override + public int hashCode() { + return delegate.hashCode(); + } + + @Override + public Headers getRequestHeaders() { + return delegate.getRequestHeaders(); + } + + @Override + public Headers getResponseHeaders() { + return delegate.getResponseHeaders(); + } + + @Override + public URI getRequestURI() { + return delegate.getRequestURI(); + } + + @Override + public String getRequestMethod() { + return delegate.getRequestMethod(); + } + + @Override + public HttpContext getHttpContext() { + return delegate.getHttpContext(); + } + + @Override + public void close() { + delegate.close(); + } + + @Override + public boolean equals(Object obj) { + return delegate.equals(obj); + } + + @Override + public InputStream getRequestBody() { + return delegate.getRequestBody(); + } + + @Override + public OutputStream getResponseBody() { + return delegate.getResponseBody(); + } + + @Override + public void sendResponseHeaders(int rCode, long responseLength) throws IOException { + delegate.sendResponseHeaders(rCode, responseLength); + } + + @Override + public InetSocketAddress getRemoteAddress() { + return delegate.getRemoteAddress(); + } + + @Override + public int getResponseCode() { + return delegate.getResponseCode(); + } + + @Override + public InetSocketAddress getLocalAddress() { + return delegate.getLocalAddress(); + } + + @Override + public String getProtocol() { + return delegate.getProtocol(); + } + + @Override + public Object getAttribute(String name) { + return delegate.getAttribute(name); + } + + @Override + public void setAttribute(String name, Object value) { + delegate.setAttribute(name, value); + } + + @Override + public void setStreams(InputStream i, OutputStream o) { + delegate.setStreams(i, o); + } + + @Override + public HttpPrincipal getPrincipal() { + return delegate.getPrincipal(); + } + + @Override + public void setPrincipal(HttpPrincipal principal) { + delegate.setPrincipal(principal); + } + + @Override + public String toString() { + return delegate.toString(); + } +} diff --git a/avaje-jex-grizzly-spi/src/main/java/io/avaje/jex/grizzly/spi/GrizzlyHttpExchangeDelegate.java b/avaje-jex-grizzly-spi/src/main/java/io/avaje/jex/grizzly/spi/GrizzlyHttpExchangeDelegate.java new file mode 100644 index 00000000..a6b6982c --- /dev/null +++ b/avaje-jex-grizzly-spi/src/main/java/io/avaje/jex/grizzly/spi/GrizzlyHttpExchangeDelegate.java @@ -0,0 +1,193 @@ +package io.avaje.jex.grizzly.spi; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.glassfish.grizzly.http.server.Request; +import org.glassfish.grizzly.http.server.Response; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpContext; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpPrincipal; + +final class GrizzlyHttpExchangeDelegate extends HttpExchange { + + /** Set of headers that RFC9110 says will not have a value list */ + private static final Set SINGLE_VALUE_HEADERS = + Set.of( + "authorization", + "content-length", + "date", + "expires", + "host", + "if-modified-since", + "if-unmodified-since", + "if-range", + "last-modified", + "location", + "referer", + "retry-after", + "user-agent"); + + private final HttpContext context; + + private final Request request; + + private final Headers responseHeaders = new Headers(); + + private Headers requestHeaders = new Headers(); + + private int statusCode = 0; + + private InputStream inputStream; + + private OutputStream outputStream; + + private HttpPrincipal httpPrincipal; + + private Response response; + + GrizzlyHttpExchangeDelegate(HttpContext httpSpiContext, Request request, Response response) { + this.context = httpSpiContext; + this.request = request; + this.response = response; + this.inputStream = request.getInputStream(); + this.outputStream = response.getOutputStream(); + } + + @Override + public Headers getRequestHeaders() { + + if (!requestHeaders.isEmpty()) { + return requestHeaders; + } + for (var name : request.getHeaderNames()) { + + if (!SINGLE_VALUE_HEADERS.contains(name.toLowerCase())) { + + for (String value : request.getHeaders(name)) { + requestHeaders.add(name, value); + } + } else { + requestHeaders.add(name, request.getHeader(name)); + } + } + return requestHeaders; + } + + @Override + public Headers getResponseHeaders() { + return responseHeaders; + } + + @Override + public URI getRequestURI() { + return URI.create(request.getRequestURI()); + } + + @Override + public String getRequestMethod() { + return request.getMethod().getMethodString(); + } + + @Override + public HttpContext getHttpContext() { + return context; + } + + @Override + public void close() { + try { + outputStream.close(); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + @Override + public InputStream getRequestBody() { + return inputStream; + } + + @Override + public OutputStream getResponseBody() { + return outputStream; + } + + @Override + public void sendResponseHeaders(int rCode, long responseLength) throws IOException { + this.statusCode = rCode; + + for (Map.Entry> stringListEntry : responseHeaders.entrySet()) { + String name = stringListEntry.getKey(); + List values = stringListEntry.getValue(); + for (String value : values) { + response.addHeader(name, value); + } + } + + if (responseLength == -1) { + response.setContentLengthLong(0); + } else if (responseLength == 0) { + response.setContentLengthLong(-1); + } else { + response.setContentLengthLong(responseLength); + } + + response.setStatus(rCode); + } + + @Override + public InetSocketAddress getRemoteAddress() { + return InetSocketAddress.createUnresolved(request.getRemoteAddr(), request.getRemotePort()); + } + + @Override + public int getResponseCode() { + return statusCode; + } + + @Override + public InetSocketAddress getLocalAddress() { + return new InetSocketAddress(request.getLocalAddr(), request.getLocalPort()); + } + + @Override + public String getProtocol() { + return request.getProtocol().getProtocolString(); + } + + @Override + public Object getAttribute(String name) { + return request.getAttribute(name); + } + + @Override + public void setAttribute(String name, Object value) { + request.setAttribute(name, value); + } + + @Override + public void setStreams(InputStream i, OutputStream o) { + assert inputStream != null; + if (i != null) inputStream = i; + if (o != null) outputStream = o; + } + + @Override + public HttpPrincipal getPrincipal() { + return httpPrincipal; + } + + public void setPrincipal(HttpPrincipal principal) { + this.httpPrincipal = principal; + } +} diff --git a/avaje-jex-grizzly-spi/src/main/java/io/avaje/jex/grizzly/spi/GrizzlyHttpServer.java b/avaje-jex-grizzly-spi/src/main/java/io/avaje/jex/grizzly/spi/GrizzlyHttpServer.java new file mode 100644 index 00000000..cdd9f79c --- /dev/null +++ b/avaje-jex-grizzly-spi/src/main/java/io/avaje/jex/grizzly/spi/GrizzlyHttpServer.java @@ -0,0 +1,161 @@ +package io.avaje.jex.grizzly.spi; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.lang.System.Logger.Level; +import java.net.InetSocketAddress; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.glassfish.grizzly.http.server.HttpServer; +import org.glassfish.grizzly.http.server.NetworkListener; +import org.glassfish.grizzly.http.server.ServerConfiguration; +import org.glassfish.grizzly.ssl.SSLEngineConfigurator; + +import com.sun.net.httpserver.HttpContext; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpsConfigurator; + +final class GrizzlyHttpServer extends com.sun.net.httpserver.HttpsServer { + private static final System.Logger LOG = + System.getLogger(GrizzlyHttpServer.class.getCanonicalName()); + private final HttpServer server; + private InetSocketAddress addr; + private ServerConfiguration httpConfiguration; + private ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + private HttpsConfigurator httpsConfig; + + public GrizzlyHttpServer(HttpServer server) { + + this(server, server.getServerConfiguration()); + } + + public GrizzlyHttpServer(HttpServer server, ServerConfiguration configuration) { + this.server = server; + this.httpConfiguration = configuration; + } + + public ServerConfiguration getHttpConfiguration() { + return httpConfiguration; + } + + @Override + public void bind(InetSocketAddress addr, int backlog) throws IOException { + + this.addr = addr; + // check if there is already a connector listening + var connectors = server.getListeners(); + if (connectors != null) { + for (var connector : connectors) { + if (connector.getPort() == addr.getPort()) { + LOG.log( + Level.DEBUG, "server already bound to port {}, no need to rebind", addr.getPort()); + return; + } + } + } + + if (LOG.isLoggable(Level.DEBUG)) { + LOG.log(Level.DEBUG, "binding server to port " + addr.getPort()); + } + var listener = new NetworkListener("rizzly", addr.getHostName(), addr.getPort()); + listener.getTransport().setWorkerThreadPool(executor); + if (backlog != 0) { + listener.getTransport().setServerConnectionBackLog(backlog); + } + if (httpsConfig != null) { + listener.setSSLEngineConfig(new SSLEngineConfigurator(httpsConfig.getSSLContext())); + } + + server.addListener(listener); + } + + protected HttpServer getServer() { + return server; + } + + @Override + public InetSocketAddress getAddress() { + if (addr.getPort() == 0 && server.isStarted()) { + return new InetSocketAddress(addr.getHostString(), server.getListener("rizzly").getPort()); + } + return addr; + } + + @Override + public void start() { + + try { + server.start(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public void setExecutor(Executor executor) { + if (executor instanceof ExecutorService service) { + this.executor = service; + } else { + throw new IllegalArgumentException("Grizzly only accepts an instance of ExecutorService"); + } + } + + @Override + public Executor getExecutor() { + return executor; + } + + @Override + public void stop(int delay) { + server.shutdownNow(); + } + + @Override + public HttpContext createContext(String path, HttpHandler httpHandler) { + + GrizzlyHttpContext context = new GrizzlyHttpContext(this, path, httpHandler); + GrizzlyHandler jettyContextHandler = context.getGrizzlyHandler(); + + httpConfiguration.addHttpHandler( + jettyContextHandler, path.transform(this::prependSlash).transform(this::appendSlash)); + + return context; + } + + private String prependSlash(String s) { + return s.startsWith("/") ? s : "/" + s; + } + + private String appendSlash(String s) { + return s.endsWith("/") ? s + "*" : s + "/*"; + } + + @Override + public HttpContext createContext(String path) { + return createContext(path, null); + } + + @Override + public void removeContext(String path) { + + throw new UnsupportedOperationException("notImplemented"); + } + + @Override + public void removeContext(HttpContext context) { + + throw new UnsupportedOperationException("notImplemented"); + } + + @Override + public void setHttpsConfigurator(HttpsConfigurator config) { + httpsConfig = config; + } + + @Override + public HttpsConfigurator getHttpsConfigurator() { + return httpsConfig; + } +} diff --git a/avaje-jex-grizzly-spi/src/main/java/io/avaje/jex/grizzly/spi/GrizzlyHttpServerProvider.java b/avaje-jex-grizzly-spi/src/main/java/io/avaje/jex/grizzly/spi/GrizzlyHttpServerProvider.java new file mode 100644 index 00000000..bd21367d --- /dev/null +++ b/avaje-jex-grizzly-spi/src/main/java/io/avaje/jex/grizzly/spi/GrizzlyHttpServerProvider.java @@ -0,0 +1,50 @@ +package io.avaje.jex.grizzly.spi; + +import java.io.IOException; +import java.net.InetSocketAddress; + +import org.glassfish.grizzly.http.server.HttpServer; + +import com.sun.net.httpserver.HttpsServer; +import com.sun.net.httpserver.spi.HttpServerProvider; + +import io.avaje.spi.ServiceProvider; + +@ServiceProvider +public class GrizzlyHttpServerProvider extends HttpServerProvider { + + private org.glassfish.grizzly.http.server.HttpServer server; + + public GrizzlyHttpServerProvider(HttpServer server) { + + this.server = server; + } + + public GrizzlyHttpServerProvider() { + + this.server = new HttpServer(); + } + + @Override + public com.sun.net.httpserver.HttpServer createHttpServer(InetSocketAddress addr, int backlog) + throws IOException { + + return createServer(addr, backlog); + } + + @Override + public HttpsServer createHttpsServer(InetSocketAddress addr, int backlog) throws IOException { + return createServer(addr, backlog); + } + + private com.sun.net.httpserver.HttpsServer createServer(InetSocketAddress addr, int backlog) + throws IOException { + if (server == null) { + server = new HttpServer(); + } + + GrizzlyHttpServer jettyHttpServer = new GrizzlyHttpServer(server); + if (addr != null) jettyHttpServer.bind(addr, backlog); + return jettyHttpServer; + } +} diff --git a/avaje-jex-grizzly-spi/src/main/java/io/avaje/jex/grizzly/spi/GrizzlyHttpsExchange.java b/avaje-jex-grizzly-spi/src/main/java/io/avaje/jex/grizzly/spi/GrizzlyHttpsExchange.java new file mode 100644 index 00000000..d1e686d6 --- /dev/null +++ b/avaje-jex-grizzly-spi/src/main/java/io/avaje/jex/grizzly/spi/GrizzlyHttpsExchange.java @@ -0,0 +1,125 @@ +package io.avaje.jex.grizzly.spi; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; + +import javax.net.ssl.SSLSession; + +import org.glassfish.grizzly.http.server.Request; +import org.glassfish.grizzly.http.server.Response; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpContext; +import com.sun.net.httpserver.HttpPrincipal; +import com.sun.net.httpserver.HttpsExchange; + +final class GrizzlyHttpsExchange extends HttpsExchange implements GrizzlyExchange { + private final GrizzlyHttpExchangeDelegate delegate; + + public GrizzlyHttpsExchange(HttpContext jaxWsContext, Request req, Response resp) { + delegate = new GrizzlyHttpExchangeDelegate(jaxWsContext, req, resp); + } + + @Override + public Headers getRequestHeaders() { + return delegate.getRequestHeaders(); + } + + @Override + public Headers getResponseHeaders() { + return delegate.getResponseHeaders(); + } + + @Override + public URI getRequestURI() { + return delegate.getRequestURI(); + } + + @Override + public String getRequestMethod() { + return delegate.getRequestMethod(); + } + + @Override + public HttpContext getHttpContext() { + return delegate.getHttpContext(); + } + + @Override + public void close() { + delegate.close(); + } + + @Override + public InputStream getRequestBody() { + return delegate.getRequestBody(); + } + + @Override + public OutputStream getResponseBody() { + return delegate.getResponseBody(); + } + + @Override + public void sendResponseHeaders(int rCode, long responseLength) throws IOException { + delegate.sendResponseHeaders(rCode, responseLength); + } + + @Override + public InetSocketAddress getRemoteAddress() { + return delegate.getRemoteAddress(); + } + + @Override + public int getResponseCode() { + return delegate.getResponseCode(); + } + + @Override + public InetSocketAddress getLocalAddress() { + return delegate.getLocalAddress(); + } + + @Override + public String getProtocol() { + return delegate.getProtocol(); + } + + @Override + public Object getAttribute(String name) { + return delegate.getAttribute(name); + } + + @Override + public void setAttribute(String name, Object value) { + delegate.setAttribute(name, value); + } + + @Override + public void setStreams(InputStream i, OutputStream o) { + delegate.setStreams(i, o); + } + + @Override + public HttpPrincipal getPrincipal() { + return delegate.getPrincipal(); + } + + @Override + public void setPrincipal(HttpPrincipal principal) { + delegate.setPrincipal(principal); + } + + @Override + public String toString() { + return delegate.toString(); + } + + @Override + public SSLSession getSSLSession() { + return null; + } +} diff --git a/avaje-jex-grizzly-spi/src/main/java/module-info.java b/avaje-jex-grizzly-spi/src/main/java/module-info.java new file mode 100644 index 00000000..6378b580 --- /dev/null +++ b/avaje-jex-grizzly-spi/src/main/java/module-info.java @@ -0,0 +1,17 @@ +import com.sun.net.httpserver.spi.HttpServerProvider; + +module io.avaje.jex.grizzly { + + exports io.avaje.jex.grizzly.spi; + + requires transitive io.avaje.jex; + requires transitive jdk.httpserver; + requires transitive org.glassfish.grizzly.http.server; + requires transitive org.glassfish.grizzly.http; + requires transitive org.glassfish.grizzly; + + requires static io.avaje.spi; + requires static java.net.http; + + provides HttpServerProvider with io.avaje.jex.grizzly.spi.GrizzlyHttpServerProvider; +} diff --git a/avaje-jex-grizzly-spi/src/test/java/io/avaje/jex/grizzly/spi/FilterTest.java b/avaje-jex-grizzly-spi/src/test/java/io/avaje/jex/grizzly/spi/FilterTest.java new file mode 100644 index 00000000..fc6cc485 --- /dev/null +++ b/avaje-jex-grizzly-spi/src/test/java/io/avaje/jex/grizzly/spi/FilterTest.java @@ -0,0 +1,119 @@ +package io.avaje.jex.grizzly.spi; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.http.HttpHeaders; +import java.net.http.HttpResponse; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.LockSupport; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +import io.avaje.jex.Jex; +import io.avaje.jex.test.TestPair; + +class FilterTest { + + static final TestPair pair = init(); + static final AtomicReference afterAll = new AtomicReference<>(); + static final AtomicReference afterTwo = new AtomicReference<>(); + + static TestPair init() { + final Jex app = + Jex.create() + .routing( + routing -> + routing + .get("/", ctx -> ctx.text("roo")) + .get( + "/noResponse", + ctx -> { + ctx.header("Content-Type", ""); + }) + .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")) + .filter( + (ctx, chain) -> { + if (ctx.path().contains("/two/")) { + ctx.header("before-two", "set"); + } + chain.proceed(); + }) + .after(ctx -> afterAll.set("set")) + .filter( + (ctx, chain) -> { + chain.proceed(); + if (ctx.path().contains("/two/")) { + 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 getNoResponse() { + clearAfter(); + HttpResponse res = pair.request().path("noResponse").GET().asString(); + assertThat(res.statusCode()).isEqualTo(204); + 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.statusCode()).isLessThan(300); + assertThat(res.headers().firstValue("before-two")).isEmpty(); + assertThat(afterTwo.get()).isNull(); + } + + private void assertHasBeforeAfterAll(HttpResponse res) { + assertThat(res.statusCode()).isLessThan(300); + assertThat(res.headers().firstValue("before-all")).get().isEqualTo("set"); + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(2)); + assertThat(afterAll.get()).isEqualTo("set"); + } +} diff --git a/avaje-jex-grizzly/pom.xml b/avaje-jex-grizzly/pom.xml deleted file mode 100644 index a32a1510..00000000 --- a/avaje-jex-grizzly/pom.xml +++ /dev/null @@ -1,53 +0,0 @@ - - - - avaje-jex-parent - io.avaje - 2.5 - - 4.0.0 - - avaje-jex-grizzly - - - - - - - - - io.avaje - avaje-jex - 2.5 - - - - org.glassfish.grizzly - grizzly-http-server - 3.0.0 - - - - org.glassfish.grizzly - grizzly-http2 - 3.0.0 - true - - - - com.fasterxml.jackson.core - jackson-databind - 2.14.0 - test - - - - org.slf4j - jul-to-slf4j - 1.7.36 - 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 deleted file mode 100644 index 82705c7d..00000000 --- a/avaje-jex-grizzly/src/main/java/io/avaje/jex/grizzly/ContextUtil.java +++ /dev/null @@ -1,65 +0,0 @@ -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 deleted file mode 100644 index 13f159f2..00000000 --- a/avaje-jex-grizzly/src/main/java/io/avaje/jex/grizzly/GrizzlyContext.java +++ /dev/null @@ -1,480 +0,0 @@ -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 org.glassfish.grizzly.http.server.Request; -import org.glassfish.grizzly.http.server.Response; -import org.glassfish.grizzly.http.server.Session; -import org.glassfish.grizzly.http.util.ContentType; - -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.UncheckedIOException; -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 Map pathParams; - 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, Map pathParams) { - this.mgr = mgr; - this.request = request; - this.response = response; - this.path = path; - this.pathParams = pathParams; - } - - /** - * 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.pathParams = 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 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 Map pathParamMap() { - return pathParams; - } - - @Override - public String pathParam(String name) { - return 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) { - Session session = request.getSession(false); - return session == null ? null : (T) session.getAttribute(key); - } - - @Override - public Map sessionAttributeMap() { - Session session = request.getSession(false); - return session == null ? emptyMap() : session.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 boolean isCommitted() { - return response.isCommitted(); - } - - public void reset() { - response.reset(); - } - - @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 deleted file mode 100644 index d6ce9e46..00000000 --- a/avaje-jex-grizzly/src/main/java/io/avaje/jex/grizzly/GrizzlyJexServer.java +++ /dev/null @@ -1,57 +0,0 @@ -package io.avaje.jex.grizzly; - -import io.avaje.applog.AppLog; -import io.avaje.jex.AppLifecycle; -import io.avaje.jex.Jex; -import org.glassfish.grizzly.http.server.HttpServer; - -import java.lang.System.Logger.Level; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.ReentrantLock; - -class GrizzlyJexServer implements Jex.Server { - - private static final System.Logger log = AppLog.getLogger("io.avaje.jex"); - - 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); - lifecycle.status(AppLifecycle.Status.STARTED); - } - - @Override - public void onShutdown(Runnable onShutdown) { - lifecycle.onShutdown(onShutdown, Integer.MAX_VALUE); - } - - @Override - public void shutdown() { - lock.lock(); - try { - if (shutdown) { - log.log(Level.DEBUG, "shutdown in progress"); - } else { - shutdown = true; - lifecycle.status(AppLifecycle.Status.STOPPING); - log.log(Level.DEBUG, "initiate shutdown with maxWaitSeconds {0}", maxWaitSeconds); - try { - server.shutdown(maxWaitSeconds, TimeUnit.SECONDS).get(); - } catch (InterruptedException |ExecutionException e) { - log.log(Level.ERROR, "Error during server shutdown", e); - } - log.log(Level.TRACE, "server http listeners stopped"); - lifecycle.status(AppLifecycle.Status.STOPPED); - } - } 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 deleted file mode 100644 index a3de6fb2..00000000 --- a/avaje-jex-grizzly/src/main/java/io/avaje/jex/grizzly/GrizzlyServerStart.java +++ /dev/null @@ -1,42 +0,0 @@ -package io.avaje.jex.grizzly; - -import io.avaje.applog.AppLog; -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 java.io.IOException; -import java.io.UncheckedIOException; -import java.lang.System.Logger.Level; - -public class GrizzlyServerStart implements SpiStartServer { - - private static final System.Logger log = AppLog.getLogger("io.avaje.jex"); - - @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.config().port(); - final HttpServer httpServer = new HttpServerBuilder() - //.addHandler(clStaticHttpHandler, "cl") - //.addHandler(staticHttpHandler, "static") - .handler(handler) - .setPort(port) - .build(); - - try { - log.log(Level.DEBUG, "starting server on port {0,number,#}", port); - httpServer.start(); - log.log(Level.INFO, "server started on port {0,number,#}", 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 deleted file mode 100644 index 3e224a39..00000000 --- a/avaje-jex-grizzly/src/main/java/io/avaje/jex/grizzly/HttpServerBuilder.java +++ /dev/null @@ -1,98 +0,0 @@ -package io.avaje.jex.grizzly; - -import io.avaje.applog.AppLog; -import org.glassfish.grizzly.http.server.*; -import org.glassfish.grizzly.ssl.SSLEngineConfigurator; -import org.glassfish.grizzly.utils.Charsets; - -import java.lang.System.Logger.Level; - -public class HttpServerBuilder { - - private static final System.Logger log = AppLog.getLogger("io.avaje.jex"); - - 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.log(Level.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 deleted file mode 100644 index bbd02875..00000000 --- a/avaje-jex-grizzly/src/main/java/io/avaje/jex/grizzly/RouteHandler.java +++ /dev/null @@ -1,82 +0,0 @@ -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; - -import java.util.Map; - -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 Map 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 deleted file mode 100644 index b7be3145..00000000 --- a/avaje-jex-grizzly/src/main/java/io/avaje/jex/grizzly/ServiceManager.java +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 855a5fe8..00000000 --- a/avaje-jex-grizzly/src/main/resources/META-INF/services/io.avaje.jex.spi.SpiStartServer +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 819941a7..00000000 --- a/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/AutoCloseIterator.java +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 11ed44c4..00000000 --- a/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/ContextFormParamTest.java +++ /dev/null @@ -1,135 +0,0 @@ -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/ContextTest.java b/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/ContextTest.java deleted file mode 100644 index 9e5f6403..00000000 --- a/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/ContextTest.java +++ /dev/null @@ -1,194 +0,0 @@ -package io.avaje.jex.grizzly; - -import io.avaje.jex.Context; -import io.avaje.jex.Jex; -//import io.avaje.jex.core.JsonbJsonService; -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() - //.configure(jex -> jex.jsonService(new JsonbJsonService())) - .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 deleted file mode 100644 index d04072c1..00000000 --- a/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/HelloDto.java +++ /dev/null @@ -1,30 +0,0 @@ -package io.avaje.jex.grizzly; - -//import io.avaje.jsonb.Json; - -//@Json -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 deleted file mode 100644 index 6136b4ae..00000000 --- a/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/HelloWorldTest.java +++ /dev/null @@ -1,48 +0,0 @@ -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 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 deleted file mode 100644 index 1c5e0438..00000000 --- a/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/JsonTest.java +++ /dev/null @@ -1,121 +0,0 @@ -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); - - // expect client gets the expected stream of beans - assertCollectedStream(beanStream); - // assert AutoCloseable iterator on the server-side was closed - assertThat(ITERATOR.isClosed()).isTrue(); - } - - @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/TestPair.java b/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/TestPair.java deleted file mode 100644 index 4fbdf6c4..00000000 --- a/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/TestPair.java +++ /dev/null @@ -1,64 +0,0 @@ -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.builder() - .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 deleted file mode 100644 index 004d9091..00000000 --- a/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/VanillaMain.java +++ /dev/null @@ -1,42 +0,0 @@ -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/myres/hello.txt b/avaje-jex-grizzly/src/test/resources/myres/hello.txt deleted file mode 100644 index d6613f5f..00000000 --- a/avaje-jex-grizzly/src/test/resources/myres/hello.txt +++ /dev/null @@ -1 +0,0 @@ -Hello there diff --git a/avaje-jex-htmx/pom.xml b/avaje-jex-htmx/pom.xml new file mode 100644 index 00000000..8ee18a29 --- /dev/null +++ b/avaje-jex-htmx/pom.xml @@ -0,0 +1,28 @@ + + + 4.0.0 + + io.avaje + avaje-jex-parent + 3.0 + + + avaje-jex-htmx + + + + + + + io.avaje + avaje-htmx-api + + + io.avaje + avaje-jex + + + + diff --git a/avaje-jex-htmx/src/main/java/io/avaje/jex/htmx/DHxHandler.java b/avaje-jex-htmx/src/main/java/io/avaje/jex/htmx/DHxHandler.java new file mode 100644 index 00000000..d892d4d0 --- /dev/null +++ b/avaje-jex-htmx/src/main/java/io/avaje/jex/htmx/DHxHandler.java @@ -0,0 +1,43 @@ +package io.avaje.jex.htmx; + +import static io.avaje.jex.htmx.HxHeaders.HX_REQUEST; +import static io.avaje.jex.htmx.HxHeaders.HX_TARGET; +import static io.avaje.jex.htmx.HxHeaders.HX_TRIGGER; +import static io.avaje.jex.htmx.HxHeaders.HX_TRIGGER_NAME; + +import io.avaje.jex.http.Context; +import io.avaje.jex.http.ExchangeHandler; + +final class DHxHandler implements ExchangeHandler { + + private final ExchangeHandler delegate; + private final String target; + private final String trigger; + private final String triggerName; + + DHxHandler(ExchangeHandler delegate, String target, String trigger, String triggerName) { + this.delegate = delegate; + this.target = target; + this.trigger = trigger; + this.triggerName = triggerName; + } + + @Override + public void handle(Context ctx) throws Exception { + if (ctx.header(HX_REQUEST) != null && matched(ctx)) { + delegate.handle(ctx); + } + } + + private boolean matched(Context ctx) { + if ((target != null && notMatched(ctx.header(HX_TARGET), target)) || (trigger != null && notMatched(ctx.header(HX_TRIGGER), trigger))) { + return false; + } + return triggerName == null || !notMatched(ctx.header(HX_TRIGGER_NAME), triggerName); + } + + private boolean notMatched(String header, String matchValue) { + return header == null || !matchValue.equals(header); + } + +} diff --git a/avaje-jex-htmx/src/main/java/io/avaje/jex/htmx/DHxHandlerBuilder.java b/avaje-jex-htmx/src/main/java/io/avaje/jex/htmx/DHxHandlerBuilder.java new file mode 100644 index 00000000..eff808e9 --- /dev/null +++ b/avaje-jex-htmx/src/main/java/io/avaje/jex/htmx/DHxHandlerBuilder.java @@ -0,0 +1,38 @@ +package io.avaje.jex.htmx; + +import io.avaje.jex.http.ExchangeHandler; + +final class DHxHandlerBuilder implements HxHandler.Builder { + + private final ExchangeHandler delegate; + private String target; + private String trigger; + private String triggerName; + + DHxHandlerBuilder(ExchangeHandler delegate) { + this.delegate = delegate; + } + + @Override + public DHxHandlerBuilder target(String target) { + this.target = target; + return this; + } + + @Override + public DHxHandlerBuilder trigger(String trigger) { + this.trigger = trigger; + return this; + } + + @Override + public DHxHandlerBuilder triggerName(String triggerName) { + this.triggerName = triggerName; + return this; + } + + @Override + public ExchangeHandler build() { + return new DHxHandler(delegate, target, trigger, triggerName); + } +} diff --git a/avaje-jex-htmx/src/main/java/io/avaje/jex/htmx/HxHandler.java b/avaje-jex-htmx/src/main/java/io/avaje/jex/htmx/HxHandler.java new file mode 100644 index 00000000..b684b0f3 --- /dev/null +++ b/avaje-jex-htmx/src/main/java/io/avaje/jex/htmx/HxHandler.java @@ -0,0 +1,46 @@ +package io.avaje.jex.htmx; + +import io.avaje.jex.http.ExchangeHandler; + +/** + * Wrap a Handler with filtering for Htmx specific headers. + *

+ * The underlying Handler will not be invoked unless the request + * is a Htmx request and matches the required attributes. + */ +public interface HxHandler { + + /** + * Create a builder that wraps the underlying handler with Htmx + * specific attribute matching. + */ + static Builder builder(ExchangeHandler delegate) { + return new DHxHandlerBuilder(delegate); + } + + /** + * Build the Htmx request handler. + */ + interface Builder { + + /** + * Match on the given target. + */ + Builder target(String target); + + /** + * Match on the given trigger. + */ + Builder trigger(String trigger); + + /** + * Match on the given trigger name. + */ + Builder triggerName(String triggerName); + + /** + * Build and return the Handler. + */ + ExchangeHandler build(); + } +} diff --git a/avaje-jex-htmx/src/main/java/io/avaje/jex/htmx/HxHeaders.java b/avaje-jex-htmx/src/main/java/io/avaje/jex/htmx/HxHeaders.java new file mode 100644 index 00000000..9d2a4da5 --- /dev/null +++ b/avaje-jex-htmx/src/main/java/io/avaje/jex/htmx/HxHeaders.java @@ -0,0 +1,62 @@ +package io.avaje.jex.htmx; + +/** + * HTMX request headers. + * + * @see Request Headers Reference + */ +public interface HxHeaders { + + /** + * Indicates that the request comes from an element that uses hx-boost. + * + * @see HX-Boosted + */ + String HX_BOOSTED = "HX-Boosted"; + + /** + * The current URL of the browser + * + * @see HX-Current-URL + */ + String HX_CURRENT_URL = "HX-Current-URL"; + + /** + * Indicates if the request is for history restoration after a miss in the local history cache. + * + * @see HX-History-Restore-Request + */ + String HX_HISTORY_RESTORE_REQUEST = "HX-History-Restore-Request"; + + /** + * Contains the user response to a hx-prompt. + * + * @see HX-Prompt + */ + String HX_PROMPT = "HX-Prompt"; + /** + * Only present and {@code true} if the request is issued by htmx. + * + * @see HX-Request + */ + String HX_REQUEST = "HX-Request"; + /** + * The {@code id} of the target element if it exists. + * + * @see HX-Target + */ + String HX_TARGET = "HX-Target"; + /** + * The {@code name} of the triggered element if it exists + * + * @see HX-Trigger-Name + */ + String HX_TRIGGER_NAME = "HX-Trigger-Name"; + /** + * The {@code id} of the triggered element if it exists. + * + * @see HX-Trigger + */ + String HX_TRIGGER = "HX-Trigger"; + +} diff --git a/avaje-jex-htmx/src/main/java/io/avaje/jex/htmx/HxReq.java b/avaje-jex-htmx/src/main/java/io/avaje/jex/htmx/HxReq.java new file mode 100644 index 00000000..8b4cfb5e --- /dev/null +++ b/avaje-jex-htmx/src/main/java/io/avaje/jex/htmx/HxReq.java @@ -0,0 +1,49 @@ +package io.avaje.jex.htmx; + +import io.avaje.htmx.api.HtmxRequest; +import io.avaje.jex.http.Context; + +/** + * Obtain the HtmxRequest for the given Jex Context. + */ +public final class HxReq { + + /** + * Create given the server request. + */ + public static HtmxRequest of(Context ctx) { + String header = ctx.header(HxHeaders.HX_REQUEST); + if (header == null) { + return HtmxRequest.EMPTY; + } + + var builder = HtmxRequest.builder(); + if (ctx.header(HxHeaders.HX_BOOSTED) != null) { + builder.boosted(true); + } + if (ctx.header(HxHeaders.HX_HISTORY_RESTORE_REQUEST) != null) { + builder.historyRestoreRequest(true); + } + var currentUrl = ctx.header(HxHeaders.HX_CURRENT_URL); + if (currentUrl != null) { + builder.currentUrl(currentUrl); + } + var prompt = ctx.header(HxHeaders.HX_PROMPT); + if (prompt != null) { + builder.promptResponse(prompt); + } + var target = ctx.header(HxHeaders.HX_TARGET); + if (target != null) { + builder.target(target); + } + var triggerName = ctx.header(HxHeaders.HX_TRIGGER_NAME); + if (triggerName != null) { + builder.triggerName(triggerName); + } + var trigger = ctx.header(HxHeaders.HX_TRIGGER); + if (trigger != null) { + builder.triggerId(trigger); + } + return builder.build(); + } +} diff --git a/avaje-jex-htmx/src/main/java/io/avaje/jex/htmx/TemplateContentCache.java b/avaje-jex-htmx/src/main/java/io/avaje/jex/htmx/TemplateContentCache.java new file mode 100644 index 00000000..5472bfa6 --- /dev/null +++ b/avaje-jex-htmx/src/main/java/io/avaje/jex/htmx/TemplateContentCache.java @@ -0,0 +1,30 @@ +package io.avaje.jex.htmx; + +import io.avaje.jex.http.Context; + +/** + * Defines caching of template content. + */ +public interface TemplateContentCache { + + /** + * Return the key given the request. + */ + String key(Context req); + + /** + * Return the key given the request with form parameters. + */ + String key(Context req, Object formParams); + + /** + * Return the content given the key. + */ + String content(String key); + + /** + * Put the content into the cache. + */ + void contentPut(String key, String content); + +} diff --git a/avaje-jex-htmx/src/main/java/io/avaje/jex/htmx/TemplateRender.java b/avaje-jex-htmx/src/main/java/io/avaje/jex/htmx/TemplateRender.java new file mode 100644 index 00000000..9dd8f9d2 --- /dev/null +++ b/avaje-jex-htmx/src/main/java/io/avaje/jex/htmx/TemplateRender.java @@ -0,0 +1,12 @@ +package io.avaje.jex.htmx; + +/** + * Template render API. + */ +public interface TemplateRender { + + /** + * Render the given template view model to the server response. + */ + String render(Object viewModel); +} diff --git a/avaje-jex-htmx/src/main/java/module-info.java b/avaje-jex-htmx/src/main/java/module-info.java new file mode 100644 index 00000000..5dea08d2 --- /dev/null +++ b/avaje-jex-htmx/src/main/java/module-info.java @@ -0,0 +1,8 @@ +module io.avaje.jex.htmx { + + requires transitive io.avaje.jex; + requires transitive io.avaje.htmx.api; + requires transitive jdk.httpserver; + + exports io.avaje.jex.htmx; +} diff --git a/avaje-jex-jdk/pom.xml b/avaje-jex-jdk/pom.xml deleted file mode 100644 index 04a8798b..00000000 --- a/avaje-jex-jdk/pom.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - avaje-jex-parent - io.avaje - 2.5 - - 4.0.0 - - avaje-jex-jdk - - - 11 - 11 - false - - - - - - io.avaje - avaje-jex - 2.5 - - - - com.fasterxml.jackson.core - jackson-databind - 2.14.0 - test - - - - - - 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 deleted file mode 100644 index f9beef54..00000000 --- a/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/BaseHandler.java +++ /dev/null @@ -1,93 +0,0 @@ -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 java.util.Map; - -class BaseHandler implements HttpHandler { - - private final SpiRoutes routes; - private final ServiceManager mgr; - - BaseHandler(SpiRoutes routes, ServiceManager mgr) { - this.mgr = mgr; - this.routes = routes; - } - - void waitForIdle(long maxSeconds) { - routes.waitForIdle(maxSeconds); - } - - @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 Map 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/JdkServerStart.java b/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/JdkServerStart.java deleted file mode 100644 index 47e7d8a5..00000000 --- a/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/JdkServerStart.java +++ /dev/null @@ -1,43 +0,0 @@ -package io.avaje.jex.jdk; - -import com.sun.net.httpserver.HttpServer; -import io.avaje.applog.AppLog; -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 java.io.IOException; -import java.lang.System.Logger.Level; -import java.net.InetSocketAddress; -import java.util.concurrent.Executor; - -public class JdkServerStart implements SpiStartServer { - - private static final System.Logger log = AppLog.getLogger("io.avaje.jex"); - - @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.config().port(); - server.bind(new InetSocketAddress(port), 0); - server.start(); - jex.lifecycle().status(AppLifecycle.Status.STARTED); - String jexVersion = Jex.class.getPackage().getImplementationVersion(); - log.log(Level.INFO, "started server on port {0,number,#} version {1}", port, jexVersion); - 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 deleted file mode 100644 index 59df313c..00000000 --- a/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/ServiceManager.java +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index a30fd880..00000000 --- a/avaje-jex-jdk/src/main/java/module-info.java +++ /dev/null @@ -1,11 +0,0 @@ -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; - - 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 deleted file mode 100644 index 79199ebe..00000000 --- a/avaje-jex-jdk/src/main/resources/META-INF/services/io.avaje.jex.spi.SpiStartServer +++ /dev/null @@ -1 +0,0 @@ -io.avaje.jex.jdk.JdkServerStart 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 deleted file mode 100644 index 116ff93f..00000000 --- a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/ContextAttributeTest.java +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index 6ca5e2c0..00000000 --- a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/ContextFormParamTest.java +++ /dev/null @@ -1,135 +0,0 @@ -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 deleted file mode 100644 index f817711a..00000000 --- a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/ContextLengthTest.java +++ /dev/null @@ -1,106 +0,0 @@ -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/FilterTest.java b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/FilterTest.java deleted file mode 100644 index 9280e929..00000000 --- a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/FilterTest.java +++ /dev/null @@ -1,89 +0,0 @@ -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/JsonTest.java b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/JsonTest.java deleted file mode 100644 index 8a61e434..00000000 --- a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/JsonTest.java +++ /dev/null @@ -1,121 +0,0 @@ -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); - - // expect client gets the expected stream of beans - assertCollectedStream(beanStream); - // assert AutoCloseable iterator on the server-side was closed - assertThat(ITERATOR.isClosed()).isTrue(); - } - - @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/QueryParamTest.java b/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/QueryParamTest.java deleted file mode 100644 index 64e7ed13..00000000 --- a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/QueryParamTest.java +++ /dev/null @@ -1,150 +0,0 @@ -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 deleted file mode 100644 index 418b21be..00000000 --- a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/RedirectTest.java +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index 9bac68c2..00000000 --- a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/TestPair.java +++ /dev/null @@ -1,65 +0,0 @@ -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.builder() - .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 deleted file mode 100644 index 5e5a132a..00000000 --- a/avaje-jex-jdk/src/test/resources/logback-test.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - 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 deleted file mode 100644 index bd9cd88f..00000000 --- a/avaje-jex-jetty/pom.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - avaje-jex-parent - io.avaje - 2.5 - - 4.0.0 - - avaje-jex-jetty - - - 11 - 11 - 11.0.13 - false - - - - - io.avaje - avaje-jex - 2.5 - - - - org.eclipse.jetty - jetty-server - ${jetty.version} - - - - com.mashape.unirest - unirest-java - 1.4.9 - test - - - - com.fasterxml.jackson.core - jackson-databind - 2.14.0 - test - - - - - - 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 deleted file mode 100644 index c986ffbe..00000000 --- a/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/ContextUtil.java +++ /dev/null @@ -1,41 +0,0 @@ -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-jetty/src/main/java/io/avaje/jex/jetty/JettyBuilder.java b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JettyBuilder.java deleted file mode 100644 index 99d59652..00000000 --- a/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JettyBuilder.java +++ /dev/null @@ -1,59 +0,0 @@ -package io.avaje.jex.jetty; - -import io.avaje.jex.JexConfig; -import io.avaje.jex.Jex; -import org.eclipse.jetty.server.Connector; -import org.eclipse.jetty.server.Server; -import org.eclipse.jetty.server.ServerConnector; -import org.eclipse.jetty.util.thread.QueuedThreadPool; -import org.eclipse.jetty.util.thread.ThreadPool; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.lang.reflect.Constructor; - -/** - * Build the Jetty Server. - */ -class JettyBuilder { - - private static final Logger log = LoggerFactory.getLogger(JettyBuilder.class); - - private final JexConfig jexConfig; - private final JettyServerConfig jettyConfig; - - JettyBuilder(Jex jex, JettyServerConfig jettyConfig) { - this.jexConfig = jex.config(); - this.jettyConfig = jettyConfig; - } - - Server build() { - Server jetty = new Server(pool()); - ServerConnector connector = new ServerConnector(jetty); - connector.setPort(jexConfig.port()); - if (jexConfig.host() != null ) { - connector.setHost(jexConfig.host()); - } - jetty.setConnectors(new Connector[]{connector}); - return jetty; - } - - private ThreadPool pool() { - if (jexConfig.virtualThreads()) { - return virtualThreadBasePool(); - } else { - return jettyConfig.maxThreads() == 0 ? new QueuedThreadPool() : new QueuedThreadPool(jettyConfig.maxThreads()); - } - } - - private ThreadPool virtualThreadBasePool() { - try { - final Class aClass = Class.forName("io.avaje.jex.jetty.threadpool.VirtualThreadPool"); - final Constructor constructor = aClass.getConstructor(); - return (ThreadPool) constructor.newInstance(); - } catch (Exception e) { - throw new IllegalStateException("Failed to start Loom threadPool", e); - } - } - -} diff --git a/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JettyJexServer.java b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JettyJexServer.java deleted file mode 100644 index 7253c3a7..00000000 --- a/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JettyJexServer.java +++ /dev/null @@ -1,169 +0,0 @@ -package io.avaje.jex.jetty; - -import io.avaje.jex.*; -import io.avaje.jex.spi.SpiRoutes; -import io.avaje.jex.spi.SpiServiceManager; -import jakarta.servlet.MultipartConfigElement; -import org.eclipse.jetty.server.Connector; -import org.eclipse.jetty.server.Handler; -import org.eclipse.jetty.server.Server; -import org.eclipse.jetty.server.ServerConnector; -import org.eclipse.jetty.server.session.SessionHandler; -import org.eclipse.jetty.util.Uptime; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.List; - -class JettyJexServer implements Jex.Server { - - private static final Logger log = LoggerFactory.getLogger(Jex.class); - - private final Jex jex; - private final SpiRoutes routes; - private final ServiceManager serviceManager; - private final JettyServerConfig config; - private final AppLifecycle lifecycle; - private final long startTime; - private final JexConfig jexConfig; - private Server server; - - JettyJexServer(Jex jex, SpiRoutes routes, SpiServiceManager serviceManager) { - this.startTime = System.currentTimeMillis(); - this.jex = jex; - this.jexConfig = jex.config(); - this.lifecycle = jex.lifecycle(); - 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(jexConfig.multipartConfig())); - } - - MultipartConfigElement initMultipartConfigElement(UploadConfig uploadConfig) { - if (uploadConfig == null) { - final int fileThreshold = jexConfig.multipartFileThreshold(); - return new MultipartConfigElement(System.getProperty("java.io.tmpdir"), -1, -1, fileThreshold); - } - return new MultipartConfigElement(uploadConfig.location(), uploadConfig.maxFileSize(), uploadConfig.maxRequestSize(), uploadConfig.fileSizeThreshold()); - } - - @Override - public void onShutdown(Runnable onShutdown) { - lifecycle.onShutdown(onShutdown, Integer.MAX_VALUE); - } - - @Override - public void restart() { - try { - server.start(); - logOnStart(server); - lifecycle.status(AppLifecycle.Status.STARTED); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - @Override - public void shutdown() { - try { - log.trace("starting shutdown"); - lifecycle.status(AppLifecycle.Status.STOPPING); - routes.waitForIdle(30); - server.stop(); - log.trace("server http listeners stopped"); - lifecycle.status(AppLifecycle.Status.STOPPED); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - @Override - public int port() { - return server.getURI().getPort(); - } - - protected Jex.Server start() { - try { - createServer(); - server.start(); - logOnStart(server); - lifecycle.registerShutdownHook(this::shutdown); - lifecycle.status(AppLifecycle.Status.STARTED); - return this; - } catch (Exception e) { - throw new IllegalStateException("Error starting server", e); - } - } - - protected void createServer() { - server = initServer(); - server.setHandler(initJettyHandler()); - if (server.getStopAtShutdown()) { - // do not use Jetty ShutdownHook, use the AppLifecycle one instead - server.setStopAtShutdown(false); - } - config.server(server); - config.postConfigure(); - } - - protected Server initServer() { - Server server = config.server(); - if (server != null) { - return server; - } - return new JettyBuilder(jex, config).build(); - } - - protected Handler initJettyHandler() { - var baseHandler = new JexHandler(jex, routes, serviceManager, initStaticHandler()); - if (!config.sessions()) { - return baseHandler; - } - var sessionHandler = initSessionHandler(); - sessionHandler.setHandler(baseHandler); - return sessionHandler; - } - - protected SessionHandler initSessionHandler() { - SessionHandler sh = config.sessionHandler(); - return sh == null ? defaultSessionHandler() : sh; - } - - protected SessionHandler defaultSessionHandler() { - SessionHandler sh = new SessionHandler(); - sh.setHttpOnly(true); - return sh; - } - - protected StaticHandler initStaticHandler() { - final List fileSources = jex.staticFiles().getSources(); - if (fileSources == null || fileSources.isEmpty()) { - return null; - } - StaticHandlerFactory factory = new StaticHandlerFactory(); - return factory.build(server, jex, fileSources); - } - - private void logOnStart(org.eclipse.jetty.server.Server server) { - long startup = System.currentTimeMillis() - startTime; - for (Connector c : server.getConnectors()) { - String virtualThreads = jexConfig.virtualThreads() ? "with virtualThreads" : ""; - if (c instanceof ServerConnector) { - ServerConnector sc = (ServerConnector) c; - String host = (sc.getHost() == null) ? "0.0.0.0" : sc.getHost(); - log.info("Listening with {} {}:{} in {}ms @{}ms {}", sc.getProtocols(), host, sc.getLocalPort(), startup, Uptime.getUptime(), virtualThreads); - } else { - log.info("bind to {} in {}ms @{}ms {}", c, startup, Uptime.getUptime(), virtualThreads); - } - } - } - - -} 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 deleted file mode 100644 index f8c41a17..00000000 --- a/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JettyServerConfig.java +++ /dev/null @@ -1,109 +0,0 @@ -package io.avaje.jex.jetty; - -import io.avaje.jex.ServerConfig; -import org.eclipse.jetty.server.Handler; -import org.eclipse.jetty.server.Server; -import org.eclipse.jetty.server.session.SessionHandler; - -import java.util.ArrayList; -import java.util.List; -import java.util.function.Consumer; - -public class JettyServerConfig implements ServerConfig { - - private boolean sessions = true; - private boolean security = true; - - /** - * Set maxThreads when using default QueuedThreadPool. Defaults to 200. - */ - private int maxThreads; - private SessionHandler sessionHandler; - private Handler contextHandler; - private Server server; - private final List> configureCallback = new ArrayList<>(); - - 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 int maxThreads() { - return maxThreads; - } - - public JettyServerConfig maxThreads(int maxThreads) { - this.maxThreads = maxThreads; - return this; - } - - public SessionHandler sessionHandler() { - return sessionHandler; - } - - /** - * Set the SessionHandler to use. When not set one is created automatically. - */ - public JettyServerConfig sessionHandler(SessionHandler sessionHandler) { - this.sessionHandler = sessionHandler; - return this; - } - - public Handler contextHandler() { - return contextHandler; - } - - /** - * Set the Jetty Handler to use. When not set one is created automatically. - */ - public JettyServerConfig contextHandler(Handler contextHandler) { - this.contextHandler = contextHandler; - return this; - } - - public Server server() { - return server; - } - - /** - * Set the Jetty Server to use. When not set one is created automatically. - */ - public JettyServerConfig server(Server server) { - this.server = server; - return this; - } - - /** - * Register a callback that is executed after the server and contextHandler have been - * created but before the server has started. - *

- * When we use this to register filters to the ServletContextHandler or perform other - * changes prior to the server starting. - */ - public JettyServerConfig register(Consumer callback) { - configureCallback.add(callback); - return this; - } - - /** - * Run configuration callbacks prior to starting the server. - */ - void postConfigure() { - for (Consumer callback : configureCallback) { - callback.accept(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 deleted file mode 100644 index 4d572895..00000000 --- a/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JettyStartServer.java +++ /dev/null @@ -1,17 +0,0 @@ -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 JettyJexServer(jex, routes, serviceManager).start(); - } -} diff --git a/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JexHandler.java b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JexHandler.java deleted file mode 100644 index 33bd44f3..00000000 --- a/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JexHandler.java +++ /dev/null @@ -1,110 +0,0 @@ -package io.avaje.jex.jetty; - -import io.avaje.jex.Context; -import io.avaje.jex.Jex; -import io.avaje.jex.Routing; -import io.avaje.jex.http.NotFoundResponse; -import io.avaje.jex.spi.SpiContext; -import io.avaje.jex.spi.SpiRoutes; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.eclipse.jetty.server.Request; -import org.eclipse.jetty.server.handler.AbstractHandler; - -import java.util.Map; - -class JexHandler extends AbstractHandler { - - //private static final String X_HTTP_METHOD_OVERRIDE = "X-HTTP-Method-Override"; - private final SpiRoutes routes; - private final ServiceManager manager; - private final StaticHandler staticHandler; - - JexHandler(Jex jex, SpiRoutes routes, ServiceManager manager, StaticHandler staticHandler) { - this.routes = routes; - this.manager = manager; - this.staticHandler = staticHandler; - } - - @Override - public void handle(String target, Request baseRequest, HttpServletRequest req, HttpServletResponse res) { - try { - final Routing.Type routeType = method(req); - final String uri = req.getRequestURI(); - SpiRoutes.Entry route = routes.match(routeType, uri); - if (route == null) { - var ctx = new JexHttpContext(manager, req, res, uri); - routes.inc(); - try { - processNoRoute(target, baseRequest, ctx, uri, routeType); - routes.after(uri, ctx); - } catch (Exception e) { - handleException(ctx, e); - } finally { - routes.dec(); - } - } else { - final Map params = route.pathParams(uri); - var ctx = new JexHttpContext(manager, req, res, route.matchPath(), params); - route.inc(); - try { - processRoute(ctx, uri, route); - routes.after(uri, ctx); - } catch (Exception e) { - handleException(ctx, e); - } finally { - route.dec(); - } - } - } finally { - baseRequest.setHandled(true); - } - } - - private void handleException(SpiContext ctx, Exception e) { - manager.handleException(ctx, e); - } - - private void processRoute(JexHttpContext ctx, String uri, SpiRoutes.Entry route) { - routes.before(uri, ctx); - ctx.setMode(null); - route.handle(ctx); - } - - private void processNoRoute(String target, Request baseRequest, JexHttpContext 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 - if (staticHandler != null && staticHandler.handle(target, baseRequest, ctx.req(), ctx.res())) { - return; - } - // todo: check if handled by singlePageHandler - //if (config.inner.singlePageHandler.handle(ctx)) return@tryWithExceptionMapper - } -// if (routeType == Routing.Type.OPTIONS && isCorsEnabled(config)) { // CORS is enabled, so we return 200 for OPTIONS -// return@tryWithExceptionMapper -// } -// if (prefer405) { -// //&& availableHandlerTypes.isNotEmpty() -// //val availableHandlerTypes = MethodNotAllowedUtil.findAvailableHttpHandlerTypes(matcher, requestUri) -// //throw MethodNotAllowedResponse(details = MethodNotAllowedUtil.getAvailableHandlerTypes(ctx, availableHandlerTypes)) -// } - 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; - } - - private Routing.Type method(HttpServletRequest req) { - return manager.lookupRoutingType(req.getMethod()); - } -} diff --git a/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JexHttpContext.java b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JexHttpContext.java deleted file mode 100644 index 3c1935af..00000000 --- a/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/JexHttpContext.java +++ /dev/null @@ -1,515 +0,0 @@ -package io.avaje.jex.jetty; - -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 jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.servlet.http.HttpSession; - -import java.io.IOException; -import java.io.InputStream; -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; - -import static java.util.Collections.emptyList; -import static java.util.Collections.emptyMap; - -class JexHttpContext implements SpiContext { - - private final ServiceManager mgr; - protected final HttpServletRequest req; - private final HttpServletResponse res; - private final Map pathParams; - private final String matchedPath; - private String characterEncoding; - private Routing.Type mode; - private Map> formParamMap; - - JexHttpContext(ServiceManager mgr, HttpServletRequest req, HttpServletResponse res, String matchedPath) { - this.mgr = mgr; - this.req = req; - this.res = res; - this.matchedPath = matchedPath; - this.pathParams = emptyMap(); - } - - JexHttpContext(ServiceManager mgr, HttpServletRequest req, HttpServletResponse res, String matchedPath, Map pathParams) { - this.mgr = mgr; - this.req = req; - this.res = res; - this.matchedPath = matchedPath; - this.pathParams = pathParams; - } - - @Override - public void setMode(Routing.Type mode) { - this.mode = mode; - } - - private String characterEncoding() { - if (characterEncoding == null) { - characterEncoding = mgr.requestCharset(this); - } - return characterEncoding; - } - - public HttpServletRequest req() { - return req; - } - - public HttpServletResponse res() { - return res; - } - - @Override - public boolean isCommitted() { - return res.isCommitted(); - } - - @Override - public void reset() { - res.reset(); - } - - @Override - public Context attribute(String key, Object value) { - req.setAttribute(key, value); - return this; - } - - @SuppressWarnings("unchecked") - @Override - public T attribute(String key) { - return (T) req.getAttribute(key); - } - - @Override - public String cookie(String name) { - final jakarta.servlet.http.Cookie[] cookies = req.getCookies(); - if (cookies != null) { - for (jakarta.servlet.http.Cookie cookie : cookies) { - if (cookie.getName().equals(name)) { - return cookie.getValue(); - } - } - } - return null; - } - - @Override - public Map cookieMap() { - final jakarta.servlet.http.Cookie[] cookies = req.getCookies(); - if (cookies == null) { - return emptyMap(); - } - final Map map = new LinkedHashMap<>(); - for (jakarta.servlet.http.Cookie cookie : cookies) { - map.put(cookie.getName(), cookie.getValue()); - } - return map; - } - - @Override - 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 jakarta.servlet.http.Cookie cookie = new jakarta.servlet.http.Cookie(name, value); - cookie.setPath("/"); - cookie.setMaxAge(maxAge); - res.addCookie(cookie); - return this; - } - - @Override - public Context cookie(String name, String value) { - return cookie(name, value, -1); - } - - @Override - public Context removeCookie(String name) { - return removeCookie(name, null); - } - - @Override - public Context removeCookie(String name, String path) { - if (path == null) { - path = "/"; - } - final jakarta.servlet.http.Cookie cookie = new jakarta.servlet.http.Cookie(name, ""); - cookie.setPath(path); - cookie.setMaxAge(0); - res.addCookie(cookie); - return this; - } - - @Override - public void redirect(String location) { - redirect(location, HttpServletResponse.SC_MOVED_TEMPORARILY); - } - - @Override - public void redirect(String location, int statusCode) { - res.setHeader(HeaderKeys.LOCATION, location); - status(statusCode); - if (mode == Routing.Type.BEFORE) { - throw new RedirectResponse(statusCode); - } - } - - @Override - public void performRedirect() { - // do nothing - } - - @Override - public String matchedPath() { - return matchedPath; - } - - @Override - public InputStream inputStream() { - try { - return req.getInputStream(); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } - - @Override - public T bodyAsClass(Class clazz) { - return mgr.jsonRead(clazz, this); - } - - @Override - public byte[] bodyAsBytes() { - return ContextUtil.readBody(req); - } - - @Override - public String body() { - return new String(bodyAsBytes(), Charset.forName(characterEncoding())); - } - - @Override - public long contentLength() { - return req.getContentLengthLong(); - } - - @Override - public Map pathParamMap() { - return pathParams; - } - - @Override - public String pathParam(String name) { - return pathParams.get(name); - } - - @Override - public String queryParam(String name) { - final String[] vals = req.getParameterValues(name); - if (vals == null || vals.length == 0) { - return null; - } else { - return vals[0]; - } - } - - @Override - public List queryParams(String name) { - final String[] vals = req.getParameterValues(name); - if (vals == null) { - return emptyList(); - } else { - return Arrays.asList(vals); - } - } - - @Override - public Map queryParamMap() { - final Map map = new LinkedHashMap<>(); - final Enumeration names = req.getParameterNames(); - while (names.hasMoreElements()) { - final String key = names.nextElement(); - map.put(key, queryParam(key)); - } - return map; - } - - @Override - public String queryString() { - return req.getQueryString(); - } - - @Override - public Map> formParamMap() { - if (formParamMap == null) { - formParamMap = initFormParamMap(); - } - return formParamMap; - } - - private Map> initFormParamMap() { - if (isMultipartFormData()) { - return mgr.multiPartForm(req); - } else { - return mgr.formParamMap(this, characterEncoding()); - } - } - - @Override - public String scheme() { - return req.getScheme(); - } - - @Override - public Context sessionAttribute(String key, Object value) { - req.getSession().setAttribute(key, value); - return this; - } - - @SuppressWarnings("unchecked") - @Override - public T sessionAttribute(String key) { - HttpSession session = req.getSession(false); - return session == null ? null : (T) session.getAttribute(key); - } - - @Override - public Map sessionAttributeMap() { - final Map map = new LinkedHashMap<>(); - final HttpSession session = req.getSession(false); - if (session == null) { - return emptyMap(); - } - final Enumeration names = session.getAttributeNames(); - while (names.hasMoreElements()) { - final String name = names.nextElement(); - map.put(name, session.getAttribute(name)); - } - return map; - } - - @Override - public String url() { - return req.getRequestURL().toString(); - } - - @Override - public String fullUrl() { - final String url = url(); - final String qs = queryString(); - return qs == null ? url : url + "?" + qs; - } - - @Override - public String contextPath() { - String path = req.getContextPath(); - return path == null ? "" : path; - } - - @Override - public Context status(int statusCode) { - res.setStatus(statusCode); - return this; - } - - @Override - public int status() { - return res.getStatus(); - } - - @Override - public String contentType() { - return req.getContentType(); - } - - @Override - public Context contentType(String contentType) { - res.setContentType(contentType); - return this; - } - - public Map headerMap() { - Map map = new LinkedHashMap<>(); - final Enumeration names = req.getHeaderNames(); - while (names.hasMoreElements()) { - final String name = names.nextElement(); - map.put(name, req.getHeader(name)); - } - return map; - } - - @Override - public String responseHeader(String key) { - return req.getHeader(key); - } - - @Override - public String header(String key) { - return req.getHeader(key); - } - - @Override - public Context header(String key, String value) { - res.setHeader(key, value); - return this; - } - - @Override - public String host() { - return req.getHeader(HeaderKeys.HOST); - } - - @Override - public String ip() { - return req.getRemoteAddr(); - } - - @Override - public boolean isMultipart() { - final String type = header(HeaderKeys.CONTENT_TYPE); - return type != null && type.toLowerCase().contains("multipart/"); - } - - @Override - public boolean isMultipartFormData() { - final String type = header(HeaderKeys.CONTENT_TYPE); - return type != null && type.toLowerCase().contains("multipart/form-data"); - } - - @Override - public String method() { - return req.getMethod(); - } - - @Override - public String path() { - return req.getRequestURI(); - } - - @Override - public int port() { - return req.getServerPort(); - } - - @Override - public String protocol() { - return req.getProtocol(); - } - - @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; - } - - /** - * 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 { - res.getWriter().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 OutputStream outputStream() { - try { - return res.getOutputStream(); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } - - @Override - public UploadedFile uploadedFile(String name) { - final List files = uploadedFiles(name); - return files.isEmpty() ? null : files.get(0); - } - - @Override - public List uploadedFiles(String name) { - if (!isMultipartFormData()) { - return emptyList(); - } else { - return mgr.uploadedFiles(req, name); - } - } - - @Override - public List uploadedFiles() { - if (!isMultipartFormData()) { - return emptyList(); - } else { - return mgr.uploadedFiles(req); - } - } -} diff --git a/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/MultipartUtil.java b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/MultipartUtil.java deleted file mode 100644 index dea863bb..00000000 --- a/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/MultipartUtil.java +++ /dev/null @@ -1,103 +0,0 @@ -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; -import java.io.UncheckedIOException; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -import static java.util.stream.Collectors.joining; -import static java.util.stream.Collectors.toList; - -class MultipartUtil { - - private final MultipartConfigElement config; - - MultipartUtil(MultipartConfigElement config) { - this.config = config; - } - - private void setConfig(HttpServletRequest req) { - req.setAttribute("org.eclipse.jetty.multipartConfig", config); - } - - List uploadedFiles(HttpServletRequest req) { - try { - setConfig(req); - return req.getParts().stream() - .filter(part -> isFile(part)) - .map(this::toUploaded) - .collect(toList()); - } catch (IOException e) { - throw new UncheckedIOException(e); - } catch (ServletException e) { - throw new RuntimeException(e); - } - } - - List uploadedFiles(HttpServletRequest req, String partName) { - try { - setConfig(req); - return req.getParts().stream() - .filter(part -> part.getName().equals(partName) && isFile(part)) - .map(this::toUploaded) - .collect(toList()); - } catch (IOException e) { - throw new UncheckedIOException(e); - } catch (ServletException e) { - throw new RuntimeException(e); - } - } - - UploadedFile toUploaded(Part part) { - return new PartUploadedFile(part); - } - - Map> fieldMap(HttpServletRequest req) { - setConfig(req); - try { - Map> map = new LinkedHashMap<>(); - for (Part part : req.getParts()) { - if (isField(part)) { - final String name = part.getName(); - final String value = readAsString(part); - map.computeIfAbsent(name, s -> new ArrayList<>()).add(value); - } - } - return map; - } catch (IOException e) { - throw new UncheckedIOException(e); - } catch (ServletException e) { - throw new RuntimeException(e); - } - } - - private String readAsString(Part part) { - try { - return new BufferedReader(new InputStreamReader(part.getInputStream(), StandardCharsets.UTF_8)) - .lines() - .collect(joining("\n")); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } - - private static boolean isFile(Part filePart) { - return !isField(filePart); - } - - private static boolean isField(Part filePart) { - return filePart.getSubmittedFileName() == null; // this is what Apache FileUpload does ... - } - -} diff --git a/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/PartUploadedFile.java b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/PartUploadedFile.java deleted file mode 100644 index 712ed3b6..00000000 --- a/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/PartUploadedFile.java +++ /dev/null @@ -1,63 +0,0 @@ -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; - -/** - * UploadedFile using servlet Part. - */ -class PartUploadedFile implements UploadedFile { - - private final Part part; - - PartUploadedFile(Part part) { - this.part = part; - } - - @Override - public String name() { - return part.getName(); - } - - @Override - public String fileName() { - return part.getSubmittedFileName(); - } - - @Override - public InputStream content() { - try { - return part.getInputStream(); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } - - @Override - public String contentType() { - return part.getContentType(); - } - - @Override - public long size() { - return part.getSize(); - } - - @Override - public void delete() { - try { - part.delete(); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } - - @Override - public String toString() { - return "name:" + name() + " fileName:" + fileName() + " size:" + size(); - } -} 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 deleted file mode 100644 index 5eb0ddbc..00000000 --- a/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/ServiceManager.java +++ /dev/null @@ -1,34 +0,0 @@ -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-jetty/src/main/java/io/avaje/jex/jetty/StaticHandler.java b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/StaticHandler.java deleted file mode 100644 index 3694c115..00000000 --- a/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/StaticHandler.java +++ /dev/null @@ -1,167 +0,0 @@ -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.Server; -import org.eclipse.jetty.server.handler.ResourceHandler; -import org.eclipse.jetty.util.resource.EmptyResource; -import org.eclipse.jetty.util.resource.Resource; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.File; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -class StaticHandler { - - private static final Logger log = LoggerFactory.getLogger(StaticHandler.class); - - private final List handlers = new ArrayList<>(); - private final Server server; - private final boolean preCompress; - - StaticHandler(boolean preCompress, Server server) { - this.preCompress = preCompress; - this.server = server; - } - - void addStaticFileConfig(StaticFileSource config) { - ResourceHandler handler; - if ("/webjars".equals(config.getPath())) { - handler = new WebjarHandler(); - } else { - PrefixableHandler h = new PrefixableHandler(config.getUrlPathPrefix()); - h.setResourceBase(getResourcePath(config)); - h.setDirAllowed(false); - h.setEtags(true); - handler = h; - } - log.info("Static file handler added {}", config); - - try { - handler.setServer(server); - handler.start(); - } catch (Exception e) { - throw new RuntimeException("Error starting Jetty static resource handler", e); - } - handlers.add(handler); - } - - private String getResourcePath(StaticFileSource config) { - if (config.getLocation() == StaticFileSource.Location.CLASSPATH) { - var resource = Resource.newClassPathResource(config.getPath()); - if (resource == null) { - throw new RuntimeException(noSuchDir(config) + " Depending on your setup, empty folders might not get copied to classpath."); - } - return resource.toString(); - } - final File path = new File(config.getPath()); - if (!path.exists()) { - throw new RuntimeException(noSuchDir(config) + " path: " + path.getAbsolutePath()); - } - return config.getPath(); - } - - private String noSuchDir(StaticFileSource config) { - return "Static resource directory with path: '" + config.getPath() + "' does not exist."; - } - - boolean handle(String target, Request baseRequest, HttpServletRequest req, HttpServletResponse res) { - for (ResourceHandler handler : handlers) { - try { - var resource = handler.getResource(target); - if (isFile(resource) || isDirectoryWithWelcomeFile(resource, handler, target)) { -// val maxAge = if (target.startsWith("/immutable/") || handler is WebjarHandler) 31622400 else 0 -// httpResponse.setHeader(HeaderKeys.CACHE_CONTROL, "max-age=$maxAge"); - - // Remove the default content type because Jetty will not set the correct one - // if the HTTP response already has a content type set -// if (precompressStaticFiles && PrecompressingResourceHandler.handle(resource, httpRequest, httpResponse)) { -// return true -// } - res.setContentType(null); - handler.handle(target, baseRequest, req, res); - req.setAttribute("handled-as-static-file", true); -// (httpResponse as JavalinResponseWrapper).outputStream.finalize() - return true; - } - } catch (Exception e) { // it's fine -// if (!Util.isClientAbortException(e)) { -// Javalin.log?.error("Exception occurred while handling static resource", e) -// } - log.error("Exception occurred while handling static resource", e); - } - } - return false; - } - - private boolean isFile(Resource resource) { - return resource != null && resource.exists() && !resource.isDirectory(); - } - - private boolean isDirectoryWithWelcomeFile(Resource resource, ResourceHandler handler, String target) { - //String path = target.removeSuffix("/")+"/index.html"; - if (target.endsWith("/")) { - target = target.substring(0, target.length() - 1); - } - String path = target + "/index.html"; - if (resource == null || !resource.isDirectory()) { - return false; - } - try { - final Resource indexHtml = handler.getResource(path); - return indexHtml != null && indexHtml.exists(); - } catch (IOException e) { - log.warn("Error checking for welcome file", e); - return false; - } - } - - private static class WebjarHandler extends ResourceHandler { - @Override - public Resource getResource(String path) throws IOException { - final Resource resource = Resource.newClassPathResource("META-INF/resources" + path); - return (resource != null) ? resource : super.getResource(path); - } - } - - private static class PrefixableHandler extends ResourceHandler { - - private final String urlPathPrefix; - - PrefixableHandler(String urlPathPrefix) { - this.urlPathPrefix = urlPathPrefix; - } - - @Override - public Resource getResource(String path) throws IOException { - if (urlPathPrefix.equals("/")) { - return super.getResource(path); // same as regular ResourceHandler - } - String targetPath = target(path); - if ("".equals(targetPath)) { - return super.getResource("/"); // directory without trailing '/' - } - if (!path.startsWith(urlPathPrefix)) { - return EmptyResource.INSTANCE; - } - if (!targetPath.startsWith("/")) { - return EmptyResource.INSTANCE; - } else { - return super.getResource(targetPath); - } - } - - private String target(String path) { - if (path.startsWith(urlPathPrefix)) { - return path.substring(urlPathPrefix.length()); - } else { - return path; - } - } - } -} diff --git a/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/StaticHandlerFactory.java b/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/StaticHandlerFactory.java deleted file mode 100644 index 5d9da4af..00000000 --- a/avaje-jex-jetty/src/main/java/io/avaje/jex/jetty/StaticHandlerFactory.java +++ /dev/null @@ -1,18 +0,0 @@ -package io.avaje.jex.jetty; - -import io.avaje.jex.Jex; -import io.avaje.jex.StaticFileSource; -import org.eclipse.jetty.server.Server; - -import java.util.List; - -class StaticHandlerFactory { - - StaticHandler build(Server server, Jex jex, List sourceList) { - StaticHandler handler = new StaticHandler(jex.config().preCompressStaticFiles(), server); - for (StaticFileSource source : sourceList) { - handler.addStaticFileConfig(source); - } - return handler; - } -} diff --git a/avaje-jex-jetty/src/main/java/module-info.java b/avaje-jex-jetty/src/main/java/module-info.java deleted file mode 100644 index 8dbb3de8..00000000 --- a/avaje-jex-jetty/src/main/java/module-info.java +++ /dev/null @@ -1,20 +0,0 @@ -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.server; - requires transitive org.eclipse.jetty.io; - requires transitive org.eclipse.jetty.util; - - - 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 deleted file mode 100644 index 8adaab64..00000000 --- a/avaje-jex-jetty/src/main/resources/META-INF/services/io.avaje.jex.spi.SpiStartServer +++ /dev/null @@ -1 +0,0 @@ -io.avaje.jex.jetty.JettyStartServer diff --git a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/AppRoles.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/AppRoles.java deleted file mode 100644 index 1ab3f369..00000000 --- a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/AppRoles.java +++ /dev/null @@ -1,11 +0,0 @@ -package io.avaje.jex.base; - -import io.avaje.jex.Role; - -/** - * Create an App specific enum that implements Role. - */ -public enum AppRoles implements Role { - ADMIN, - USER -} diff --git a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/AutoCloseIterator.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/AutoCloseIterator.java deleted file mode 100644 index c613db09..00000000 --- a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/AutoCloseIterator.java +++ /dev/null @@ -1,32 +0,0 @@ -package io.avaje.jex.base; - -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-jetty/src/test/java/io/avaje/jex/base/CharacterEncodingTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/CharacterEncodingTest.java deleted file mode 100644 index 9fd11298..00000000 --- a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/CharacterEncodingTest.java +++ /dev/null @@ -1,50 +0,0 @@ -package io.avaje.jex.base; - -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(contentType(htmlRes)).isEqualTo("text/html;charset=utf-8"); - assertThat(jsonRes.body()).isEqualTo("\"白菜湯\""); - 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-jetty/src/test/java/io/avaje/jex/base/ContextAttributeTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/ContextAttributeTest.java deleted file mode 100644 index 4035832b..00000000 --- a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/ContextAttributeTest.java +++ /dev/null @@ -1,52 +0,0 @@ -package io.avaje.jex.base; - -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.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-jetty/src/test/java/io/avaje/jex/base/ContextCookieTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/ContextCookieTest.java deleted file mode 100644 index a01e72f1..00000000 --- a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/ContextCookieTest.java +++ /dev/null @@ -1,82 +0,0 @@ -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; - -class ContextCookieTest { - - static TestPair pair = init(); - - static TestPair init() { - var app = Jex.create() - .routing(routing -> routing - .get("/setCookie", ctx -> ctx.cookie("ck", "val").cookie("ck2", "val2")) - .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, 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-jetty/src/test/java/io/avaje/jex/base/ContextLengthTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/ContextLengthTest.java deleted file mode 100644 index a7b7c0cb..00000000 --- a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/ContextLengthTest.java +++ /dev/null @@ -1,106 +0,0 @@ -package io.avaje.jex.base; - -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-jetty/src/test/java/io/avaje/jex/base/ContextTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/ContextTest.java deleted file mode 100644 index faa77f74..00000000 --- a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/ContextTest.java +++ /dev/null @@ -1,194 +0,0 @@ -package io.avaje.jex.base; - -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 health_liveness() { - HttpResponse res = pair.request().path("health/liveness").GET().asString(); - assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("ok"); - } - - @Test - void health_readiness() { - HttpResponse res = pair.request().path("health/readiness").GET().asString(); - assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("ok"); - } - - @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"); - 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-jetty/src/test/java/io/avaje/jex/base/ExceptionManagerTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/ExceptionManagerTest.java deleted file mode 100644 index 29358ec6..00000000 --- a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/ExceptionManagerTest.java +++ /dev/null @@ -1,80 +0,0 @@ -package io.avaje.jex.base; - -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-jetty/src/test/java/io/avaje/jex/base/FilterTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/FilterTest.java deleted file mode 100644 index 550eded8..00000000 --- a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/FilterTest.java +++ /dev/null @@ -1,74 +0,0 @@ -package io.avaje.jex.base; - -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 static org.assertj.core.api.Assertions.assertThat; - -class FilterTest { - - static TestPair pair = init(); - - 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 -> ctx.header("after-all", "set")) - .after("/two/*", ctx -> ctx.header("after-two", "set")) - .get("/dummy", ctx -> ctx.text("dummy")) - ); - - return TestPair.create(app); - } - - @AfterAll - static void end() { - pair.shutdown(); - } - - @Test - void get() { - HttpResponse res = pair.request().GET().asString(); - assertHasBeforeAfterAll(res); - assertNoBeforeAfterTwo(res); - - res = pair.request().path("one").GET().asString(); - assertHasBeforeAfterAll(res); - assertNoBeforeAfterTwo(res); - - res = pair.request().path("two").GET().asString(); - assertHasBeforeAfterAll(res); - assertNoBeforeAfterTwo(res); - } - - - @Test - void get_two_expect_extraFilters() { - 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(headers.firstValue("after-two")).get().isEqualTo("set"); - } - - private void assertNoBeforeAfterTwo(HttpResponse res) { - assertThat(res.headers().firstValue("before-two")).isEmpty(); - assertThat(res.headers().firstValue("after-two")).isEmpty(); - } - - private void assertHasBeforeAfterAll(HttpResponse res) { - assertThat(res.headers().firstValue("before-all")).get().isEqualTo("set"); - assertThat(res.headers().firstValue("after-all")).get().isEqualTo("set"); - } -} diff --git a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/HelloDto.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/HelloDto.java deleted file mode 100644 index 6e892c1c..00000000 --- a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/HelloDto.java +++ /dev/null @@ -1,27 +0,0 @@ -package io.avaje.jex.base; - -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-jetty/src/test/java/io/avaje/jex/base/JsonTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/JsonTest.java deleted file mode 100644 index e7d3085a..00000000 --- a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/JsonTest.java +++ /dev/null @@ -1,121 +0,0 @@ -package io.avaje.jex.base; - -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); - - // expect client gets the expected stream of beans - assertCollectedStream(beanStream); - // assert AutoCloseable iterator on the server-side was closed - assertThat(ITERATOR.isClosed()).isTrue(); - } - - @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-jetty/src/test/java/io/avaje/jex/base/MultipartFormPostTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/MultipartFormPostTest.java deleted file mode 100644 index 69b202f0..00000000 --- a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/MultipartFormPostTest.java +++ /dev/null @@ -1,132 +0,0 @@ -package io.avaje.jex.base; - -import com.mashape.unirest.http.Unirest; -import com.mashape.unirest.http.exceptions.UnirestException; -import io.avaje.jex.Jex; -import io.avaje.jex.UploadedFile; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.Test; - -import java.io.File; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -class MultipartFormPostTest { - - static TestPair pair = init(); - - static final File helloFile = new File("src/test/resources/static-a/hello.txt"); - static final File hello2File = new File("src/test/resources/static-a/hello2.txt"); - - static TestPair init() { - final Jex app = Jex.create() - .routing(routing -> routing - .post("/simple", ctx -> { - final UploadedFile file = ctx.uploadedFile("one"); - ctx.text("nm:" + file.name() + " fn:" + file.fileName() + " size:" + file.size()); - }) - .post("/both", ctx -> { - final UploadedFile file = ctx.uploadedFile("one"); - ctx.text("nm:" + file.name() + " fn:" + file.fileName() + " size:" + file.size() + " paramMap:" + ctx.formParamMap()); - }) - .post("/multi", ctx -> { - String out = ""; - final List files = ctx.uploadedFiles("one"); - for (UploadedFile file : files) { - out += "file[nm:" + file.name() + " fn:" + file.fileName() + " size:" + file.size() + "]"; - } - ctx.text(out + " paramMap:" + ctx.formParamMap()); - }) - .post("/multiAll", ctx -> { - String out = ""; - final List files = ctx.uploadedFiles(); - for (UploadedFile file : files) { - out += "file[nm:" + file.name() + " fn:" + file.fileName() + " size:" + file.size() + "]"; - } - ctx.text(out); - }) - .post("/delete", ctx -> { - final UploadedFile file = ctx.uploadedFile("one"); - file.delete(); - ctx.text("withDelete nm:" + file.name() + " fn:" + file.fileName() + " size:" + file.size()); - }) - ); - - return TestPair.create(app); - } - - @AfterAll - static void end() { - pair.shutdown(); - } - - @Test - void simple() throws UnirestException { - final String baseUrl = pair.url(); - - final com.mashape.unirest.http.HttpResponse res = - Unirest.post(baseUrl + "/simple") - .field("one", helloFile) - .asString(); - - assertThat(res.getBody()).isEqualTo("nm:one fn:hello.txt size:" + helloFile.length()); - } - - @Test - void delete() throws UnirestException { - final String baseUrl = pair.url(); - - final com.mashape.unirest.http.HttpResponse res = - Unirest.post(baseUrl + "/delete") - .field("one", helloFile) - .asString(); - - assertThat(res.getBody()).isEqualTo("withDelete nm:one fn:hello.txt size:" + helloFile.length()); - } - - @Test - void both() throws UnirestException { - final String baseUrl = pair.url(); - - final com.mashape.unirest.http.HttpResponse res = - Unirest.post(baseUrl + "/both") - .field("a", "aval") - .field("b", "bval") - .field("one", helloFile) - .asString(); - - assertThat(res.getBody()).isEqualTo("nm:one fn:hello.txt size:" + helloFile.length() + " paramMap:{a=[aval], b=[bval]}"); - } - - @Test - void multipleFiles() throws UnirestException { - final String baseUrl = pair.url(); - - final com.mashape.unirest.http.HttpResponse res = - Unirest.post(baseUrl + "/multi") - .field("a", "a1") - .field("a", "a2") - .field("b", "b1") - .field("b", "b2") - .field("c", "c1") - .field("one", helloFile) - .field("one", hello2File) - .asString(); - - assertThat(res.getBody()).isEqualTo("file[nm:one fn:hello.txt size:" + helloFile.length()+ "]file[nm:one fn:hello2.txt size:" + hello2File.length() + "] paramMap:{a=[a1, a2], b=[b1, b2], c=[c1]}"); - } - - @Test - void multipleFilesAll() throws UnirestException { - final String baseUrl = pair.url(); - - final com.mashape.unirest.http.HttpResponse res = - Unirest.post(baseUrl + "/multiAll") - .field("one", helloFile) - .field("two", hello2File) - .asString(); - - assertThat(res.getBody()).isEqualTo("file[nm:one fn:hello.txt size:" + helloFile.length()+ "]file[nm:two fn:hello2.txt size:" + hello2File.length() + "]"); - } -} diff --git a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/NestedRoutesTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/NestedRoutesTest.java deleted file mode 100644 index d7858d97..00000000 --- a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/NestedRoutesTest.java +++ /dev/null @@ -1,73 +0,0 @@ -package io.avaje.jex.base; - -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-jetty/src/test/java/io/avaje/jex/base/Roles.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/Roles.java deleted file mode 100644 index 87dd02e0..00000000 --- a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/Roles.java +++ /dev/null @@ -1,22 +0,0 @@ -package io.avaje.jex.base; - -import java.lang.annotation.Retention; -import java.lang.annotation.Target; - -import static java.lang.annotation.ElementType.METHOD; -import static java.lang.annotation.ElementType.TYPE; -import static java.lang.annotation.RetentionPolicy.RUNTIME; - -/** - * Create an app specific Role annotation that uses the - * app specific role enum. - */ -@Target(value={METHOD, TYPE}) -@Retention(value=RUNTIME) -public @interface Roles { - - /** - * Specify the permitted roles (using app specific enum). - */ - AppRoles[] value() default {}; -} diff --git a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/RolesTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/RolesTest.java deleted file mode 100644 index 111092bc..00000000 --- a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/RolesTest.java +++ /dev/null @@ -1,102 +0,0 @@ -package io.avaje.jex.base; - -import io.avaje.jex.Jex; -import io.avaje.jex.Role; -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 RolesTest { - - enum AppRoles implements Role { - ADMIN, - USER, - } - - static TestPair pair = init(); - - static TestPair init() { - var app = Jex.create() - .accessManager((handler, ctx, permittedRoles) -> { - final String role = ctx.queryParam("role"); - if (role == null || !permittedRoles.contains(AppRoles.valueOf(role))) { - ctx.status(401).text("Unauthorized"); - } else { - ctx.attribute("authBy", role); - handler.handle(ctx); - } - }) - .routing(routing -> routing - .get(ctx -> ctx.text("get")) - .get("/multi", ctx -> ctx.text("multi-" + ctx.attribute("authBy"))).withRoles(AppRoles.ADMIN, AppRoles.USER) - .get("/user", ctx -> ctx.text("user")).withRoles(AppRoles.USER) - ); - return TestPair.create(app); - } - - @AfterAll - static void end() { - pair.shutdown(); - } - - @Test - void noRoles() { - HttpResponse res = pair.request().GET().asString(); - assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("get"); - } - - @Test - void singleRole_withRole() { - HttpResponse res = pair.request() - .path("user").queryParam("role", "USER") - .GET().asString(); - - assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("user"); - } - - @Test - void singleRole_withoutRole() { - HttpResponse res = pair.request() - .path("user") - .GET().asString(); - - assertThat(res.statusCode()).isEqualTo(401); - assertThat(res.body()).isEqualTo("Unauthorized"); - } - - @Test - void multiRole_withRole() { - HttpResponse res = pair.request() - .path("multi").queryParam("role", "USER") - .GET().asString(); - - assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("multi-USER"); - } - - @Test - void multiRole_withRole2() { - HttpResponse res = pair.request() - .path("multi").queryParam("role", "ADMIN") - .GET().asString(); - - assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("multi-ADMIN"); - } - - @Test - void multiRole_withoutRole() { - HttpResponse res = pair.request() - .path("multi") - .GET().asString(); - - assertThat(res.statusCode()).isEqualTo(401); - assertThat(res.body()).isEqualTo("Unauthorized"); - } - -} diff --git a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/RouteRegexTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/RouteRegexTest.java deleted file mode 100644 index bd00befa..00000000 --- a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/RouteRegexTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package io.avaje.jex.base; - -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 RouteRegexTest { - - static TestPair pair = init(); - - static TestPair init() { - var app = Jex.create() - .routing(routing -> routing - .get("/foo/{id:[0-9]+}", ctx -> ctx.text("digit:" + ctx.pathParam("id"))) - .get("/foo/count", ctx -> ctx.text("count")) - ); - return TestPair.create(app); - } - - @AfterAll - static void end() { - pair.shutdown(); - } - - @Test - void when_digitMatch() { - HttpResponse res = pair.request().path("foo/7").GET().asString(); - - assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("digit:7"); - } - - @Test - void when_notDigitMatch() { - HttpResponse res = pair.request().path("foo/count").GET().asString(); - - - assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("count"); - } - - @Test - void when_noMatch() { - HttpResponse res = pair.request().path("foo/a").GET().asString(); - - assertThat(res.statusCode()).isEqualTo(404); - } - -} diff --git a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/RouteSplatTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/RouteSplatTest.java deleted file mode 100644 index 41aae324..00000000 --- a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/RouteSplatTest.java +++ /dev/null @@ -1,73 +0,0 @@ -package io.avaje.jex.base; - -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 RouteSplatTest { - - static TestPair pair = init(); - - static TestPair init() { - var app = Jex.create() - .routing(routing -> routing - .get("/{id}/one", ctx -> ctx.text("id:" + ctx.pathParam("id"))) - .get("/{id}/one2", ctx -> ctx.text("id:" + ctx.pathParam("id"))) - .get("//one", ctx -> ctx.text("s1:" + ctx.pathParam("a"))) - .get("//two/", ctx -> ctx.text("s2:" + ctx.pathParam("a") + "|" + ctx.pathParam("b"))) - ); - return TestPair.create(app); - } - - @AfterAll - static void end() { - pair.shutdown(); - } - - @Test - void when_utf8Encoded() { - // This fails in Jetty 11.0.2 due to: https://github.com/eclipse/jetty.project/issues/6001 - // String path = URLEncoder.encode("java/kotlin", StandardCharsets.UTF_8) - // + "/two/" + URLEncoder.encode("x/y", StandardCharsets.UTF_8); - // HttpResponse res = pair.request().path(path).get().asString(); - - HttpResponse res = pair.request().path("java/kotlin/two/x/y").GET().asString(); - assertThat(res.body()).isEqualTo("s2:java/kotlin|x/y"); - assertThat(res.statusCode()).isEqualTo(200); - } - - @Test - void when_pathParamMatch() { - HttpResponse res = pair.request().path("42/one").GET().asString(); - assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("id:42"); - } - - @Test - void when_splatMatch() { - HttpResponse res = pair.request().path("42/foo/one").GET().asString(); - - assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("s1:42/foo"); - } - - @Test - void when_splats() { - HttpResponse res = pair.request().path("a/b/c/two/x/y").GET().asString(); - - assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("s2:a/b/c|x/y"); - } - - @Test - void when_noSplats() { - HttpResponse res = pair.request().path("42/one2").GET().asString(); - - assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("id:42"); - } -} diff --git a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/SimpleTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/SimpleTest.java deleted file mode 100644 index b018ebda..00000000 --- a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/SimpleTest.java +++ /dev/null @@ -1,204 +0,0 @@ -package io.avaje.jex.base; - -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 SimpleTest { - - static UUID uuid = UUID.randomUUID(); - - static UUID sessAttrUuid; - static Map sessAttrMap; - - 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("/two", ctx -> ctx.text("query:" + ctx.queryParam("z", "defVal"))) - .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())) - .get("/sessionSet", ctx -> { - ctx.sessionAttribute("myAttr", uuid).text("ok"); - }) - .get("/sessionGet", ctx -> { - sessAttrUuid = ctx.sessionAttribute("myAttr"); - ctx.text("ok"); - }) - .get("/sessionMap", ctx -> { - sessAttrMap = ctx.sessionAttributeMap(); - ctx.text("ok"); - }) - - ); - 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 getTwo_withParam() { - var res = pair.request() - .path("two").queryParam("z", "hello").GET().asString(); - - assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("query:hello"); - - res = pair.request() - .path("two").GET().asString(); - - assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("query:defVal"); - - res = pair.request() - .path("two").queryParam("notZ", "hello").GET().asString(); - - assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("query:defVal"); - } - - @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"); - } - - @Test - void sessionSetGetMap() { - HttpResponse res = pair.request().path("sessionSet").GET().asString(); - assertThat(res.statusCode()).isEqualTo(200); - - res = pair.request().path("sessionGet").GET().asString(); - assertThat(res.statusCode()).isEqualTo(200); - assertThat(sessAttrUuid).isSameAs(uuid); - - res = pair.request().path("sessionMap").GET().asString(); - assertThat(res.statusCode()).isEqualTo(200); - assertThat(sessAttrMap).hasSize(1); - assertThat(sessAttrMap.get("myAttr")).isSameAs(uuid); - } - -} diff --git a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/StaticContentTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/StaticContentTest.java deleted file mode 100644 index 5795cd28..00000000 --- a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/StaticContentTest.java +++ /dev/null @@ -1,73 +0,0 @@ -package io.avaje.jex.base; - -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 static org.assertj.core.api.Assertions.assertThat; - -class StaticContentTest { - - static TestPair pair = init(); - - static TestPair init() { - final Jex app = Jex.create() - .routing(routing -> routing - .get("/", ctx -> ctx.text("ze-get")) - .get("/foo", ctx -> ctx.text("ze-post")) - ) - .staticFiles().addClasspath("/static", "static-a") - .staticFiles().addExternal("/other", "test-static-files"); - ; - - return TestPair.create(app); - } - - @AfterAll - static void end() { - pair.shutdown(); - } - - @Test - void get_fromClassPath() { - HttpResponse res = pair.request().path("static/hello.txt").GET().asString(); - assertThat(res.body().trim()).isEqualTo("hello-from-static"); - assertThat(contentType(res.headers())).isEqualTo("text/plain"); - } - - @Test - void get_fromClassPath_another() { - HttpResponse res = pair.request().path("static/goodbye.html").GET().asString(); - assertThat(res.body().trim()).isEqualTo("goodbye"); - assertThat(contentType(res.headers())).isEqualTo("text/html"); - } - - @Test - void get_fromExternalFile() { - HttpResponse res = pair.request().path("other/plain-file.txt").GET().asString(); - assertThat(res.body().trim()).isEqualTo("plain-file"); - assertThat(contentType(res.headers())).isEqualTo("text/plain"); - } - - @Test - void get_fromExternalFile2() { - HttpResponse res = pair.request().path("other/basic.html").GET().asString(); - assertThat(res.body().trim()).isEqualTo("basic"); - assertThat(contentType(res.headers())).isEqualTo("text/html"); - } - -// @Test -// void get_fromExternal_index() { -// HttpResponse res = pair.request().path("other/").GET().asString(); -// assertThat(res.body().trim()).isEqualTo("hello-from-static"); -// assertThat(contentType(res.headers())).isEqualTo("text/plain"); -// } - - private String contentType(HttpHeaders headers) { - return headers.firstValue("Content-Type").get(); - } - -} diff --git a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/TestPair.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/TestPair.java deleted file mode 100644 index f6974d20..00000000 --- a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/TestPair.java +++ /dev/null @@ -1,62 +0,0 @@ -package io.avaje.jex.base; - -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.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.builder() - .baseUrl(url) - .bodyAdapter(new JacksonBodyAdapter()) - .build(); - - return new TestPair(port, jexServer, client); - } -} diff --git a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/VerbTest.java b/avaje-jex-jetty/src/test/java/io/avaje/jex/base/VerbTest.java deleted file mode 100644 index 3e34e69f..00000000 --- a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/VerbTest.java +++ /dev/null @@ -1,139 +0,0 @@ -package io.avaje.jex.base; - -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 VerbTest { - - static TestPair pair = init(); - - static TestPair init() { - var app = Jex.create() - .routing(routing -> routing - .head(ctx -> ctx.text("head")) - .head("/head", ctx -> ctx.text("headWithPath")) - .get(ctx -> ctx.text("get")) - .get("/get", ctx -> ctx.text("getWithPath")) - .put(ctx -> ctx.text("put")) - .put("/put", ctx -> ctx.text("putWithPath")) - .post(ctx -> ctx.text("post")) - .post("/post", ctx -> ctx.text("postWithPath")) - .patch(ctx -> ctx.text("patch")) - .patch("/patch", ctx -> ctx.text("patchWithPath")) - .delete(ctx -> ctx.text("delete")) - .delete("/delete", ctx -> ctx.text("deleteWithPath")) - .trace(ctx -> ctx.text("trace")) - .trace("/trace", ctx -> ctx.text("traceWithPath")) - .get("/dummy", ctx -> ctx.text("dummy")) - ); - 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("get"); - } - - @Test - void get_with_path() { - HttpResponse res = pair.request().path("get").GET().asString(); - assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("getWithPath"); - } - - @Test - void post() { - HttpResponse res = pair.request().body("dummy").POST().asString(); - assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("post"); - } - - @Test - void post_with_path() { - HttpResponse res = pair.request().path("post").body("dummy").POST().asString(); - assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("postWithPath"); - } - - @Test - void put() { - HttpResponse res = pair.request().body("dummy").PUT().asString(); - assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("put"); - } - - @Test - void put_with_path() { - HttpResponse res = pair.request().path("put").body("dummy").PUT().asString(); - assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("putWithPath"); - } - - @Test - void delete() { - HttpResponse res = pair.request().body("dummy").DELETE().asString(); - assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("delete"); - } - - @Test - void delete_with_path() { - HttpResponse res = pair.request().path("delete").body("dummy").DELETE().asString(); - assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("deleteWithPath"); - } - - @Test - void head() { - HttpResponse res = pair.request().HEAD().asString(); - assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo(""); - } - - @Test - void head_with_path() { - HttpResponse res = pair.request().path("head").HEAD().asString(); - assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo(""); - } - - @Test - void patch() { - HttpResponse res = pair.request().body("dummy").PATCH().asString(); - assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("patch"); - } - - @Test - void patch_with_path() { - HttpResponse res = pair.request().path("patch").body("dummy").PATCH().asString(); - assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("patchWithPath"); - } - - @Test - void trace() { - HttpResponse res = pair.request().body("dummy").TRACE().asString(); - assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("trace"); - } - - @Test - void trace_with_path() { - HttpResponse res = pair.request().path("trace").body("dummy").TRACE().asString(); - assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("traceWithPath"); - } -} diff --git a/avaje-jex-jetty/src/test/resources/logback-test.xml b/avaje-jex-jetty/src/test/resources/logback-test.xml deleted file mode 100644 index ddb21350..00000000 --- a/avaje-jex-jetty/src/test/resources/logback-test.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - TRACE - - - %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - - - - - - - - diff --git a/avaje-jex-jetty/src/test/resources/static-a/goodbye.html b/avaje-jex-jetty/src/test/resources/static-a/goodbye.html deleted file mode 100644 index ea1f574f..00000000 --- a/avaje-jex-jetty/src/test/resources/static-a/goodbye.html +++ /dev/null @@ -1 +0,0 @@ -goodbye diff --git a/avaje-jex-jetty/src/test/resources/static-a/hello.txt b/avaje-jex-jetty/src/test/resources/static-a/hello.txt deleted file mode 100644 index dbabe200..00000000 --- a/avaje-jex-jetty/src/test/resources/static-a/hello.txt +++ /dev/null @@ -1 +0,0 @@ -hello-from-static diff --git a/avaje-jex-jetty/src/test/resources/static-a/hello2.txt b/avaje-jex-jetty/src/test/resources/static-a/hello2.txt deleted file mode 100644 index 56aaa1e3..00000000 --- a/avaje-jex-jetty/src/test/resources/static-a/hello2.txt +++ /dev/null @@ -1 +0,0 @@ -hello2 other static content diff --git a/avaje-jex-jetty/test-static-files/basic.html b/avaje-jex-jetty/test-static-files/basic.html deleted file mode 100644 index 8b4e34d7..00000000 --- a/avaje-jex-jetty/test-static-files/basic.html +++ /dev/null @@ -1 +0,0 @@ -basic diff --git a/avaje-jex-jetty/test-static-files/index.html b/avaje-jex-jetty/test-static-files/index.html deleted file mode 100644 index 0ce384c3..00000000 --- a/avaje-jex-jetty/test-static-files/index.html +++ /dev/null @@ -1 +0,0 @@ -index diff --git a/avaje-jex-jetty/test-static-files/plain-file.txt b/avaje-jex-jetty/test-static-files/plain-file.txt deleted file mode 100644 index 6be11da0..00000000 --- a/avaje-jex-jetty/test-static-files/plain-file.txt +++ /dev/null @@ -1 +0,0 @@ -plain-file diff --git a/avaje-jex-mustache/pom.xml b/avaje-jex-mustache/pom.xml index 5f241248..31e66cf2 100644 --- a/avaje-jex-mustache/pom.xml +++ b/avaje-jex-mustache/pom.xml @@ -4,13 +4,13 @@ avaje-jex-parent io.avaje - 2.5 + 3.0 avaje-jex-mustache - 0.9.10 + 0.9.14 @@ -18,7 +18,14 @@ io.avaje avaje-jex - 2.5 + ${project.version} + provided + + + + io.avaje + avaje-spi-service + 2.11 provided @@ -30,27 +37,19 @@ - - io.avaje - avaje-jex-jetty - 2.5 - test - - com.fasterxml.jackson.core jackson-databind - 2.14.0 + ${jackson.version} test io.avaje avaje-jex-test - 2.5 + ${project.version} test - diff --git a/avaje-jex-mustache/src/main/java/io/avaje/jex/render/mustache/MustacheRender.java b/avaje-jex-mustache/src/main/java/io/avaje/jex/render/mustache/MustacheRender.java index 805dcbbb..547db319 100644 --- a/avaje-jex-mustache/src/main/java/io/avaje/jex/render/mustache/MustacheRender.java +++ b/avaje-jex-mustache/src/main/java/io/avaje/jex/render/mustache/MustacheRender.java @@ -2,14 +2,17 @@ import com.github.mustachejava.DefaultMustacheFactory; import com.github.mustachejava.MustacheFactory; -import io.avaje.jex.Context; -import io.avaje.jex.TemplateRender; + +import io.avaje.jex.http.Context; +import io.avaje.jex.spi.TemplateRender; +import io.avaje.spi.ServiceProvider; import java.io.IOException; import java.io.StringWriter; import java.io.UncheckedIOException; import java.util.Map; +@ServiceProvider public class MustacheRender implements TemplateRender { private final MustacheFactory mustacheFactory; diff --git a/avaje-jex-mustache/src/main/java/module-info.java b/avaje-jex-mustache/src/main/java/module-info.java index 77d4f013..e553bae8 100644 --- a/avaje-jex-mustache/src/main/java/module-info.java +++ b/avaje-jex-mustache/src/main/java/module-info.java @@ -1,8 +1,10 @@ -open module io.avaje.jex.mustache { +module io.avaje.jex.mustache { requires transitive io.avaje.jex; requires transitive com.github.mustachejava; requires java.net.http; - provides io.avaje.jex.TemplateRender with io.avaje.jex.render.mustache.MustacheRender; + requires static io.avaje.spi; + + provides io.avaje.jex.spi.JexExtension with io.avaje.jex.render.mustache.MustacheRender; } diff --git a/avaje-jex-mustache/src/main/resources/META-INF/services/io.avaje.jex.TemplateRender b/avaje-jex-mustache/src/main/resources/META-INF/services/io.avaje.jex.TemplateRender deleted file mode 100644 index f92a16e3..00000000 --- a/avaje-jex-mustache/src/main/resources/META-INF/services/io.avaje.jex.TemplateRender +++ /dev/null @@ -1 +0,0 @@ -io.avaje.jex.render.mustache.MustacheRender diff --git a/avaje-jex-static-content/pom.xml b/avaje-jex-static-content/pom.xml new file mode 100644 index 00000000..af2d27e8 --- /dev/null +++ b/avaje-jex-static-content/pom.xml @@ -0,0 +1,27 @@ + + 4.0.0 + + io.avaje + avaje-jex-parent + 3.0 + + avaje-jex-static-content + + + + io.avaje + avaje-jex + ${project.version} + + + + io.avaje + avaje-jex-test + ${project.version} + test + + + + \ No newline at end of file diff --git a/avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/AbstractStaticHandler.java b/avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/AbstractStaticHandler.java new file mode 100644 index 00000000..c28667b9 --- /dev/null +++ b/avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/AbstractStaticHandler.java @@ -0,0 +1,101 @@ +package io.avaje.jex.staticcontent; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.FileNameMap; +import java.net.URLConnection; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Predicate; + +import com.sun.net.httpserver.HttpExchange; + +import io.avaje.jex.compression.CompressedOutputStream; +import io.avaje.jex.compression.CompressionConfig; +import io.avaje.jex.http.BadRequestException; +import io.avaje.jex.http.Context; +import io.avaje.jex.http.ExchangeHandler; +import io.avaje.jex.http.NotFoundException; + +abstract sealed class AbstractStaticHandler implements ExchangeHandler + permits StaticFileHandler, StaticClassResourceHandler { + + protected final Map mimeTypes; + protected final CompressionConfig compressionConfig; + protected final String filesystemRoot; + protected final String urlPrefix; + protected final Predicate skipFilePredicate; + protected final Map headers; + protected final boolean precompress; + protected final Map compressedFiles = new ConcurrentHashMap<>(); + private static final FileNameMap MIME_MAP = URLConnection.getFileNameMap(); + + protected AbstractStaticHandler( + String urlPrefix, + String filesystemRoot, + Map mimeTypes, + Map headers, + Predicate skipFilePredicate, + boolean precompress, + CompressionConfig compressionConfig) { + this.compressionConfig = compressionConfig; + this.filesystemRoot = filesystemRoot; + this.urlPrefix = urlPrefix; + this.skipFilePredicate = skipFilePredicate; + this.headers = headers; + this.mimeTypes = mimeTypes; + this.precompress = precompress; + } + + protected void throw404(HttpExchange jdkExchange) { + throw new NotFoundException("File Not Found for request: " + jdkExchange.getRequestURI()); + } + + // This is one function to avoid giving away where we failed + protected void reportPathTraversal() { + throw new BadRequestException("Path traversal attempt detected"); + } + + protected String getExt(String path) { + int slashIndex = path.lastIndexOf('/'); + String basename = (slashIndex < 0) ? path : path.substring(slashIndex + 1); + + int dotIndex = basename.lastIndexOf('.'); + if (dotIndex >= 0) { + return basename.substring(dotIndex + 1); + } else { + return ""; + } + } + + protected String lookupMime(String path) { + var lower = path.toLowerCase(); + return Objects.requireNonNullElseGet( + MIME_MAP.getContentTypeFor(path), + () -> { + String ext = getExt(lower); + return mimeTypes.getOrDefault(ext, "application/octet-stream"); + }); + } + + protected boolean isCached(final String path) { + return precompress && compressedFiles.containsKey(path); + } + + protected void addCachedEntry(Context ctx, String urlPath, InputStream fis) throws IOException { + var baos = new ByteArrayOutputStream(); + fis.transferTo(new CompressedOutputStream(compressionConfig, ctx, baos)); + var bytes = baos.toByteArray(); + var responseHeaders = Map.copyOf(ctx.exchange().getResponseHeaders()); + ctx.write(bytes); + compressedFiles.put(urlPath, new CachedResource(responseHeaders, bytes)); + } + + protected void writeCached(Context ctx, String path) { + var cached = compressedFiles.get(path); + ctx.headerMap(cached.headers()); + ctx.write(cached.bytes()); + } +} diff --git a/avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/CachedResource.java b/avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/CachedResource.java new file mode 100644 index 00000000..bc5dd374 --- /dev/null +++ b/avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/CachedResource.java @@ -0,0 +1,6 @@ +package io.avaje.jex.staticcontent; + +import java.util.List; +import java.util.Map; + +record CachedResource(Map> headers, byte[] bytes) {} diff --git a/avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/ClassResourceLoader.java b/avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/ClassResourceLoader.java new file mode 100644 index 00000000..7f7378b3 --- /dev/null +++ b/avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/ClassResourceLoader.java @@ -0,0 +1,33 @@ +package io.avaje.jex.staticcontent; + +import java.net.URL; + +/** + * Loading resources from the classpath or module path. + * + *

When not specified Avaje Jex provides a default implementation that looks to find resources + * using the class loader associated with the ClassResourceLoader. + * + *

As a fallback, {@link ClassLoader#getSystemResourceAsStream(String)} is used if the loader + * returns null. + */ +public interface ClassResourceLoader { + + /** + * Create a {@code ClassResourceLoader} instance based on a given Class. + * + * @param clazz The class to use for resource loading. + * @return A new {@code ClassResourceLoader} instance. + */ + static ClassResourceLoader fromClass(Class clazz) { + return new DefaultResourceLoader(clazz); + } + + /** + * Loads the specified resource and returns its URL. + * + * @param resourcePath The path to the resource. + * @return The URL of the resource. + */ + URL loadResource(String resourcePath); +} diff --git a/avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/DefaultResourceLoader.java b/avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/DefaultResourceLoader.java new file mode 100644 index 00000000..57b230b1 --- /dev/null +++ b/avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/DefaultResourceLoader.java @@ -0,0 +1,31 @@ +package io.avaje.jex.staticcontent; + +import java.net.URL; +import java.util.Objects; +import java.util.Optional; + +final class DefaultResourceLoader implements ClassResourceLoader { + + private final Class clazz; + + DefaultResourceLoader() { + this.clazz = DefaultResourceLoader.class; + } + + DefaultResourceLoader(Class clazz) { + this.clazz = clazz; + } + + @Override + public URL loadResource(String resourcePath) { + var url = clazz.getResource(resourcePath); + if (url == null) { + // search the module path for top level resource + url = + Optional.ofNullable(ClassLoader.getSystemResource(resourcePath)) + .orElseGet( + () -> Thread.currentThread().getContextClassLoader().getResource(resourcePath)); + } + return Objects.requireNonNull(url, "Unable to locate resource: " + resourcePath); + } +} diff --git a/avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/StaticClassResourceHandler.java b/avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/StaticClassResourceHandler.java new file mode 100644 index 00000000..a2bb40c0 --- /dev/null +++ b/avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/StaticClassResourceHandler.java @@ -0,0 +1,101 @@ +package io.avaje.jex.staticcontent; + +import static io.avaje.jex.core.Constants.CONTENT_TYPE; + +import java.net.URL; +import java.nio.file.Paths; +import java.util.Map; +import java.util.function.Predicate; + +import io.avaje.jex.compression.CompressionConfig; +import io.avaje.jex.http.Context; + +final class StaticClassResourceHandler extends AbstractStaticHandler { + + private final URL indexFile; + private final URL singleFile; + private final ClassResourceLoader resourceLoader; + + StaticClassResourceHandler( + String urlPrefix, + String filesystemRoot, + Map mimeTypes, + Map headers, + Predicate skipFilePredicate, + ClassResourceLoader resourceLoader, + URL indexFile, + URL singleFile, + boolean precompress, + CompressionConfig compressionConfig) { + super( + urlPrefix, + filesystemRoot, + mimeTypes, + headers, + skipFilePredicate, + precompress, + compressionConfig); + + this.resourceLoader = resourceLoader; + this.indexFile = indexFile; + this.singleFile = singleFile; + } + + @Override + public void handle(Context ctx) { + if (singleFile != null) { + final var path = singleFile.getPath(); + if (isCached(path)) { + writeCached(ctx, path); + return; + } + sendURL(ctx, path, singleFile); + return; + } + + final var jdkExchange = ctx.exchange(); + if (skipFilePredicate.test(ctx)) { + throw404(jdkExchange); + } + + final String wholeUrlPath = jdkExchange.getRequestURI().getPath(); + if (wholeUrlPath.endsWith("/") || wholeUrlPath.equals(urlPrefix)) { + final var path = indexFile.getPath(); + if (isCached(path)) { + writeCached(ctx, path); + return; + } + sendURL(ctx, path, indexFile); + return; + } + + final String urlPath = wholeUrlPath.substring(urlPrefix.length()); + + if (isCached(urlPath)) { + writeCached(ctx, urlPath); + return; + } + + final String normalizedPath = + Paths.get(filesystemRoot, urlPath).normalize().toString().replace("\\", "/"); + + if (!normalizedPath.startsWith(filesystemRoot)) { + reportPathTraversal(); + } + sendURL(ctx, urlPath, resourceLoader.loadResource(normalizedPath)); + } + + private void sendURL(Context ctx, String urlPath, URL path) { + try (var fis = path.openStream()) { + ctx.header(CONTENT_TYPE, lookupMime(urlPath)); + ctx.headers(headers); + if (precompress) { + addCachedEntry(ctx, urlPath, fis); + return; + } + ctx.write(fis); + } catch (final Exception e) { + throw404(ctx.exchange()); + } + } +} diff --git a/avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/StaticContent.java b/avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/StaticContent.java new file mode 100644 index 00000000..7e54811d --- /dev/null +++ b/avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/StaticContent.java @@ -0,0 +1,141 @@ +package io.avaje.jex.staticcontent; + +import java.net.URLConnection; +import java.util.function.Predicate; + +import io.avaje.jex.http.Context; +import io.avaje.jex.security.Role; +import io.avaje.jex.spi.JexPlugin; + +/** + * Static content resource handler. + *

{@code
+ *
+ *  var staticContent = StaticContent.createFile("src/test/resources/public")
+ *     .directoryIndex("index.html")
+ *     .preCompress()
+ *     .build()
+ *
+ *  Jex.create()
+ *    .plugin(staticContent)
+ *    .port(8080)
+ *    .start();
+ *
+ * }
+ */ +public sealed interface StaticContent extends JexPlugin + permits StaticResourceHandlerBuilder { + + /** + * Create and return a new static content class path configuration. + * + * @param resourceRoot The file to serve, or the directory the files are located in. + */ + static Builder ofClassPath(String resourceRoot) { + return StaticResourceHandlerBuilder.builder(resourceRoot); + } + + /** + * Create and return a new static content class path configuration with the + * `/public` directory as the root. + */ + static Builder ofClassPath() { + return StaticResourceHandlerBuilder.builder("/public/"); + } + + /** + * Create and return a new static content configuration for a File. + * + * @param resourceRoot The path of the file to serve, or the directory the files are located in. + */ + static Builder ofFile(String resourceRoot) { + return StaticResourceHandlerBuilder.builder(resourceRoot).file(); + } + + /** + * Builder for StaticContent. + */ + sealed interface Builder + permits StaticResourceHandlerBuilder { + + /** + * Sets the HTTP route for the static resource handler. + * + * @param path the HTTP path prefix + * @param roles the security roles for the route + * @return the updated configuration + */ + Builder route(String path, Role... roles); + + /** + * Sets the index file to be served when a directory is requested. + * + * @param directoryIndex the index file + * @return the updated configuration + */ + Builder directoryIndex(String directoryIndex); + + /** + * Sent resources will be pre-compressed and cached in memory when this is enabled + * + * @return the updated configuration + */ + Builder preCompress(); + + /** + * Sets a custom resource loader for loading class/module path resources. This is normally used + * when running the application on the module path when files cannot be discovered. + * + *

Example usage: {@code service.resourceLoader(ClassResourceLoader.create(getClass())) } + * + * @param resourceLoader the custom resource loader + * @return the updated configuration + */ + Builder resourceLoader(ClassResourceLoader resourceLoader); + + /** + * Sets a custom resource loader for loading class/module path resources using the given class. + * This is normally used when running the application on the module path when files cannot be + * discovered. + * + * @param clazz the class used to custom load resources + * @return the updated configuration + */ + default Builder resourceLoader(Class clazz) { + return resourceLoader(ClassResourceLoader.fromClass(clazz)); + } + + /** + * Adds a new MIME type mapping to the configuration. (Default: uses {@link + * URLConnection#getFileNameMap()} + * + * @param ext the file extension (e.g., "html", "css", "js") + * @param mimeType the corresponding MIME type (e.g., "text/html", "text/css", + * "application/javascript") + * @return the updated configuration + */ + Builder putMimeTypeMapping(String ext, String mimeType); + + /** + * Adds a new response header to the configuration. + * + * @param key the header name + * @param value the header value + * @return the updated configuration + */ + Builder putResponseHeader(String key, String value); + + /** + * Sets a predicate to filter files based on the request context. + * + * @param skipFilePredicate the predicate to use + * @return the updated configuration + */ + Builder skipFilePredicate(Predicate skipFilePredicate); + + /** + * Build and return the StaticContent. + */ + StaticContent build(); + } +} diff --git a/avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/StaticFileHandler.java b/avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/StaticFileHandler.java new file mode 100644 index 00000000..e5a4e899 --- /dev/null +++ b/avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/StaticFileHandler.java @@ -0,0 +1,116 @@ +package io.avaje.jex.staticcontent; + +import static io.avaje.jex.core.Constants.CONTENT_TYPE; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.Map; +import java.util.function.Predicate; + +import com.sun.net.httpserver.HttpExchange; + +import io.avaje.jex.compression.CompressionConfig; +import io.avaje.jex.http.Context; + +final class StaticFileHandler extends AbstractStaticHandler { + + private final File indexFile; + private final File singleFile; + + StaticFileHandler( + String urlPrefix, + String filesystemRoot, + Map mimeTypes, + Map headers, + Predicate skipFilePredicate, + File welcomeFile, + File singleFile, + boolean precompress, + CompressionConfig compressionConfig) { + super( + urlPrefix, + filesystemRoot, + mimeTypes, + headers, + skipFilePredicate, + precompress, + compressionConfig); + this.indexFile = welcomeFile; + this.singleFile = singleFile; + } + + @Override + public void handle(Context ctx) throws IOException { + final var jdkExchange = ctx.exchange(); + if (singleFile != null) { + + final var path = singleFile.getPath(); + if (isCached(path)) { + writeCached(ctx, path); + return; + } + + sendFile(ctx, jdkExchange, path, singleFile); + return; + } + + if (skipFilePredicate.test(ctx)) { + throw404(jdkExchange); + } + + final String wholeUrlPath = jdkExchange.getRequestURI().getPath(); + if (wholeUrlPath.endsWith("/") || wholeUrlPath.equals(urlPrefix)) { + + final var path = indexFile.getPath(); + if (isCached(path)) { + writeCached(ctx, path); + return; + } + + sendFile(ctx, jdkExchange, path, indexFile); + return; + } + + final String urlPath = wholeUrlPath.substring(urlPrefix.length()); + + if (isCached(urlPath)) { + writeCached(ctx, urlPath); + return; + } + + File canonicalFile; + try { + canonicalFile = new File(filesystemRoot, urlPath).getCanonicalFile(); + } catch (IOException e) { + // This may be more benign (i.e. not an attack, just a 403), + // but we don't want an attacker to be able to discern the difference. + reportPathTraversal(); + return; + } + + String canonicalPath = canonicalFile.getPath(); + if (!canonicalPath.startsWith(filesystemRoot)) { + reportPathTraversal(); + } + + sendFile(ctx, jdkExchange, urlPath, canonicalFile); + } + + private void sendFile(Context ctx, HttpExchange jdkExchange, String urlPath, File canonicalFile) + throws IOException { + try (var fis = new FileInputStream(canonicalFile)) { + String mimeType = lookupMime(urlPath); + ctx.header(CONTENT_TYPE, mimeType); + ctx.headers(headers); + if (precompress) { + addCachedEntry(ctx, urlPath, fis); + return; + } + ctx.write(fis); + } catch (FileNotFoundException e) { + throw404(jdkExchange); + } + } +} diff --git a/avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/StaticResourceHandlerBuilder.java b/avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/StaticResourceHandlerBuilder.java new file mode 100644 index 00000000..b0f0b642 --- /dev/null +++ b/avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/StaticResourceHandlerBuilder.java @@ -0,0 +1,191 @@ +package io.avaje.jex.staticcontent; + +import java.io.File; +import java.net.URL; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Predicate; + +import io.avaje.jex.Jex; +import io.avaje.jex.compression.CompressionConfig; +import io.avaje.jex.http.Context; +import io.avaje.jex.http.ExchangeHandler; +import io.avaje.jex.security.Role; + +final class StaticResourceHandlerBuilder implements StaticContent.Builder, StaticContent { + + private static final String FAILED_TO_LOCATE_FILE = "Failed to locate file: "; + private static final String DIRECTORY_INDEX_FAILURE = + "Failed to locate Directory Index Resource: "; + private static final Predicate NO_OP_PREDICATE = ctx -> false; + private static final ClassResourceLoader DEFAULT_LOADER = new DefaultResourceLoader(); + + private String path = "/"; + private String root; + private String directoryIndex = null; + private ClassResourceLoader resourceLoader = DEFAULT_LOADER; + private final Map mimeTypes = new HashMap<>(); + private final Map headers = new HashMap<>(); + private Predicate skipFilePredicate = NO_OP_PREDICATE; + private boolean isClasspath = true; + private boolean precompress; + private Role[] roles = {}; + + private StaticResourceHandlerBuilder(String root) { + this.root = root; + } + + static StaticResourceHandlerBuilder builder(String root) { + return new StaticResourceHandlerBuilder(root); + } + + @Override + public void apply(Jex jex) { + + path = + Objects.requireNonNull(path) + .transform(s -> path.endsWith("/") && directoryIndex != null ? path + "*" : path); + + jex.get(path, createHandler(jex.config().compression()), roles); + } + + @Override + public StaticContent build() { + return this; + } + + ExchangeHandler createHandler(CompressionConfig compress) { + path = + path.transform(this::prependSlash) + .transform(s -> s.endsWith("/*") ? s.substring(0, s.length() - 2) : s); + + root = isClasspath ? root.transform(this::prependSlash) : root; + if (isClasspath && "/".equals(root)) { + throw new IllegalArgumentException( + "Cannot serve full classpath, please configure a classpath prefix"); + } + + if (root.endsWith("/") && directoryIndex == null) { + throw new IllegalArgumentException( + "Directory Index file is required when serving directories"); + } + + if (!isClasspath) { + return fileLoader(compress); + } + + return classPathHandler(compress); + } + + @Override + public StaticResourceHandlerBuilder route(String path, Role... roles) { + this.path = path; + this.roles = roles; + return this; + } + + @Override + public StaticResourceHandlerBuilder directoryIndex(String directoryIndex) { + this.directoryIndex = directoryIndex; + return this; + } + + @Override + public StaticResourceHandlerBuilder resourceLoader(ClassResourceLoader resourceLoader) { + this.resourceLoader = resourceLoader; + return this; + } + + @Override + public StaticResourceHandlerBuilder putMimeTypeMapping(String key, String value) { + this.mimeTypes.put(key, value); + return this; + } + + @Override + public StaticResourceHandlerBuilder putResponseHeader(String key, String value) { + this.headers.put(key, value); + return this; + } + + @Override + public StaticResourceHandlerBuilder skipFilePredicate(Predicate skipFilePredicate) { + this.skipFilePredicate = skipFilePredicate; + return this; + } + + @Override + public StaticResourceHandlerBuilder preCompress() { + this.precompress = true; + return this; + } + + StaticResourceHandlerBuilder file() { + this.isClasspath = false; + return this; + } + + private String prependSlash(String s) { + return s.charAt(0) == '/' ? s : "/" + s; + } + + private String appendSlash(String s) { + return s.endsWith("/") ? s : s + "/"; + } + + private StaticFileHandler fileLoader(CompressionConfig compress) { + String fsRoot; + File dirIndex = null; + File singleFile = null; + if (directoryIndex != null) { + try { + dirIndex = new File(root.transform(this::appendSlash) + directoryIndex).getCanonicalFile(); + fsRoot = dirIndex.getParentFile().getPath(); + } catch (Exception e) { + throw new IllegalStateException( + DIRECTORY_INDEX_FAILURE + root.transform(this::appendSlash) + directoryIndex, e); + } + } else { + try { + singleFile = new File(root).getCanonicalFile(); + fsRoot = singleFile.getParentFile().getPath(); + } catch (Exception e) { + throw new IllegalStateException(FAILED_TO_LOCATE_FILE + root, e); + } + } + + return new StaticFileHandler( + path, + fsRoot, + mimeTypes, + headers, + skipFilePredicate, + dirIndex, + singleFile, + precompress, + compress); + } + + private StaticClassResourceHandler classPathHandler(CompressionConfig compress) { + URL dirIndex = null; + URL singleFile = null; + if (directoryIndex != null) { + dirIndex = resourceLoader.loadResource(root.transform(this::appendSlash) + directoryIndex); + } else { + singleFile = resourceLoader.loadResource(root); + } + + return new StaticClassResourceHandler( + path, + root, + mimeTypes, + headers, + skipFilePredicate, + resourceLoader, + dirIndex, + singleFile, + precompress, + compress); + } +} diff --git a/avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/package-info.java b/avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/package-info.java new file mode 100644 index 00000000..16ad428f --- /dev/null +++ b/avaje-jex-static-content/src/main/java/io/avaje/jex/staticcontent/package-info.java @@ -0,0 +1,15 @@ +/** + * Static Content API - see {@link io.avaje.jex.staticcontent.StaticContent}. + * + *

{@code
+ * var staticContent = StaticContentService.createCP("/public").httpPath("/").directoryIndex("index.html");
+ * final Jex.Server app = Jex.create()
+ *   .plugin(staticContent)
+ *   .port(8080)
+ *   .start();
+ *
+ * app.shutdown();
+ *
+ * }
+ */ +package io.avaje.jex.staticcontent; diff --git a/avaje-jex-static-content/src/main/java/module-info.java b/avaje-jex-static-content/src/main/java/module-info.java new file mode 100644 index 00000000..54966043 --- /dev/null +++ b/avaje-jex-static-content/src/main/java/module-info.java @@ -0,0 +1,21 @@ +/** + * Defines the Static Content API for serving static resources with Jex - see {@link io.avaje.jex.staticcontent.StaticContent}. + * + *
{@code
+ * var staticContent = StaticContentService.createCP("/public").httpPath("/").directoryIndex("index.html");
+ * final Jex.Server app = Jex.create()
+ *   .plugin(staticContent)
+ *   .port(8080)
+ *   .start();
+ *
+ * app.shutdown();
+ *
+ * }
+ */ +module io.avaje.jex.staticcontent { + + exports io.avaje.jex.staticcontent; + + requires transitive io.avaje.jex; + +} diff --git a/avaje-jex-static-content/src/test/java/io/avaje/jex/staticcontent/CompressedStaticFileTest.java b/avaje-jex-static-content/src/test/java/io/avaje/jex/staticcontent/CompressedStaticFileTest.java new file mode 100644 index 00000000..557def3d --- /dev/null +++ b/avaje-jex-static-content/src/test/java/io/avaje/jex/staticcontent/CompressedStaticFileTest.java @@ -0,0 +1,144 @@ +package io.avaje.jex.staticcontent; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.http.HttpResponse; +import java.time.Duration; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +import io.avaje.jex.Jex; +import io.avaje.jex.test.TestPair; + +class CompressedStaticFileTest { + + static TestPair pair = init(); + + static TestPair init() { + + final Jex app = + Jex.create() + .plugin(defaultCP().route("/index").build()) + .plugin(defaultFile().route("/indexFile").build()) + .plugin(defaultCP().route("/indexWild/*").build()) + .plugin(defaultFile().route("/indexWildFile/*").build()) + .plugin(defaultCP().route("/sus/").build()) + .plugin(defaultFile().route("/susFile/*").build()) + .plugin(StaticContent.ofClassPath("/logback.xml").route("/").build()) + .plugin( + StaticContent.ofFile("src/test/resources/logback.xml") + .route("/singleFile") + .build()); + + return TestPair.create(app); + } + + private static StaticContent.Builder defaultFile() { + return StaticContent.ofFile("src/test/resources/public") + .directoryIndex("index.html") + .preCompress(); + } + + private static StaticContent.Builder defaultCP() { + return StaticContent.ofClassPath("/public") + .directoryIndex("index.html") + .preCompress(); + } + + @AfterAll + static void end() { + pair.shutdown(); + } + + @Test + void testGet() { + pair.request().path("index").GET().asString(); + HttpResponse res = pair.request().path("index").GET().asString(); + assertThat(res.statusCode()).isEqualTo(200); + } + + @Test + void testTraversal() { + pair.request().path("indexWild/../hmm").GET().asString(); + HttpResponse res = pair.request().path("indexWild/../hmm").GET().asString(); + assertThat(res.statusCode()).isEqualTo(400); + } + + @Test + void getIndexWildCP() { + pair.request().path("indexWild/").GET().asString(); + HttpResponse res = pair.request().path("indexWild/").GET().asString(); + assertThat(res.statusCode()).isEqualTo(200); + assertThat(res.headers().firstValue("Content-Type").orElseThrow()).contains("html"); + } + + @Test + void getIndex404() { + pair.request().path("index").path("index.html").GET().asString(); + HttpResponse res = pair.request().path("index").path("index.html").GET().asString(); + assertThat(res.statusCode()).isEqualTo(404); + } + + @Test + void getDirContentCP() { + pair.request().requestTimeout(Duration.ofHours(1)).path("sus/sus.txt").GET().asString(); + HttpResponse res = + pair.request().requestTimeout(Duration.ofHours(1)).path("sus/sus.txt").GET().asString(); + assertThat(res.statusCode()).isEqualTo(200); + assertThat(res.body()).contains("ඞ"); + } + + @Test + void getSingleFileCP() { + pair.request().GET().asString(); + HttpResponse res = pair.request().GET().asString(); + assertThat(res.statusCode()).isEqualTo(200); + assertThat(res.headers().firstValue("Content-Type").orElseThrow()).contains("xml"); + } + + @Test + void get404() { + HttpResponse res = pair.request().path("unknown").path("index.html").GET().asString(); + assertThat(res.statusCode()).isEqualTo(404); + } + + @Test + void getIndexFile() { + pair.request().path("indexFile").GET().asString(); + HttpResponse res = pair.request().path("indexFile").GET().asString(); + assertThat(res.statusCode()).isEqualTo(200); + assertThat(res.headers().firstValue("Content-Type").orElseThrow()).contains("html"); + } + + @Test + void getDirContentFile() { + pair.request().path("susFile/sus.txt").GET().asString(); + HttpResponse res = pair.request().path("susFile/sus.txt").GET().asString(); + assertThat(res.statusCode()).isEqualTo(200); + assertThat(res.body()).contains("ඞ"); + } + + @Test + void getSingleResourceFile() { + pair.request().path("singleFile").GET().asString(); + HttpResponse res = pair.request().path("singleFile").GET().asString(); + assertThat(res.statusCode()).isEqualTo(200); + assertThat(res.headers().firstValue("Content-Type").orElseThrow()).contains("xml"); + } + + @Test + void getIndexWildFile() { + pair.request().path("indexWildFile/").GET().asString(); + HttpResponse res = pair.request().path("indexWildFile/").GET().asString(); + assertThat(res.statusCode()).isEqualTo(200); + assertThat(res.headers().firstValue("Content-Type").orElseThrow()).contains("html"); + } + + @Test + void testFileTraversal() { + pair.request().path("indexWildFile/../traverse").GET().asString(); + HttpResponse res = pair.request().path("indexWildFile/../traverse").GET().asString(); + assertThat(res.statusCode()).isEqualTo(400); + } +} diff --git a/avaje-jex-static-content/src/test/java/io/avaje/jex/staticcontent/StaticFileTest.java b/avaje-jex-static-content/src/test/java/io/avaje/jex/staticcontent/StaticFileTest.java new file mode 100644 index 00000000..ffe40d85 --- /dev/null +++ b/avaje-jex-static-content/src/test/java/io/avaje/jex/staticcontent/StaticFileTest.java @@ -0,0 +1,124 @@ +package io.avaje.jex.staticcontent; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.http.HttpResponse; +import java.time.Duration; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +import io.avaje.jex.Jex; +import io.avaje.jex.test.TestPair; + +class StaticFileTest { + + static TestPair pair = init(); + + static TestPair init() { + + final Jex app = + Jex.create() + .plugin(defaultCP().route("/index").build()) + .plugin(defaultFile().route("/indexFile").build()) + .plugin(defaultCP().route("/indexWild/*").build()) + .plugin(defaultFile().route("/indexWildFile/*").build()) + .plugin(defaultCP().route("/sus/").build()) + .plugin(defaultFile().route("/susFile/*").build()) + .plugin(StaticContent.ofClassPath("/logback.xml").route("/single").build()) + .plugin( + StaticContent.ofFile("src/test/resources/logback.xml") + .route("/singleFile").build()); + + return TestPair.create(app); + } + + private static StaticContent.Builder defaultFile() { + return StaticContent.ofFile("src/test/resources/public") + .directoryIndex("index.html"); + } + + private static StaticContent.Builder defaultCP() { + return StaticContent.ofClassPath("/public").directoryIndex("index.html"); + } + + @AfterAll + static void end() { + pair.shutdown(); + } + + @Test + void testGet() { + HttpResponse res = pair.request().path("index").GET().asString(); + assertThat(res.statusCode()).isEqualTo(200); + } + + @Test + void testTraversal() { + HttpResponse res = pair.request().path("indexWild/../hmm").GET().asString(); + assertThat(res.statusCode()).isEqualTo(400); + } + + @Test + void getIndexWildCP() { + HttpResponse res = pair.request().path("indexWild/").GET().asString(); + assertThat(res.statusCode()).isEqualTo(200); + assertThat(res.headers().firstValue("Content-Type").orElseThrow()).contains("html"); + } + + @Test + void getIndex404() { + HttpResponse res = pair.request().path("index").path("index.html").GET().asString(); + assertThat(res.statusCode()).isEqualTo(404); + } + + @Test + void getDirContentCP() { + HttpResponse res = + pair.request().requestTimeout(Duration.ofHours(1)).path("sus/sus.txt").GET().asString(); + assertThat(res.statusCode()).isEqualTo(200); + assertThat(res.body()).contains("ඞ"); + } + + @Test + void getSingleFileCP() { + HttpResponse res = pair.request().path("single").GET().asString(); + assertThat(res.statusCode()).isEqualTo(200); + assertThat(res.headers().firstValue("Content-Type").orElseThrow()).contains("xml"); + } + + @Test + void getIndexFile() { + HttpResponse res = pair.request().path("indexFile").GET().asString(); + assertThat(res.statusCode()).isEqualTo(200); + assertThat(res.headers().firstValue("Content-Type").orElseThrow()).contains("html"); + } + + @Test + void getDirContentFile() { + HttpResponse res = + pair.request().requestTimeout(Duration.ofHours(1)).path("susFile/sus.txt").GET().asString(); + assertThat(res.statusCode()).isEqualTo(200); + assertThat(res.body()).contains("ඞ"); + } + + @Test + void getSingleResourceFile() { + HttpResponse res = pair.request().path("singleFile").GET().asString(); + assertThat(res.statusCode()).isEqualTo(200); + assertThat(res.headers().firstValue("Content-Type").orElseThrow()).contains("xml"); + } + + @Test + void getIndexWildFile() { + HttpResponse res = pair.request().path("indexWildFile/").GET().asString(); + assertThat(res.statusCode()).isEqualTo(200); + assertThat(res.headers().firstValue("Content-Type").orElseThrow()).contains("html"); + } + + @Test + void testFileTraversal() { + HttpResponse res = pair.request().path("indexWildFile/../traverse").GET().asString(); + assertThat(res.statusCode()).isEqualTo(400); + } +} diff --git a/avaje-jex-grizzly/src/test/resources/logback-test.xml b/avaje-jex-static-content/src/test/resources/logback.xml similarity index 91% rename from avaje-jex-grizzly/src/test/resources/logback-test.xml rename to avaje-jex-static-content/src/test/resources/logback.xml index ddb21350..199b4a4f 100644 --- a/avaje-jex-grizzly/src/test/resources/logback-test.xml +++ b/avaje-jex-static-content/src/test/resources/logback.xml @@ -13,7 +13,7 @@ - + diff --git a/avaje-jex-static-content/src/test/resources/public/index.html b/avaje-jex-static-content/src/test/resources/public/index.html new file mode 100644 index 00000000..41abec16 --- /dev/null +++ b/avaje-jex-static-content/src/test/resources/public/index.html @@ -0,0 +1,9 @@ + + + + Index.html + + +

This ia my first page.

+ + \ No newline at end of file diff --git a/avaje-jex-static-content/src/test/resources/public/sus.txt b/avaje-jex-static-content/src/test/resources/public/sus.txt new file mode 100644 index 00000000..b20ae04e --- /dev/null +++ b/avaje-jex-static-content/src/test/resources/public/sus.txt @@ -0,0 +1 @@ +ඞ \ No newline at end of file diff --git a/avaje-jex-test/pom.xml b/avaje-jex-test/pom.xml index d811fd2d..97e5427f 100644 --- a/avaje-jex-test/pom.xml +++ b/avaje-jex-test/pom.xml @@ -4,7 +4,7 @@ avaje-jex-parent io.avaje - 2.5 + 3.0 avaje-jex-test @@ -14,38 +14,31 @@ io.avaje avaje-jex - 2.5 - provided io.avaje - avaje-http-client - 1.21 + junit + 1.5 - io.avaje - avaje-inject-test - 8.11 - true + avaje-http-client + io.avaje - avaje-jsonb - 1.1 + avaje-inject-test true com.fasterxml.jackson.core jackson-databind - 2.14.0 - true + ${jackson.version} -
diff --git a/avaje-jex-test/src/main/java/io/avaje/jex/test/JexInjectPlugin.java b/avaje-jex-test/src/main/java/io/avaje/jex/test/JexInjectPlugin.java index 22b80984..d12b975b 100644 --- a/avaje-jex-test/src/main/java/io/avaje/jex/test/JexInjectPlugin.java +++ b/avaje-jex-test/src/main/java/io/avaje/jex/test/JexInjectPlugin.java @@ -1,12 +1,13 @@ package io.avaje.jex.test; -import io.avaje.http.client.HttpClientContext; +import java.lang.annotation.Annotation; +import java.lang.reflect.Type; + +import io.avaje.http.client.HttpClient; import io.avaje.inject.BeanScope; import io.avaje.inject.test.Plugin; import io.avaje.jex.Jex; -import java.lang.annotation.Annotation; - /** * avaje-inject-test plugin that: * @@ -20,12 +21,13 @@ public final class JexInjectPlugin implements Plugin { private static final String AVAJE_HTTP_CLIENT = "io.avaje.http.api.Client"; private static final String AVAJE_HTTP_PATH = "io.avaje.http.api.Path"; - /** - * Return true if it's a http client this plugin supports. - */ + /** Return true if it's a http client this plugin supports. */ @Override - public boolean forType(Class type) { - return HttpClientContext.class.equals(type) || isHttpClientApi(type); + public boolean forType(Type type) { + + if (!(type instanceof Class clazz)) return false; + + return HttpClient.class.equals(clazz) || isHttpClientApi(clazz); } private boolean isHttpClientApi(Class type) { @@ -34,10 +36,7 @@ private boolean isHttpClientApi(Class type) { } for (Annotation annotation : type.getAnnotations()) { String name = annotation.annotationType().getName(); - if (AVAJE_HTTP_CLIENT.equals(name)) { - return true; - } - if (AVAJE_HTTP_PATH.equals(name)) { + if (AVAJE_HTTP_CLIENT.equals(name) || AVAJE_HTTP_PATH.equals(name)) { return true; } } @@ -57,7 +56,7 @@ public Scope createScope(BeanScope beanScope) { private static class LocalScope implements Plugin.Scope { private final Jex.Server server; - private final HttpClientContext httpClient; + private final HttpClient httpClient; LocalScope(BeanScope beanScope) { Jex jex = beanScope.getOptional(Jex.class) @@ -68,19 +67,20 @@ private static class LocalScope implements Plugin.Scope { // get a HttpClientContext.Builder provided by dependency injection test scope or new one up this.server = jex.start(); int port = server.port(); - this.httpClient = beanScope.getOptional(HttpClientContext.Builder.class) - .orElse(HttpClientContext.builder()) + this.httpClient = beanScope.getOptional(HttpClient.Builder.class) + .orElse(HttpClient.builder()) .configureWith(beanScope) .baseUrl("http://localhost:" + port) .build(); } @Override - public Object create(Class type) { - if (HttpClientContext.class.equals(type)) { + public Object create(Type type) { + + if (HttpClient.class.equals(type)) { return httpClient; } - return apiClient(type); + return apiClient((Class) type); } private Object apiClient(Class clientInterface) { diff --git a/avaje-jex-test/src/main/java/io/avaje/jex/test/TestPair.java b/avaje-jex-test/src/main/java/io/avaje/jex/test/TestPair.java index 09c25612..5c0d3dee 100644 --- a/avaje-jex-test/src/main/java/io/avaje/jex/test/TestPair.java +++ b/avaje-jex-test/src/main/java/io/avaje/jex/test/TestPair.java @@ -1,26 +1,21 @@ package io.avaje.jex.test; -import com.fasterxml.jackson.databind.ObjectMapper; -import io.avaje.http.client.HttpClientContext; +import java.net.http.HttpClient.Version; + +import io.avaje.http.client.HttpClient; import io.avaje.http.client.HttpClientRequest; -import io.avaje.http.client.JacksonBodyAdapter; -import io.avaje.http.client.RequestLogger; import io.avaje.jex.Jex; -import java.util.Random; - -/** - * Server and Client pair for a test. - */ +/** Server and Client pair for a test. */ public class TestPair { private final int port; private final Jex.Server server; - private final HttpClientContext client; + private final HttpClient client; - public TestPair(int port, Jex.Server server, HttpClientContext client) { + public TestPair(int port, Jex.Server server, HttpClient client) { this.port = port; this.server = server; this.client = client; @@ -42,19 +37,13 @@ public String url() { return client.url().build(); } - /** - * Create a Server and Client pair for a given set of tests. - */ + /** 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); - var jexServer = app.port(port).start(); - + var jexServer = app.port(0).start(); + var port = jexServer.port(); var url = "http://localhost:" + port; - var client = HttpClientContext.builder() - .baseUrl(url) - .bodyAdapter(new JacksonBodyAdapter()) - .build(); + var client = HttpClient.builder().version(Version.HTTP_1_1).baseUrl(url).build(); return new TestPair(port, jexServer, client); } diff --git a/avaje-jex-test/src/main/java/module-info.java b/avaje-jex-test/src/main/java/module-info.java index 724d928b..0a2163e6 100644 --- a/avaje-jex-test/src/main/java/module-info.java +++ b/avaje-jex-test/src/main/java/module-info.java @@ -4,10 +4,8 @@ requires transitive io.avaje.jex; requires transitive io.avaje.http.client; - requires static com.fasterxml.jackson.databind; - requires static io.avaje.jsonb; + requires transitive com.fasterxml.jackson.databind; requires static io.avaje.inject.test; - requires static org.apiguardian.api; // stink man !! provides io.avaje.inject.test.Plugin with io.avaje.jex.test.JexInjectPlugin; } diff --git a/avaje-jex/pom.xml b/avaje-jex/pom.xml index d0b7aa75..03d99032 100644 --- a/avaje-jex/pom.xml +++ b/avaje-jex/pom.xml @@ -4,71 +4,79 @@ io.avaje avaje-jex-parent - 2.5 + 3.0 avaje-jex - 11.0.13 io.avaje - avaje-applog - 1.0 + avaje-config + true io.avaje - avaje-config - 2.4 + avaje-inject true io.avaje - avaje-inject - 8.11 - true + avaje-spi-service + provided com.fasterxml.jackson.core jackson-databind - 2.14.0 + ${jackson.version} true io.avaje avaje-jsonb - 1.1 true - + + io.avaje + avaje-jsonb-generator + test + - - - - org.apache.maven.plugins - maven-surefire-plugin - - - --add-opens io.avaje.jex/io.avaje.jex.base=com.fasterxml.jackson.databind - --add-modules com.fasterxml.jackson.databind - --add-opens io.avaje.jex/io.avaje.jex=ALL-UNNAMED - --add-opens io.avaje.jex/io.avaje.jex.base=ALL-UNNAMED - --add-opens io.avaje.jex/io.avaje.jex.core=ALL-UNNAMED - --add-opens io.avaje.jex/io.avaje.jex.routes=ALL-UNNAMED - --add-opens io.avaje.jex/io.avaje.jex.jetty=ALL-UNNAMED - - - - - + + io.avaje + junit + 1.5 + test + + + io.avaje + avaje-http-client + test + + + + + robaho + + false + + + + io.github.robaho + httpserver + test + + + + diff --git a/avaje-jex/src/main/java/io/avaje/jex/AccessManager.java b/avaje-jex/src/main/java/io/avaje/jex/AccessManager.java deleted file mode 100644 index 0e54839b..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/AccessManager.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Javalin - https://javalin.io - * Copyright 2017 David Åse - * Licensed under Apache 2.0: https://github.com/tipsy/javalin/blob/master/LICENSE - */ -package io.avaje.jex; - - -import java.util.Set; - -/** - * Provide access check for routes that have permitted roles assigned to them. - * - *

- * An example implementation might look like the code below. - * - *

{@code
- *
- *   var app = Jex.create()
- *     .accessManager((handler, ctx, permittedRoles) -> {
- *
- *         // obtain current users role(s)
- *         final String userRole = ...
- *
- *         if (userRole == null || !permittedRoles.contains(AppRoles.valueOf(userRole))) {
- *           ctx.status(401).text("Unauthorized");
- *         } else {
- *           // allow
- *           handler.handle(ctx);
- *         }
- *       })
- *
- * }
- */ -@FunctionalInterface -public interface AccessManager { - - /** - * Check that the current user has one of the required roles. - *

- * Implementations should call the handler if the user has one of - * the permitted roles. - * - * @param handler The handler to call if the user has an appropriate role. - * @param ctx The context - * @param permittedRoles The permitted roles for the endpoint - */ - void manage(Handler handler, Context ctx, Set permittedRoles); -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/AppLifecycle.java b/avaje-jex/src/main/java/io/avaje/jex/AppLifecycle.java index 469f6f56..d9d749ad 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/AppLifecycle.java +++ b/avaje-jex/src/main/java/io/avaje/jex/AppLifecycle.java @@ -1,10 +1,9 @@ package io.avaje.jex; -/** - * Application lifecycle support. - */ +/** Defines the lifecycle configuration for an application. */ public interface AppLifecycle { + /** Represents the possible states of the application server. */ enum Status { STARTING, STARTED, @@ -13,41 +12,55 @@ enum Status { } /** - * Register a Runnable to run on shutdown of the server. - *

- * This will execute after the server has deemed there are no active requests. + * Registers a runnable to be executed when the application server is shutting down. + * + *

This runnable will be executed after all active requests have been processed. + * + * @param onShutdown The runnable to execute on shutdown. */ void onShutdown(Runnable onShutdown); /** - * Register a Runnable to run on shutdown of the server with ordering. - *

- * The runnables are executed with order from low to high (0 means run first). - *

- * This will execute after the server has deemed there are no active requests. + * Registers a runnable to be executed when the application server is shutting down, with a + * specific order. + * + *

Runnables with lower order values will be executed first. * - * @param onShutdown The function to run on shutdown - * @param order The relative order to execute with 0 meaning run first + *

This runnable will be executed after all active requests have been processed. + * + * @param onShutdown The runnable to execute on shutdown. + * @param order The order in which to execute the runnable. */ void onShutdown(Runnable onShutdown, int order); /** - * Register the runnable with the Runtime as a shutdown hook. + * Registers a runnable as a shutdown hook with the JVM. + * + *

This runnable will be executed when the JVM is shutting down, regardless of the application + * server's state. + * + * @param onShutdown The runnable to register as a shutdown hook. */ void registerShutdownHook(Runnable onShutdown); /** - * Return the current status. + * Returns the current status of the application server. + * + * @return The current status of the server. */ Status status(); /** - * Set the current status. + * Sets the current status of the application server. + * + * @param newStatus The new status to set. */ void status(Status newStatus); /** - * Return true if status starting or started (the server is coming up). + * Indicates whether the application server is currently starting or has started. + * + * @return true if the server is starting or started, false otherwise. */ default boolean isAlive() { Status status = status(); @@ -55,11 +68,12 @@ default boolean isAlive() { } /** - * Return true the server has started. + * Indicates whether the application server has fully started. + * + * @return true if the server has started, false otherwise. */ default boolean isReady() { Status status = status(); return status == Status.STARTED; } - } diff --git a/avaje-jex/src/main/java/io/avaje/jex/AvajeJex.java b/avaje-jex/src/main/java/io/avaje/jex/AvajeJex.java new file mode 100644 index 00000000..b603a332 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/AvajeJex.java @@ -0,0 +1,58 @@ +package io.avaje.jex; + +import io.avaje.config.Config; +import io.avaje.inject.BeanScope; +import io.avaje.jex.Jex.Server; + +/** + * Start Jex using avaje-inject, avaje-http, avaje-config. + * + *

- avaje-http generates the adapter for the {@code @Controller} + * + *

- avaje-inject generates dependency injection wiring + * + *

- avaje-config reads external configuration. + */ +public interface AvajeJex { + + /** + * Start Jex server using {@code @Controller} with avaje-inject, avaje-http, avaje-config. + * + *

{@code
+   * public static void main(String[] args) {
+   *
+   *   AvajeJex.start();
+   * }
+   * }
+ * + * @return The running server. + */ + static Server start() { + return start(BeanScope.builder().build()); + } + + /** + * Start Jex server using {@code @Controller} with avaje-inject, avaje-http, avaje-config. + * + *
{@code
+   * public static void main(String[] args) {
+   *
+   *   AvajeJex.start();
+   * }
+   * }
+ * + * @param beanScope the beanscope used to configure Jex + * @return The running server. + */ + static Server start(BeanScope beanScope) { + Jex jex = beanScope.getOptional(Jex.class).orElse(Jex.create()); + jex.configureWith(beanScope); + + JexConfig config = jex.config(); + config.port(Config.getInt("server.port", config.port())); + config.contextPath(Config.get("server.context.path", config.contextPath())); + config.host(Config.getNullable("server.context.host", config.host())); + + return jex.start(); + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/BootJex.java b/avaje-jex/src/main/java/io/avaje/jex/BootJex.java deleted file mode 100644 index 321032e2..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/BootJex.java +++ /dev/null @@ -1,36 +0,0 @@ -package io.avaje.jex; - -/** - * Start Jex using {@code @Controller} and avaje-inject, avaje-http, avaje-config. - *

- * - avaje-http generates the adapter for the {@code @Controller} - * - avaje-inject generates dependency injection wiring - * - avaje-config reads external configuration (application.properties|yaml, application-test.properties|yaml). - */ -public interface BootJex { - - /** - * Start Jex server using {@code @Controller} with avaje-inject, avaje-http, avaje-config. - * - *

{@code
-   *   public static void main(String[] args) {
-   *
-   *     BootJex.start();
-   *   }
-   * }
- */ - static void start() { - BootJexState.start(); - } - -// /** -// * Stop the Jex server (for CRaC). -// */ -// static void stop() { -// BootJexState.stop(); -// } -// -// static void restart() { -// BootJexState.restart(); -// } -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/BootJexState.java b/avaje-jex/src/main/java/io/avaje/jex/BootJexState.java deleted file mode 100644 index f95b8a00..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/BootJexState.java +++ /dev/null @@ -1,58 +0,0 @@ -package io.avaje.jex; - -import io.avaje.config.Config; -import io.avaje.inject.BeanScope; - -class BootJexState { - - private static State state; - - static void start() { - state = new BootJexState().create(); - } - - static void stop() { - state.stop(); - } - - static void restart() { - state.restart(); - } - - State create() { - BeanScope beanScope = BeanScope.builder().build(); - - Jex jex = beanScope.getOptional(Jex.class).orElse(Jex.create()); - jex.configureWith(beanScope); - - JexConfig config = jex.config(); - int port = config.port(); - if (port == 7001) { - config.port(Config.getInt("jex.port", port)); - } - - jex.lifecycle().onShutdown(beanScope::close); - return new State(jex.start(), beanScope); - } - - private static class State { - - private final Jex.Server server; - private final BeanScope beanScope; - - State(Jex.Server server, BeanScope beanScope) { - this.server = server; - this.beanScope = beanScope; - } - - void stop() { - server.shutdown(); - } - - public void restart() { - // CRaC based startup ... - //beanScope.restart(); - server.restart(); - } - } -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/Context.java b/avaje-jex/src/main/java/io/avaje/jex/Context.java deleted file mode 100644 index 305b7636..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/Context.java +++ /dev/null @@ -1,520 +0,0 @@ -package io.avaje.jex; - -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. - */ -public interface Context { - - /** - * Return the matched path as a raw expression. - */ - String matchedPath(); - - /** - * Sets an attribute on the request. - *

- * Attributes are available to other handlers in the request lifecycle - */ - Context attribute(String key, Object value); - - /** - * Get the specified attribute from the request. - */ - T attribute(String key); - - /** - * Return a request cookie by name, or null. - */ - String cookie(String name); - - /** - * Returns a map with all the cookie keys and values on the request. - */ - Map cookieMap(); - - /** - * Sets a cookie with name, value with unlimited age. - */ - Context cookie(String name, String value); - - /** - * Sets a cookie with name, value, and max-age. - */ - Context cookie(String name, String value, int maxAge); - - /** - * Sets a Cookie. - */ - Context cookie(Cookie cookie); - - /** - * Remove a cookie by name. - */ - Context removeCookie(String name); - - /** - * Remove a cookie by name and path. - */ - Context removeCookie(String name, String path); - - /** - * Redirect to the specified location using 302 status code. - */ - void redirect(String location); - - /** - * Redirect to the location specifying the response status code. - */ - void redirect(String location, int httpStatusCode); - - /** - * Return the request body as bytes. - */ - byte[] bodyAsBytes(); - - /*** - * Return the request body as bean. - * - * @param beanType The bean type - */ - T bodyAsClass(Class beanType); - - /** - * Return the request body as String. - */ - String body(); - - /** - * Return the request content length. - */ - long contentLength(); - - /** - * Return the request content type. - */ - String contentType(); - - /** - * Set the response content type. - */ - Context contentType(String contentType); - - /** - * Return all the path parameters as a map. - */ - Map pathParamMap(); - - /** - * Return the path parameter. - * - * @param name The path parameter name. - */ - String pathParam(String name); - - /** - * Return the first query parameter value. - * - * @param name The query parameter name - */ - String queryParam(String name); - - /** - * Return the first query parameter value or the default value if it does not exist. - * - * @param name The query parameter name - */ - default String queryParam(String name, String defaultValue) { - String val = queryParam(name); - return val != null ? val : defaultValue; - } - - /** - * Return all the query parameters for the given parameter name. - */ - List queryParams(String name); - - /** - * Return all the query parameters as a map. - *

- * Note this returns the first value for any given key if that key has multiple values. - */ - Map queryParamMap(); - - /** - * Return the request query string, or null. - */ - String queryString(); - - /** - * Return the first form param value for the specified key or null. - */ - default String formParam(String key) { - return formParam(key, null); - } - - /** - * Return the first form param value for the specified key or the default value. - */ - 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. - */ - 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. - */ - Map> formParamMap(); - - /** - * Return the request scheme. - */ - String scheme(); - - /** - * Sets an attribute for the user session. - */ - Context sessionAttribute(String key, Object value); - - /** - * Gets specified attribute from the user session, or null. - */ - T sessionAttribute(String key); - - /** - * Return a map of all the attributes in the user session. - */ - Map sessionAttributeMap(); - - /** - * Return the request url. - */ - String url(); - - /** - * Return the full request url, including query string (if present) - */ - default String fullUrl() { - final String url = url(); - final String qs = queryString(); - return qs == null ? url : url + "?" + qs; - } - - /** - * Return the request context path. - */ - String contextPath(); - - /** - * Return the request user agent, or null. - */ - default String userAgent() { - return header(HeaderKeys.USER_AGENT); - } - - /** - * Set the status code on the response. - */ - Context status(int statusCode); - - /** - * Return the current response status. - */ - int status(); - - /** - * Write plain text content to the response. - */ - Context text(String content); - - /** - * Write html content to the response. - */ - Context html(String content); - - /** - * Set the response body as JSON for the given bean. - */ - Context json(Object bean); - - /** - * Write the stream as a JSON stream with new line delimiters - * {@literal application/x-json-stream}. - * - * @param stream The stream of beans to write as json - */ - Context jsonStream(Stream stream); - - /** - * Write the stream as a JSON stream with new line delimiters - * {@literal application/x-json-stream}. - * - * @param iterator The iterator of beans to write as json - */ - Context jsonStream(Iterator iterator); - - /** - * Write raw content to the response. - */ - Context write(String content); - - /** - * Render a template typically as html. - * - * @param name The template name - */ - default Context render(String name) { - return render(name, emptyMap()); - } - - /** - * Render a template typically as html with the given model. - * - * @param name The template name - * @param model The model used with the template - */ - Context render(String name, Map model); - - /** - * Return true if content has already been written to the underlying server outputStream. - */ - boolean isCommitted(); - - /** - * If not committed reset the underlying response status, headers and buffer. - */ - void reset(); - - /** - * Return all the request headers as a map. - */ - Map headerMap(); - - /** - * Return the request header. - * - * @param key The header key - */ - String header(String key); - - /** - * Set the response header. - * - * @param key The header key - * @param value The header value - */ - Context header(String key, String value); - - /** - * Return the response header. - */ - String responseHeader(String key); - - /** - * Returns the request host, or null. - */ - String host(); - - /** - * Returns the request IP. - */ - String ip(); - - /** - * Returns true if request is multipart. - */ - boolean isMultipart(); - - /** - * Returns true if request is multipart/form-data. - */ - boolean isMultipartFormData(); - - /** - * Returns the request method. - */ - String method(); - - /** - * Return the request path. - */ - String path(); - - /** - * Return the request port. - */ - int port(); - - /** - * Return the request protocol. - */ - String protocol(); - - /** - * Return the first UploadedFile for the specified name or null. - */ - UploadedFile uploadedFile(String name); - - /** - * Return a list of UploadedFiles for the specified name, or empty list. - */ - List uploadedFiles(String name); - - /** - * Return a list of all UploadedFiles. - */ - 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/DJex.java b/avaje-jex/src/main/java/io/avaje/jex/DJex.java index 5c741c5e..455590b1 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/DJex.java +++ b/avaje-jex/src/main/java/io/avaje/jex/DJex.java @@ -1,8 +1,10 @@ package io.avaje.jex; import io.avaje.inject.BeanScope; -import io.avaje.jex.core.HealthPlugin; +import io.avaje.jex.core.BootstrapServer; import io.avaje.jex.spi.*; +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.spi.HttpServerProvider; import java.util.*; import java.util.function.Consumer; @@ -10,65 +12,22 @@ final class DJex implements 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<>(); private final DJexConfig config = new DJexConfig(); - private ServerConfig serverConfig; - - DJex() { - this.staticFiles = new DefaultStaticFileConfig(this); - } @Override public DJexConfig config() { return config; } - @Override - public Jex attribute(Class cls, T instance) { - attributes.put(cls, instance); - return this; - } - - @Override - @SuppressWarnings("unchecked") - public T attribute(Class cls) { - return (T) attributes.get(cls); - } - - @Override - public Jex errorHandling(ErrorHandling.Service service) { - service.add(errorHandling); - return this; - } - - @Override - public ErrorHandling errorHandling() { - return errorHandling; - } - - @Override - public ServerConfig serverConfig() { - return serverConfig; - } - - @Override - public Jex serverConfig(ServerConfig serverConfig) { - this.serverConfig = serverConfig; - return this; - } - - @Override - public Jex routing(Routing.Service routes) { + public Jex routing(Routing.HttpService routes) { routing.add(routes); return this; } @Override - public Jex routing(Collection routes) { + public Jex routing(Collection routes) { routing.addAll(routes); return this; } @@ -78,20 +37,14 @@ public Routing routing() { return routing; } - @Override - public Jex accessManager(AccessManager accessManager) { - this.config.accessManager(accessManager); - return this; - } - @Override public Jex jsonService(JsonService jsonService) { - this.config.jsonService(jsonService); + config.jsonService(jsonService); return this; } @Override - public Jex plugin(Plugin plugin) { + public Jex plugin(JexPlugin plugin) { plugin.apply(this); return this; } @@ -99,47 +52,34 @@ public Jex plugin(Plugin plugin) { @Override public Jex configureWith(BeanScope beanScope) { lifecycle.onShutdown(beanScope::close); - for (Plugin plugin : beanScope.list(Plugin.class)) { + for (JexPlugin plugin : beanScope.list(JexPlugin.class)) { plugin.apply(this); } - for (ErrorHandling.Service service : beanScope.list(ErrorHandling.Service.class)) { - service.add(errorHandling); - } - routing.addAll(beanScope.list(Routing.Service.class)); + routing.addAll(beanScope.list(Routing.HttpService.class)); beanScope.getOptional(JsonService.class).ifPresent(this::jsonService); - beanScope.getOptional(AccessManager.class).ifPresent(this::accessManager); + beanScope.getOptional(HttpsConfigurator.class).ifPresent(config()::httpsConfig); + beanScope.getOptional(HttpServerProvider.class).ifPresent(config()::serverProvider); return this; } @Override - public Jex configure(Consumer configure) { + public Jex config(Consumer configure) { configure.accept(config); return this; } - @Override - public Jex exception(Class exceptionClass, ExceptionHandler handler) { - errorHandling.exception(exceptionClass, handler); - return this; - } - @Override public Jex port(int port) { - this.config.port(port); + config.port(port); return this; } @Override - public Jex context(String contextPath) { - this.config.contextPath(contextPath); + public Jex contextPath(String contextPath) { + config.contextPath(contextPath); return this; } - @Override - public StaticFileConfig staticFiles() { - return staticFiles; - } - @Override public Jex register(TemplateRender renderer, String... extensions) { for (String extension : extensions) { @@ -155,22 +95,6 @@ public AppLifecycle lifecycle() { @Override public Server start() { - if (config.health()) { - plugin(new HealthPlugin()); - } - final SpiRoutes routes = ServiceLoader.load(SpiRoutesProvider.class) - .findFirst().get() - .create(this.routing, this.config.accessManager(), this.config.ignoreTrailingSlashes()); - - final SpiServiceManager serviceManager = ServiceLoader.load(SpiServiceManagerProvider.class) - .findFirst().get() - .create(this); - - final Optional start = ServiceLoader.load(SpiStartServer.class).findFirst(); - if (start.isEmpty()) { - throw new IllegalStateException("There is no SpiStartServer? Missing dependency on jex-jetty?"); - } - return start.get().start(this, routes, serviceManager); + return BootstrapServer.start(this); } - } diff --git a/avaje-jex/src/main/java/io/avaje/jex/DJexConfig.java b/avaje-jex/src/main/java/io/avaje/jex/DJexConfig.java index 5f9894ec..877beea3 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/DJexConfig.java +++ b/avaje-jex/src/main/java/io/avaje/jex/DJexConfig.java @@ -1,25 +1,40 @@ package io.avaje.jex; -import io.avaje.jex.spi.JsonService; - import java.util.HashMap; import java.util.Map; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.function.Consumer; -class DJexConfig implements JexConfig { +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.spi.HttpServerProvider; - private int port = 7001; - private String host; +import io.avaje.jex.compression.CompressionConfig; +import io.avaje.jex.spi.JsonService; +import io.avaje.jex.spi.TemplateRender; + +final class DJexConfig implements JexConfig { + + private int port = 8080; private String contextPath = "/"; + private String host; + private int socketBacklog = 0; private boolean health = true; private boolean ignoreTrailingSlashes = true; - private boolean virtualThreads; - - private boolean preCompressStaticFiles; + private Executor executor; private JsonService jsonService; - private AccessManager accessManager; - private UploadConfig multipartConfig; - private int multipartFileThreshold = 8 * 1024; private final Map renderers = new HashMap<>(); + private HttpsConfigurator httpsConfig; + private final CompressionConfig compression = new CompressionConfig(); + private int bufferInitial = 256; + private long bufferMax = 4096L; + private HttpServerProvider serverProvider; + + @Override + public JexConfig host(String host) { + this.host = host; + return this; + } @Override public JexConfig port(int port) { @@ -28,14 +43,20 @@ public JexConfig port(int port) { } @Override - public JexConfig host(String host) { - this.host = host; + public JexConfig contextPath(String contextPath) { + if (!this.contextPath.equals(contextPath)) { + + this.contextPath = + contextPath + .transform(s -> s.charAt(0) == '/' ? s : "/" + s) + .transform(s -> s.endsWith("/") ? s.substring(0, s.lastIndexOf("/")) : s); + } return this; } @Override - public JexConfig contextPath(String contextPath) { - this.contextPath = contextPath; + public JexConfig socketBacklog(int socketBacklog) { + this.socketBacklog = socketBacklog; return this; } @@ -52,105 +73,130 @@ public JexConfig ignoreTrailingSlashes(boolean ignoreTrailingSlashes) { } @Override - public JexConfig preCompressStaticFiles(boolean preCompressStaticFiles) { - this.preCompressStaticFiles = preCompressStaticFiles; + public JexConfig jsonService(JsonService jsonService) { + this.jsonService = jsonService; return this; } @Override - public JexConfig jsonService(JsonService jsonService) { - this.jsonService = jsonService; + public JexConfig renderer(String extension, TemplateRender renderer) { + renderers.put(extension, renderer); return this; } @Override - public JexConfig accessManager(AccessManager accessManager) { - this.accessManager = accessManager; - return this; + public Executor executor() { + if (executor == null) { + executor = + Executors.newThreadPerTaskExecutor( + Thread.ofVirtual().name("avaje-jex-http-", 0).factory()); + } + return executor; } @Override - public JexConfig multipartConfig(UploadConfig multipartConfig) { - this.multipartConfig = multipartConfig; + public JexConfig executor(Executor executor) { + this.executor = executor; return this; } @Override - public JexConfig multipartFileThreshold(int multipartFileThreshold) { - this.multipartFileThreshold = multipartFileThreshold; - return this; + public String host() { + return host; } @Override - public JexConfig renderer(String extension, TemplateRender renderer) { - renderers.put(extension, renderer); - return this; + public int port() { + return port; } @Override - public boolean virtualThreads() { - return virtualThreads; + public String contextPath() { + return contextPath; } @Override - public JexConfig virtualThreads(boolean virtualThreads) { - this.virtualThreads = virtualThreads; - return this; + public int socketBacklog() { + return socketBacklog; } @Override - public int port() { - return port; + public boolean health() { + return health; } @Override - public String host() { - return host; + public boolean ignoreTrailingSlashes() { + return ignoreTrailingSlashes; } @Override - public String contextPath() { - return contextPath; + public JsonService jsonService() { + return jsonService; } @Override - public boolean health() { - return health; + public Map renderers() { + return renderers; } @Override - public boolean ignoreTrailingSlashes() { - return ignoreTrailingSlashes; + public String scheme() { + return httpsConfig == null ? "http" : "https"; } @Override - public boolean preCompressStaticFiles() { - return preCompressStaticFiles; + public HttpsConfigurator httpsConfig() { + return httpsConfig; } @Override - public JsonService jsonService() { - return jsonService; + public JexConfig httpsConfig(HttpsConfigurator httpsConfig) { + this.httpsConfig = httpsConfig; + return this; } @Override - public AccessManager accessManager() { - return accessManager; + public JexConfig compression(Consumer consumer) { + consumer.accept(compression); + return this; } @Override - public UploadConfig multipartConfig() { - return multipartConfig; + public CompressionConfig compression() { + return compression; } @Override - public int multipartFileThreshold() { - return multipartFileThreshold; + public long maxStreamBufferSize() { + return bufferMax; } @Override - public Map renderers() { - return renderers; + public int initialStreamBufferSize() { + return bufferInitial; + } + + @Override + public JexConfig initialStreamBufferSize(int initialSize) { + bufferInitial = initialSize; + return this; + } + + @Override + public JexConfig maxStreamBufferSize(long maxSize) { + bufferMax = maxSize; + return this; } + @Override + public HttpServerProvider serverProvider() { + return this.serverProvider != null ? serverProvider : HttpServerProvider.provider(); + } + + @Override + public JexConfig serverProvider(HttpServerProvider serverProvider) { + this.serverProvider = serverProvider; + return this; + } } diff --git a/avaje-jex/src/main/java/io/avaje/jex/DefaultErrorHandling.java b/avaje-jex/src/main/java/io/avaje/jex/DefaultErrorHandling.java deleted file mode 100644 index bbaeda6f..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/DefaultErrorHandling.java +++ /dev/null @@ -1,39 +0,0 @@ -package io.avaje.jex; - -import java.util.HashMap; -import java.util.Map; - -class DefaultErrorHandling implements ErrorHandling { - - private final Map, ExceptionHandler> handlers = new HashMap<>(); - - @Override - public ErrorHandling exception(Class type, ExceptionHandler handler) { - handlers.put(type, handler); - return this; - } - - @Override - public ErrorHandling error(int statusCode, Handler handler) { - return null; - } - - @Override - public ErrorHandling error(int statusCode, String contentType, Handler handler) { - return null; - } - - @SuppressWarnings("unchecked") - public ExceptionHandler find(Class exceptionType) { - Class type = exceptionType; - do { - final ExceptionHandler handler = handlers.get(type); - if (handler != null) { - return (ExceptionHandler) handler; - } - type = type.getSuperclass(); - } while (type != null); - return null; - } - -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/DefaultLifecycle.java b/avaje-jex/src/main/java/io/avaje/jex/DefaultLifecycle.java index f19622a2..ce0d6b4a 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/DefaultLifecycle.java +++ b/avaje-jex/src/main/java/io/avaje/jex/DefaultLifecycle.java @@ -1,7 +1,5 @@ package io.avaje.jex; -import io.avaje.applog.AppLog; - import java.lang.System.Logger.Level; import java.util.ArrayList; import java.util.Collections; @@ -12,14 +10,14 @@ final class DefaultLifecycle implements AppLifecycle { - private static final System.Logger log = AppLog.getLogger("io.avaje.jex"); + private static final System.Logger log = System.getLogger("io.avaje.jex"); private final List shutdownRunnable = new ArrayList<>(); private final ReentrantLock lock = new ReentrantLock(); private final AtomicInteger next = new AtomicInteger(1000); + private final AtomicBoolean jvmStop = new AtomicBoolean(); private Status status = Status.STARTING; private Hook shutdownHook; - private final AtomicBoolean jvmStop = new AtomicBoolean(); @Override public void onShutdown(Runnable onShutdown) { @@ -49,7 +47,7 @@ public void registerShutdownHook(Runnable onShutdown) { } } - static class Hook extends Thread { + static final class Hook extends Thread { private final AtomicBoolean jvmStop; Hook(Runnable runnable, AtomicBoolean jvmStop) { @@ -105,7 +103,7 @@ private void removeShutdownHook() { } } - static class Pair implements Comparable { + static final class Pair implements Comparable { private final Runnable callback; private final int order; diff --git a/avaje-jex/src/main/java/io/avaje/jex/DefaultRouting.java b/avaje-jex/src/main/java/io/avaje/jex/DefaultRouting.java index 25445250..b50ebffb 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/DefaultRouting.java +++ b/avaje-jex/src/main/java/io/avaje/jex/DefaultRouting.java @@ -1,80 +1,81 @@ package io.avaje.jex; -import java.util.*; - -/** - * - */ -class DefaultRouting implements Routing { +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import io.avaje.jex.http.ExceptionHandler; +import io.avaje.jex.http.ExchangeHandler; +import io.avaje.jex.http.HttpFilter; +import io.avaje.jex.security.Role; + +final class DefaultRouting implements Routing { private final List handlers = new ArrayList<>(); + private final List filters = new ArrayList<>(); private final Deque pathDeque = new ArrayDeque<>(); - /** - * Last entry that we can add permitted roles to. - */ - private Entry lastEntry; + private final Map, ExceptionHandler> exceptionHandlers = new HashMap<>(); @Override - public List all() { + public List handlers() { return handlers; } + @Override + public List filters() { + return filters; + } + + @Override + public Map, ExceptionHandler> errorHandlers() { + return exceptionHandlers; + } + private String path(String path) { - return String.join("", pathDeque) + ((path.startsWith("/") || path.isEmpty()) ? path : "/" + path); + return String.join("", pathDeque) + (path.charAt(0) == '/' || path.isEmpty() ? path : "/" + path); } - private void addEndpoints(String path, Group group) { - path = path.startsWith("/") ? path : "/" + path; + private void addEndpoints(String path, HttpService group) { + path = path.charAt(0) == '/' ? path : "/" + path; pathDeque.addLast(path); - group.addGroup(); + group.add(this); pathDeque.removeLast(); } @Override - public Routing add(Routing.Service routes) { + public Routing add(Routing.HttpService routes) { routes.add(this); return this; } @Override - public Routing addAll(Collection routes) { - for (Service route : routes) { + public Routing addAll(Collection routes) { + for (HttpService route : routes) { route.add(this); } return this; } @Override - public Routing path(String path, Group group) { - addEndpoints(path, group); + public Routing error(Class type, ExceptionHandler handler) { + exceptionHandlers.put(type, handler); return this; } @Override - public Routing withRoles(Set permittedRoles) { - if (lastEntry == null) { - throw new IllegalStateException("Must call withRoles() after adding a route"); - } - lastEntry.withRoles(permittedRoles); + public Routing group(String path, HttpService group) { + addEndpoints(path, group); return this; } - @Override - public Routing withRoles(Role... permittedRoles) { - return withRoles(Set.of(permittedRoles)); - } - - private void add(Type verb, String path, Handler handler) { - lastEntry = new Entry(verb, path(path), handler); - handlers.add(lastEntry); - } - - private void addBefore(String path, Handler handler) { - add(Type.BEFORE, path(path), handler); - } - - private void addAfter(String path, Handler handler) { - add(Type.AFTER, path(path), handler); + private void add(Type verb, String path, ExchangeHandler handler, Role... roles) { + var entry = new Entry(verb, path(path), handler, Set.of(roles)); + handlers.add(entry); } // ******************************************************************************************** @@ -82,227 +83,74 @@ private void addAfter(String path, Handler handler) { // ******************************************************************************************** @Override - public Routing get(String path, Handler handler) { - add(Type.GET, path, handler); - return this; - } - - @Override - public Routing get(Handler handler) { - get("", handler); - return this; - } - - @Override - public Routing post(String path, Handler handler) { - add(Type.POST, path, handler); - return this; - } - - @Override - public Routing post(Handler handler) { - post("", handler); + public Routing get(String path, ExchangeHandler handler, Role... roles) { + add(Type.GET, path, handler, roles); return this; } @Override - public Routing put(String path, Handler handler) { - add(Type.PUT, path, handler); + public Routing post(String path, ExchangeHandler handler, Role... roles) { + add(Type.POST, path, handler, roles); return this; } @Override - public Routing put(Handler handler) { - put("", handler); + public Routing put(String path, ExchangeHandler handler, Role... roles) { + add(Type.PUT, path, handler, roles); return this; } @Override - public Routing patch(String path, Handler handler) { - add(Type.PATCH, path, handler); + public Routing patch(String path, ExchangeHandler handler, Role... roles) { + add(Type.PATCH, path, handler, roles); return this; } @Override - public Routing patch(Handler handler) { - patch("", handler); + public Routing delete(String path, ExchangeHandler handler, Role... roles) { + add(Type.DELETE, path, handler, roles); return this; } @Override - public Routing delete(String path, Handler handler) { - add(Type.DELETE, path, handler); + public Routing head(String path, ExchangeHandler handler, Role... roles) { + add(Type.HEAD, path, handler, roles); return this; } @Override - public Routing delete(Handler handler) { - delete("", handler); + public Routing trace(String path, ExchangeHandler handler, Role... roles) { + add(Type.TRACE, path, handler, roles); return this; } @Override - public Routing head(String path, Handler handler) { - add(Type.HEAD, path, handler); - return this; - } - - @Override - public Routing head(Handler handler) { - head("", handler); - return this; - } - - @Override - public Routing trace(String path, Handler handler) { - add(Type.TRACE, path, handler); - return this; - } - - @Override - public Routing trace(Handler handler) { - trace("", handler); + public Routing options(String path, ExchangeHandler handler, Role... roles) { + add(Type.OPTIONS, path, handler, roles); return this; } // ******************************************************************************************** - // Before/after handlers (filters) + // Filters // ******************************************************************************************** @Override - public Routing before(String path, Handler handler) { - addBefore(path, handler); - return this; - } - - @Override - public Routing before(Handler handler) { - before("/*", handler); - return this; - } - - @Override - public Routing after(String path, Handler handler) { - addAfter(path, handler); - return this; - } - - @Override - public Routing after(Handler handler) { - after("/*", handler); + public Routing filter(HttpFilter handler) { + filters.add(handler); return this; } - // ******************************************************************************************** - // WebSocket - // ******************************************************************************************** -// -// /** -// * Adds a WebSocket handler on the specified path. -// * The method can only be called inside a {@link Javalin#routes(EndpointGroup)}. -// * -// * @see WebSockets in docs -// */ -// public static void ws(String path, Consumer ws) { -// me().ws(prefixPath(path), ws); -// } -// -// /** -// * Adds a WebSocket handler with the given roles for the specified path. -// * The method can only be called inside a {@link Javalin#routes(EndpointGroup)}. -// * -// * @see WebSockets in docs -// */ -// public static void ws(String path, Consumer ws, Set permittedRoles) { -// me().ws(prefixPath(path), ws, permittedRoles); -// } -// -// /** -// * Adds a WebSocket handler on the current path. -// * The method can only be called inside a {@link Javalin#routes(EndpointGroup)}. -// * -// * @see WebSockets in docs -// */ -// public static void ws(Consumer ws) { -// me().ws(prefixPath(""), ws); -// } -// -// /** -// * Adds a WebSocket handler with the given roles for the current path. -// * The method can only be called inside a {@link Javalin#routes(EndpointGroup)}. -// * -// * @see WebSockets in docs -// */ -// public static void ws(Consumer ws, Set permittedRoles) { -// me().ws(prefixPath(""), ws, permittedRoles); -// } -// -// /** -// * Adds a WebSocket before handler for the specified path to the {@link Javalin} instance. -// * The method can only be called inside a {@link Javalin#routes(EndpointGroup)}. -// */ -// public Javalin wsBefore(String path, Consumer wsHandler) { -// return me().wsBefore(prefixPath(path), wsHandler); -// } -// -// /** -// * Adds a WebSocket before handler for the current path to the {@link Javalin} instance. -// * The method can only be called inside a {@link Javalin#routes(EndpointGroup)}. -// */ -// public Javalin wsBefore(Consumer wsHandler) { -// return me().wsBefore(prefixPath("/*"), wsHandler); -// } -// -// /** -// * Adds a WebSocket after handler for the specified path to the {@link Javalin} instance. -// * The method can only be called inside a {@link Javalin#routes(EndpointGroup)}. -// */ -// public Javalin wsAfter(String path, Consumer wsHandler) { -// return me().wsAfter(prefixPath(path), wsHandler); -// } -// -// /** -// * Adds a WebSocket after handler for the current path to the {@link Javalin} instance. -// * The method can only be called inside a {@link Javalin#routes(EndpointGroup)}. -// */ -// public Javalin wsAfter(Consumer wsHandler) { -// return me().wsAfter(prefixPath("/*"), wsHandler); -// } -// -// // ******************************************************************************************** -// // Server-sent events -// // ******************************************************************************************** -// -// public static void sse(String path, Consumer client) { -// me().sse(prefixPath(path), client); -// } -// -// public static void sse(String path, Consumer client, Set permittedRoles) { -// me().sse(prefixPath(path), client, permittedRoles); -// } -// -// public static void sse(Consumer client) { -// me().sse(prefixPath(""), client); -// } -// -// public static void sse(Consumer client, Set permittedRoles) { -// me().sse(prefixPath(""), client, permittedRoles); -// } - - private static class Entry implements Routing.Entry { + private static final class Entry implements Routing.Entry { private final Type type; private final String path; - private final Handler handler; - private Set roles = Collections.emptySet(); + private final ExchangeHandler handler; + private final Set roles; - Entry(Type type, String path, Handler handler) { + Entry(Type type, String path, ExchangeHandler handler, Set roles) { this.type = type; this.path = path; this.handler = handler; - } - - void withRoles(Set roles) { this.roles = roles; } @@ -317,7 +165,7 @@ public String getPath() { } @Override - public Handler getHandler() { + public ExchangeHandler getHandler() { return handler; } diff --git a/avaje-jex/src/main/java/io/avaje/jex/DefaultStaticFileConfig.java b/avaje-jex/src/main/java/io/avaje/jex/DefaultStaticFileConfig.java deleted file mode 100644 index 9e332a6d..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/DefaultStaticFileConfig.java +++ /dev/null @@ -1,42 +0,0 @@ -package io.avaje.jex; - -import java.util.ArrayList; -import java.util.List; - -class DefaultStaticFileConfig implements StaticFileConfig { - - private final List sources = new ArrayList<>(); - private final Jex jex; - - DefaultStaticFileConfig(Jex jex) { - this.jex = jex; - } - - @Override - public Jex addClasspath(String path) { - return addClasspath("/", path); - } - - @Override - public Jex addClasspath(String urlPrefix, String path) { - sources.add(new StaticFileSource(urlPrefix, path, StaticFileSource.Location.CLASSPATH)); - return jex; - } - - @Override - public Jex addExternal(String path) { - return addExternal("/", path); - } - - @Override - public Jex addExternal(String urlPrefix, String path) { - sources.add(new StaticFileSource(urlPrefix, path, StaticFileSource.Location.EXTERNAL)); - return jex; - } - - @Override - public List getSources() { - return sources; - } - -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/ErrorHandling.java b/avaje-jex/src/main/java/io/avaje/jex/ErrorHandling.java deleted file mode 100644 index 0df47c9c..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/ErrorHandling.java +++ /dev/null @@ -1,42 +0,0 @@ -package io.avaje.jex; - -public interface ErrorHandling { - - /** - * Register an exception handler for the given exception type. - */ - ErrorHandling exception(Class exceptionClass, ExceptionHandler handler); - - /** - * Adds an error mapper to the instance. - * Useful for turning error-codes (404, 500) into standardized messages/pages - */ - ErrorHandling error(int statusCode, Handler handler); - - /** - * Adds an error mapper for the specified content-type to the instance. - * Useful for turning error-codes (404, 500) into standardized messages/pages - */ - ErrorHandling error(int statusCode, String contentType, Handler handler); - - /** - * Return a registered exception handler given the exception type or null - * if one is not found. - *

- * This includes searching the super types of the exception. - *

- */ - ExceptionHandler find(Class exceptionType); - - /** - * Adds to the Routing. - */ - @FunctionalInterface - interface Service { - - /** - * Add to the error handling. - */ - void add(ErrorHandling errorHandling); - } -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/ExceptionHandler.java b/avaje-jex/src/main/java/io/avaje/jex/ExceptionHandler.java deleted file mode 100644 index 1bd9666e..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/ExceptionHandler.java +++ /dev/null @@ -1,7 +0,0 @@ -package io.avaje.jex; - -public interface ExceptionHandler { - - void handle(T exception, Context ctx); - -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/Handler.java b/avaje-jex/src/main/java/io/avaje/jex/Handler.java deleted file mode 100644 index e23816d2..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/Handler.java +++ /dev/null @@ -1,7 +0,0 @@ -package io.avaje.jex; - -@FunctionalInterface -public interface Handler { - - void handle(Context ctx); -} 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 0aef346b..caa06ac8 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/Jex.java +++ b/avaje-jex/src/main/java/io/avaje/jex/Jex.java @@ -1,24 +1,32 @@ package io.avaje.jex; -import io.avaje.inject.BeanScope; -import io.avaje.jex.spi.JsonService; - import java.util.Collection; import java.util.function.Consumer; +import io.avaje.inject.BeanScope; +import io.avaje.jex.Routing.HttpService; +import io.avaje.jex.http.Context; +import io.avaje.jex.http.ExceptionHandler; +import io.avaje.jex.http.ExchangeHandler; +import io.avaje.jex.http.HttpFilter; +import io.avaje.jex.http.sse.SseClient; +import io.avaje.jex.security.Role; +import io.avaje.jex.spi.JexPlugin; +import io.avaje.jex.spi.JsonService; +import io.avaje.jex.spi.TemplateRender; + /** * Create configure and start Jex. * *
{@code
+ * final Jex.Server app = Jex.create()
+ *   .routing(routing -> routing
+ *     .get("/", ctx -> ctx.text("hello world"))
+ *     .get("/one", ctx -> ctx.text("one"))
+ *   .port(8080)
+ *   .start();
  *
- *     final Jex.Server app = Jex.create()
- *       .routing(routing -> routing
- *         .get("/", ctx -> ctx.text("hello world"))
- *         .get("/one", ctx -> ctx.text("one"))
- *       .port(8080)
- *       .start();
- *
- *     app.shutdown();
+ * app.shutdown();
  *
  * }
*/ @@ -29,14 +37,14 @@ public interface Jex { * *
{@code
    *
-   *     final Jex.Server app = Jex.create()
-   *       .routing(routing -> routing
-   *         .get("/", ctx -> ctx.text("hello world"))
-   *         .get("/one", ctx -> ctx.text("one"))
-   *       .port(8080)
-   *       .start();
+   * final Jex.Server app = Jex.create()
+   *   .routing(routing -> routing
+   *     .get("/", ctx -> ctx.text("hello world"))
+   *     .get("/one", ctx -> ctx.text("one"))
+   *   .port(8080)
+   *   .start();
    *
-   *     app.shutdown();
+   * app.shutdown();
    *
    * }
*/ @@ -45,151 +53,250 @@ static Jex create() { } /** - * Set a custom attribute that can be used by an implementation. + * Adds a new HTTP route and its associated handler to the Jex routing configuration. + * + * @param routes The HTTP service to add. */ - Jex attribute(Class cls, T instance); + Jex routing(Routing.HttpService routes); /** - * Return a custom attribute. + * Adds multiple HTTP routes and their associated handlers to the Jex routing configuration. + * + * @param routes A collection of HTTP services to add. */ - T attribute(Class cls); + Jex routing(Collection routes); /** - * Configure error handlers. + * Returns the routing configuration object, allowing for further customization. + * + * @return The routing configuration object. */ - Jex errorHandling(ErrorHandling.Service service); + Routing routing(); /** - * Return the Error handler to add error handlers. + * Adds a GET handler to the route configuration. + * + * @param path The path pattern to match the request URI. + * @param handler The handler to invoke when a GET request matches the path. + * @param roles An array of roles that are associated with this endpoint. */ - ErrorHandling errorHandling(); + default Jex get(String path, ExchangeHandler handler, Role... roles) { + routing().get(path, handler, roles); + return this; + } /** - * Return the server specific configuration. + * Adds a POST handler to the route configuration. + * + * @param path The path pattern to match the request URI. + * @param handler The handler to invoke when a POST request matches the path. + * @param roles An array of roles that are associated with this endpoint. */ - ServerConfig serverConfig(); + default Jex post(String path, ExchangeHandler handler, Role... roles) { + routing().post(path, handler, roles); + return this; + } /** - * Set the server specific configuration. + * Adds a PUT handler to the route configuration. + * + * @param path The path pattern to match the request URI. + * @param handler The handler to invoke when a PUT request matches the path. + * @param roles An array of roles that are associated with this endpoint. */ - Jex serverConfig(ServerConfig serverConfig); + default Jex put(String path, ExchangeHandler handler, Role... roles) { + routing().put(path, handler, roles); + return this; + } /** - * Add routes and handlers to the routing. + * Adds a PATCH handler to the route configuration. + * + * @param path The path pattern to match the request URI. + * @param handler The handler to invoke when a PATCH request matches the path. + * @param roles An array of roles that are associated with this endpoint. */ - Jex routing(Routing.Service routes); + default Jex patch(String path, ExchangeHandler handler, Role... roles) { + routing().patch(path, handler, roles); + return this; + } /** - * Add many routes and handlers to the routing. + * Adds a DELETE handler to the route configuration. + * + * @param path The path pattern to match the request URI. + * @param handler The handler to invoke when a DELETE request matches the path. + * @param roles An array of roles that are associated with this endpoint. */ - Jex routing(Collection routes); + default Jex delete(String path, ExchangeHandler handler, Role... roles) { + routing().delete(path, handler, roles); + return this; + } /** - * Return the Routing to configure. + * Adds an OPTIONS handler to the route configuration. + * + * @param path The path pattern to match the request URI. + * @param handler The handler to invoke when an OPTIONS request matches the path. + * @param roles An array of roles that are associated with this endpoint. */ - Routing routing(); + default Jex options(String path, ExchangeHandler handler, Role... roles) { + routing().options(path, handler, roles); + return this; + } /** - * Set the AccessManager. + * Adds an SSE handler to the route configuration. + * + * @param path The path pattern to match the request URI. + * @param handler The sse handler to invoke when a GET request matches the path. + * @param roles An array of roles that are associated with this endpoint. */ - Jex accessManager(AccessManager accessManager); + default Jex sse(String path, Consumer handler, Role... roles) { + return get(path, SseClient.handler(handler), roles); + } + + /** Add a filter for all matched requests. */ + default Jex filter(HttpFilter handler) { + routing().filter(handler); + return this; + } + + /** Add a pre-processing filter for all matched requests. */ + default Jex before(Consumer handler) { + routing().before(handler); + return this; + } + + /** Add a post-processing filter for all matched requests. */ + default Jex after(Consumer handler) { + routing().after(handler); + return this; + } /** - * Set the JsonService. + * Registers an exception handler that handles the given type of exceptions. This will replace an + * existing error handler for the same exception class. + * + * @param exceptionClass the type of exception to handle by this handler + * @param handler the error handler + * @param exception type */ - Jex jsonService(JsonService jsonService); + default Jex error(Class exceptionClass, ExceptionHandler handler) { + routing().error(exceptionClass, handler); + return this; + } /** - * Add Plugin functionality. + * Add a group of route handlers with a common path prefix. + * + *
{@code
+   * routing.path("api", g -> {
+   *     g.get("/", ctx -> ctx.text("apiRoot"));
+   *     g.get("{id}", ctx -> ctx.text("api-" + ctx.pathParam("id")));
+   * });
+   *
+   * }
+ * + * @param path the common path prefix + * @param group the function to register the rout handlers + * */ - Jex plugin(Plugin plugin); + default Jex group(String path, HttpService group) { + routing().group(path, group); + return this; + } /** - * Configure given the dependency injection scope from avaje-inject. + * Sets the JSON service to use for serialization and deserialization. * - * @param beanScope The scope potentially containing Handlers, AccessManager, Plugins etc. + * @param jsonService The JSON service to use. */ - Jex configureWith(BeanScope beanScope); + Jex jsonService(JsonService jsonService); /** - * Configure via a lambda taking the JexConfig instance. + * Adds a plugin to the Jex instance, extending its functionality. + * + * @param plugin The plugin to add. */ - Jex configure(Consumer configure); + Jex plugin(JexPlugin plugin); /** - * Add an exception handler for the given exception type. + * Configures the Jex instance using a dependency injection scope from Avaje-Inject. + * + *

This method allows you to leverage the Avaje-Inject framework to provide dependencies like + * Handlers, StaticResources, and Plugins to the Jex instance. + * + * @param beanScope The Avaje-Inject BeanScope containing the dependencies. + * @return The configured Jex instance. */ - Jex exception(Class exceptionClass, ExceptionHandler handler); + Jex configureWith(BeanScope beanScope); /** - * Set the port to use. + * Configures the Jex instance using a functional approach. + * + *

The provided consumer lambda allows you to customize the Jex configuration, such as setting + * the port, compression, and other options. + * + * @param configure A consumer lambda that accepts a {@link JexConfig} instance for configuration. + * @return The configured Jex instance. */ - Jex port(int port); + Jex config(Consumer configure); /** - * Set the context path. + * Sets the port number on which the Jex server will listen for incoming requests. + * + *

The default value is 8080. If The port is set to 0, the server will randomly choose an available port. + * + * @param port The port number to use. */ - Jex context(String contextPath); + Jex port(int port); /** - * Return the static file configuration. + * Sets the context path for the Jex application. + * + *

The context path is the portion of the URL that identifies the application. + * + * @param contextPath The context path to use. + * @return The updated Jex instance. */ - StaticFileConfig staticFiles(); + Jex contextPath(String contextPath); /** * Explicitly register a template renderer. - *

- * Note that if not explicitly registered TemplateRender's can be - * automatically registered via ServiceLoader just by including them - * to the class path. * - * @param renderer The template renderer to register + *

Note that if not explicitly registered TemplateRender's can be automatically registered via + * ServiceLoader just by including them to the class path. + * + * @param renderer The template renderer to register * @param extensions The extensions the renderer is used for */ Jex register(TemplateRender renderer, String... extensions); - /** - * Return the application lifecycle support. - */ + /** Return the application lifecycle support. */ AppLifecycle lifecycle(); - /** - * Return the configuration. - */ + /** Return the configuration. */ JexConfig config(); - /** - * Start the server. - */ + /** Start the server. */ Jex.Server start(); - /** - * The running server. - */ + /** The running server. */ interface Server { /** - * Register a function to execute LAST on shutdown after all the - * normal lifecycle shutdown functions have run. - *

- * Typically, we desire to shut down logging (e.g. Log4J) last. + * Register a function to execute LAST on shutdown after all the normal lifecycle shutdown + * functions have run. + * + *

Typically, we desire to shut down logging (e.g. Log4J) last. */ void onShutdown(Runnable onShutdown); - /** - * Shutdown the server. - */ + /** Shutdown the server. */ void shutdown(); - /** - * Return the port the server is using. - */ - default int port() { - throw new IllegalStateException("not supported"); - } - - default void restart() { - throw new IllegalStateException("not supported"); - } + /** The port of the server */ + int port(); } } diff --git a/avaje-jex/src/main/java/io/avaje/jex/JexConfig.java b/avaje-jex/src/main/java/io/avaje/jex/JexConfig.java index fe6b6914..6db7bf13 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/JexConfig.java +++ b/avaje-jex/src/main/java/io/avaje/jex/JexConfig.java @@ -1,135 +1,189 @@ package io.avaje.jex; -import io.avaje.jex.spi.JsonService; - import java.util.Map; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.function.Consumer; + +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.spi.HttpServerProvider; + +import io.avaje.jex.compression.CompressionConfig; +import io.avaje.jex.spi.JsonService; +import io.avaje.jex.spi.TemplateRender; /** - * Jex configuration. + * Jex configuration interface. + * + *

Provides a fluent API for configuring Jex's various settings, including port, host, health + * endpoint, trailing slash handling, JSON service, template renderers, executor service, HTTPS + * configuration, compression, and plugin loading. */ public interface JexConfig { - /** - * Set the port to use. Defaults to 7001. - */ - JexConfig port(int port); + /** Returns the configured compression settings. */ + CompressionConfig compression(); /** - * Set the host to bind to. + * Configures compression settings using a consumer function. + * + * @param consumer The consumer function to configure compression settings. + * @return The updated configuration. */ - JexConfig host(String host); + JexConfig compression(Consumer consumer); + + /** Return the contextPath. (Defaults to "/") */ + String contextPath(); /** - * Set the contextPath. + * Set the contextPath passed to the underlying HttpServer. (defaults to "/") + * + * @param contextPath The context path */ JexConfig contextPath(String contextPath); /** - * Set to true to include the health endpoint. Defaults to true. + * Executor for serving requests. Defaults to a {@link + * Executors#newVirtualThreadPerTaskExecutor()} */ - JexConfig health(boolean health); + Executor executor(); /** - * Set to true to ignore trailing slashes. Defaults to true. + * Sets the executor service used to handle incoming requests. + * + * @param executor The executor service. */ - JexConfig ignoreTrailingSlashes(boolean ignoreTrailingSlashes); + JexConfig executor(Executor executor); - /** - * Set to true to pre compress static files. Defaults to false. - */ - JexConfig preCompressStaticFiles(boolean preCompressStaticFiles); + /** Returns whether the health endpoint is enabled. */ + boolean health(); /** - * Set the JsonService to use. + * Enables/Disables the default health endpoint. + * + * @param health whether to enable/disable. */ - JexConfig jsonService(JsonService jsonService); + JexConfig health(boolean health); - /** - * Set the AccessManager to use. - */ - JexConfig accessManager(AccessManager accessManager); + /** Returns the configured host. */ + String host(); /** - * Set the upload configuration. + * Set the host on which the HttpServer will bind to. Defaults to any local address. + * + * @param host The host. */ - JexConfig multipartConfig(UploadConfig multipartConfig); + JexConfig host(String host); - /** - * Set the multipartFileThreshold. - */ - JexConfig multipartFileThreshold(int multipartFileThreshold); + /** Return the {@link HttpsConfigurator} if https is enabled. */ + HttpsConfigurator httpsConfig(); /** - * Register a template renderer explicitly. + * Enable https with the provided {@link HttpsConfigurator} * - * @param extension The extension the renderer applies to. - * @param renderer The template render to use for the given extension. + * @param https The HTTPS configuration. */ - JexConfig renderer(String extension, TemplateRender renderer); + JexConfig httpsConfig(HttpsConfigurator https); - /** - * Set to true to use virtual threads if supported. Defaults to false. - */ - JexConfig virtualThreads(boolean virtualThreads); + /** Returns whether trailing slashes in request URIs are ignored. */ + boolean ignoreTrailingSlashes(); /** - * Return true if virtual threads should be used. + * Configures whether trailing slashes in request URIs should be ignored. + * + * @param ignoreTrailingSlashes whether to enable/disable trailing slashes. */ - boolean virtualThreads(); + JexConfig ignoreTrailingSlashes(boolean ignoreTrailingSlashes); - /** - * Return the port to use. - */ - int port(); + /** The initial size of the response buffer */ + int initialStreamBufferSize(); /** - * Return the host to bind to. + * Set the initial size of the response stream buffer. If exceeded, the buffer will expand until + * it reaches the maximum configured size + * + *

Defaults to 256 + * + * @param initialSize The initial size of the response buffer */ - String host(); + JexConfig initialStreamBufferSize(int initialSize); - /** - * Return the contextPath to use. - */ - String contextPath(); + /** Returns the configured JSON service. */ + JsonService jsonService(); /** - * Return true to include the health endpoint. + * Sets the JSON service used for (de)serialization. + * + * @param jsonService The json service instance. */ - boolean health(); + JexConfig jsonService(JsonService jsonService); - /** - * Return true to ignore trailing slashes. - */ - boolean ignoreTrailingSlashes(); + /** the maximum size of the response stream buffer. */ + long maxStreamBufferSize(); /** - * Return true if static files should be pre compressed. + * Set the maximum size of the response stream buffer. If the response data exceeds this size, it + * will be written to the client using chunked transfer encoding. Otherwise, the response will be + * sent using a Content-Length header with the exact size of the response data. + * + *

Defaults to 4096 + * + * @param maxSize The maximum size of the response */ - boolean preCompressStaticFiles(); + JexConfig maxStreamBufferSize(long maxSize); + + /** Returns the configured port number. (Defaults to 8080 if not set) */ + int port(); /** - * Return the JsonService. + * Sets the port number on which the HttpServer will listen for incoming requests. * + * + *

The default value is 8080. If The port is set to 0, the server will randomly choose an + * available port. + * + * @param port The port number. */ - JsonService jsonService(); + JexConfig port(int port); /** - * Return the access manager. + * Registers a template renderer for a specific file extension. + * + * @param extension The file extension. + * @param renderer The template renderer implementation. */ - AccessManager accessManager(); + JexConfig renderer(String extension, TemplateRender renderer); + + /** Returns a map of registered template renderers, keyed by file extension. */ + Map renderers(); + + /** Return the schema as http or https. */ + String scheme(); /** - * Return the multipartConfig. + * Provide the provider used to create the {@link HttpServer} instance. If not set, {@link + * HttpServerProvider#provider()} will be used to create the server */ - UploadConfig multipartConfig(); + HttpServerProvider serverProvider(); /** - * Return the multipartFileThreshold. + * Configure Provider used to created {@link HttpServer} instances. If not set, {@link + * HttpServerProvider#provider()} will be used to create the server. + * + * @param serverProvider provider used to create the server */ - int multipartFileThreshold(); + JexConfig serverProvider(HttpServerProvider serverProvider); + + /** Return the socket backlog. */ + int socketBacklog(); /** - * Return the template renderers registered by extension. + * Set the socket backlog. If this value is less than or equal to zero, then a system default + * value is used + * + * @param backlog the socket backlog. If this value is less than or equal to zero, then a system + * default value is used */ - Map renderers(); + JexConfig socketBacklog(int backlog); } diff --git a/avaje-jex/src/main/java/io/avaje/jex/Plugin.java b/avaje-jex/src/main/java/io/avaje/jex/Plugin.java deleted file mode 100644 index a9feb473..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/Plugin.java +++ /dev/null @@ -1,12 +0,0 @@ -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/Role.java b/avaje-jex/src/main/java/io/avaje/jex/Role.java deleted file mode 100644 index 9906821d..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/Role.java +++ /dev/null @@ -1,4 +0,0 @@ -package io.avaje.jex; - -public interface Role { -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/Routing.java b/avaje-jex/src/main/java/io/avaje/jex/Routing.java index f7daf58e..19d1870b 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/Routing.java +++ b/avaje-jex/src/main/java/io/avaje/jex/Routing.java @@ -2,167 +2,176 @@ import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.function.Consumer; +import com.sun.net.httpserver.Filter; + +import io.avaje.jex.http.Context; +import io.avaje.jex.http.ExceptionHandler; +import io.avaje.jex.http.ExchangeHandler; +import io.avaje.jex.http.HttpFilter; +import io.avaje.jex.http.sse.SseClient; +import io.avaje.jex.security.Role; + +/** Routing abstraction. */ public interface Routing { - /** - * Add the routes provided by the Routing Service. - */ - Routing add(Routing.Service routes); + /** Add the routes provided by the given HttpService. */ + Routing add(Routing.HttpService service); - /** - * Add all the routes provided by the Routing Services. - */ - Routing addAll(Collection routes); + /** Add all the routes provided by the Routing Services. */ + Routing addAll(Collection routes); /** - * Specify permittedRoles for the last added handler. - *

{@code
-   *
-   *  routing
-   *  .get("/customers", getHandler).withRoles(readRoles)
-   *  .post("/customers", postHandler).withRoles(writeRoles)
-   *  ...
-   *
-   * }
+ * Registers an exception handler that handles the given type of exceptions. This will replace an + * existing error handler for the same exception class. * - * @param permittedRoles The permitted roles required for the last handler + * @param exceptionClass the type of exception to handle by this handler + * @param handler the error handler + * @param exception type */ - Routing withRoles(Set permittedRoles); + Routing error(Class exceptionClass, ExceptionHandler handler); /** - * Specify permittedRoles for the last added handler using varargs. - *
{@code
+   * Add a group of route handlers with a common path prefix.
    *
-   *  routing
-   *  .get("/customers", getHandler).withRoles(ADMIN, USER)
-   *  .post("/customers", postHandler).withRoles(ADMIN)
-   *  ...
+   * 
{@code
+   * routing.path("api", g -> {
+   *     g.get("/", ctx -> ctx.text("apiRoot"));
+   *     g.get("{id}", ctx -> ctx.text("api-" + ctx.pathParam("id")));
+   * });
    *
    * }
* - * @param permittedRoles The permitted roles required for the last handler - */ - Routing withRoles(Role... permittedRoles); - - /** - * Add a group of route handlers with a common path prefix. - */ - Routing path(String path, Group group); - - /** - * Add a HEAD handler. - */ - Routing head(String path, Handler handler); - - /** - * Add a HEAD handler for "/". - */ - Routing head(Handler handler); - - /** - * Add a GET handler. - */ - Routing get(String path, Handler handler); - - /** - * Add a GET handler for "/". - */ - Routing get(Handler handler); - - /** - * Add a POST handler. + * @param path the common path prefix + * @param group the function to register the rout handlers + * */ - Routing post(String path, Handler handler); + Routing group(String path, HttpService group); /** - * Add a POST handler for "/". + * Adds a HEAD handler to the route configuration. + * + * @param path The path pattern to match the request URI. + * @param handler The handler to invoke when a HEAD request matches the path. + * @param roles roles that are associated with this endpoint. */ - Routing post(Handler handler); + Routing head(String path, ExchangeHandler handler, Role... roles); /** - * Add a PUT handler. + * Adds a GET handler to the route configuration. + * + * @param path The path pattern to match the request URI. + * @param handler The handler to invoke when a GET request matches the path. + * @param roles roles that are associated with this endpoint. */ - Routing put(String path, Handler handler); + Routing get(String path, ExchangeHandler handler, Role... roles); /** - * Add a PUT handler for "/". + * Adds a POST handler to the route configuration. + * + * @param path The path pattern to match the request URI. + * @param handler The handler to invoke when a POST request matches the path. + * @param roles roles that are associated with this endpoint. */ - Routing put(Handler handler); + Routing post(String path, ExchangeHandler handler, Role... roles); /** - * Add a PATCH handler. + * Adds a PUT handler to the route configuration. + * + * @param path The path pattern to match the request URI. + * @param handler The handler to invoke when a PUT request matches the path. + * @param roles roles that are associated with this endpoint. */ - Routing patch(String path, Handler handler); + Routing put(String path, ExchangeHandler handler, Role... roles); /** - * Add a PATCH handler for "/". + * Adds a PATCH handler to the route configuration. + * + * @param path The path pattern to match the request URI. + * @param handler The handler to invoke when a PATCH request matches the path. + * @param roles roles that are associated with this endpoint. */ - Routing patch(Handler handler); + Routing patch(String path, ExchangeHandler handler, Role... roles); /** - * Add a DELETE handler. + * Adds a DELETE handler to the route configuration. + * + * @param path The path pattern to match the request URI. + * @param handler The handler to invoke when a DELETE request matches the path. + * @param roles roles that are associated with this endpoint. */ - Routing delete(String path, Handler handler); + Routing delete(String path, ExchangeHandler handler, Role... roles); /** - * Add a DELETE handler for "/". + * Adds a TRACE handler to the route configuration. + * + * @param path The path pattern to match the request URI. + * @param handler The handler to invoke when a TRACE request matches the path. + * @param roles roles that are associated with this endpoint. */ - Routing delete(Handler handler); + Routing trace(String path, ExchangeHandler handler, Role... roles); /** - * Add a TRACE handler. + * Adds an OPTIONS handler to the route configuration. + * + * @param path The path pattern to match the request URI. + * @param handler The handler to invoke when an OPTIONS request matches the path. + * @param roles roles that are associated with this endpoint. */ - Routing trace(String path, Handler handler); + Routing options(String path, ExchangeHandler handler, Role... roles); - /** - * Add a TRACE handler for "/". - */ - Routing trace(Handler handler); + /** Add a filter for all matched requests. */ + Routing filter(HttpFilter handler); - /** - * Add a before filter for the given path. - */ - Routing before(String path, Handler handler); + /** Add a filter for all matched requests. */ + default Routing filter(Filter handler) { + return filter(HttpFilter.fromJdkFilter(handler)); + } - /** - * Add a before filter for all requests. - */ - Routing before(Handler handler); + /** Add a pre-processing filter for all matched requests. */ + default Routing before(Consumer handler) { + return filter( + (ctx, chain) -> { + handler.accept(ctx); + chain.proceed(); + }); + } - /** - * Add a after filter for the given path. - */ - Routing after(String path, Handler handler); + /** Add a post-processing filter for all matched requests. */ + default Routing after(Consumer handler) { + return filter( + (ctx, chain) -> { + chain.proceed(); + handler.accept(ctx); + }); + } /** - * Add an after filter for all requests. + * Adds an SSE handler to the route configuration. + * + * @param path The path pattern to match the request URI. + * @param handler The sse handler to invoke when a GET request matches the path. + * @param roles An array of roles that are associated with this endpoint. */ - Routing after(Handler handler); + default Routing sse(String path, Consumer handler, Role... roles) { + return get(path, SseClient.handler(handler), roles); + } - /** - * Return all the registered handlers. - */ - List all(); + /** Return all the registered handlers. */ + List handlers(); - /** - * A group of routing entries prefixed by a common path. - */ - @FunctionalInterface - interface Group { + /** Return all the registered filters. */ + List filters(); - /** - * Add the group of entries with a common prefix. - */ - void addGroup(); - } + /** Return all the registered Exception Handlers. */ + Map, ExceptionHandler> errorHandlers(); - /** - * Adds to the Routing. - */ + /** Adds to the Routing. */ @FunctionalInterface - interface Service { + interface HttpService { /** * Add to the routing. @@ -172,71 +181,31 @@ interface Service { void add(Routing routing); } - /** - * A routing entry. - */ + /** A routing entry. */ interface Entry { - /** - * Return the type of entry. - */ + /** Return the type of entry. */ Type getType(); - /** - * Return the full path of the entry. - */ + /** Return the full path of the entry. */ String getPath(); - /** - * Return the handler. - */ - Handler getHandler(); + /** Return the handler. */ + ExchangeHandler getHandler(); - /** - * Return the roles. - */ + /** Return the roles. */ Set getRoles(); } - /** - * The type of route entry. - */ + /** The type of route entry. */ enum Type { - /** - * Before filter. - */ - BEFORE, - /** - * After filter. - */ - AFTER, - /** - * Http GET. - */ GET, - /** - * Http POST. - */ POST, - /** - * HTTP PUT. - */ PUT, - /** - * HTTP PATCH. - */ PATCH, - /** - * HTTP DELETE. - */ DELETE, - /** - * HTTP HEAD. - */ HEAD, - /** - * HTTP TRACE. - */ - TRACE//, CONNECT, OPTIONS, INVALID; + TRACE, + OPTIONS; } } diff --git a/avaje-jex/src/main/java/io/avaje/jex/ServerConfig.java b/avaje-jex/src/main/java/io/avaje/jex/ServerConfig.java deleted file mode 100644 index 7f860f57..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/ServerConfig.java +++ /dev/null @@ -1,7 +0,0 @@ -package io.avaje.jex; - -/** - * Marker for server specific configuration. - */ -public interface ServerConfig { -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/StaticFileConfig.java b/avaje-jex/src/main/java/io/avaje/jex/StaticFileConfig.java deleted file mode 100644 index 8138e0bb..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/StaticFileConfig.java +++ /dev/null @@ -1,16 +0,0 @@ -package io.avaje.jex; - -import java.util.List; - -public interface StaticFileConfig { - - Jex addClasspath(String path); - - Jex addClasspath(String urlPrefix, String path); - - Jex addExternal(String path); - - Jex addExternal(String urlPrefix, String path); - - List getSources(); -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/StaticFileSource.java b/avaje-jex/src/main/java/io/avaje/jex/StaticFileSource.java deleted file mode 100644 index 07ba465e..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/StaticFileSource.java +++ /dev/null @@ -1,55 +0,0 @@ -package io.avaje.jex; - -import java.util.Objects; - -public class StaticFileSource { - - public enum Location { - CLASSPATH, EXTERNAL - } - - private final String urlPathPrefix; - private final String path; - private final Location location; - - public StaticFileSource(String urlPathPrefix, String path, Location location) { - this.urlPathPrefix = urlPathPrefix; - this.path = path; - this.location = location; - } - - public String getUrlPathPrefix() { - return urlPathPrefix; - } - - public String getPath() { - return path; - } - - public Location getLocation() { - return location; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - StaticFileSource that = (StaticFileSource) o; - return urlPathPrefix.equals(that.urlPathPrefix) && - path.equals(that.path) && - location == that.location; - } - - @Override - public int hashCode() { - return Objects.hash(urlPathPrefix, path, location); - } - - @Override - public String toString() { - return "urlPathPrefix: " + urlPathPrefix - + ", path: " + path - + ", location: " + location; - } - -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/TemplateRender.java b/avaje-jex/src/main/java/io/avaje/jex/TemplateRender.java deleted file mode 100644 index c8219042..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/TemplateRender.java +++ /dev/null @@ -1,27 +0,0 @@ -package io.avaje.jex; - -import java.util.Map; - -/** - * Template rendering typically of html. - */ -public interface TemplateRender { - - /** - * Return the extensions this template renders for by default. - *

- * When the template render is not explicitly registered it can be - * automatically registered via ServiceLoader and these are the extensions - * it will register for by default. - */ - String[] defaultExtensions(); - - /** - * Render the template and model typically as html to the given context. - * - * @param context The context to render the template to - * @param name The template name - * @param model The model of key value pairs used when rendering the template - */ - void render(Context context, String name, Map model); -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/UploadConfig.java b/avaje-jex/src/main/java/io/avaje/jex/UploadConfig.java deleted file mode 100644 index 640bc08a..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/UploadConfig.java +++ /dev/null @@ -1,58 +0,0 @@ -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/UploadedFile.java b/avaje-jex/src/main/java/io/avaje/jex/UploadedFile.java deleted file mode 100644 index bc3351e9..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/UploadedFile.java +++ /dev/null @@ -1,36 +0,0 @@ -package io.avaje.jex; - -import java.io.InputStream; - -/** - * An uploaded file. - */ -public interface UploadedFile { - - /** - * Return the name of the part. - */ - String name(); - - /** - * Return the submitted file name. - */ - String fileName(); - - /** - * Return the file content as InputStream. - */ - InputStream content(); - - /** - * Return the content type for this part. - */ - String contentType(); - - /** - * Return the size. - */ - long size(); - - void delete(); -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/compression/CompressedOutputStream.java b/avaje-jex/src/main/java/io/avaje/jex/compression/CompressedOutputStream.java new file mode 100644 index 00000000..f323036f --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/compression/CompressedOutputStream.java @@ -0,0 +1,90 @@ +package io.avaje.jex.compression; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import io.avaje.jex.core.Constants; +import io.avaje.jex.http.Context; + +/** + * OutputStream implementation that conditionally compresses the output based on configuration and + * request headers. + */ +public final class CompressedOutputStream extends OutputStream { + + private final int minSizeForCompression; + private final CompressionConfig compression; + private final Context ctx; + private final OutputStream originStream; + + private OutputStream compressedStream; + private boolean compressionDecided; + + public CompressedOutputStream( + CompressionConfig compression, Context ctx, OutputStream originStream) { + this.minSizeForCompression = compression.minSizeForCompression(); + this.compression = compression; + this.ctx = ctx; + this.originStream = originStream; + } + + private void decideCompression(int length) throws IOException { + if (!compressionDecided) { + boolean compressionAllowed = + compressedStream == null + && compression.allowsForCompression(ctx.responseHeader(Constants.CONTENT_TYPE)); + + if (compressionAllowed && length >= minSizeForCompression) { + Optional compressor; + compressor = findMatchingCompressor(ctx.headerValues(Constants.ACCEPT_ENCODING)); + if (compressor.isPresent()) { + this.compressedStream = compressor.get().compress(originStream); + ctx.header(Constants.CONTENT_ENCODING, compressor.get().encoding()); + } + } + compressionDecided = true; + } + } + + @Override + public void write(byte[] bytes, int offset, int length) throws IOException { + decideCompression(length); + (compressedStream != null ? compressedStream : originStream).write(bytes, offset, length); + } + + @Override + public void write(int byteVal) throws IOException { + decideCompression(1); + (compressedStream != null ? compressedStream : originStream).write(byteVal); + } + + @Override + public void close() throws IOException { + if (compressedStream != null) { + compressedStream.close(); + } + originStream.close(); + } + + private Optional findMatchingCompressor(List acceptedEncoding) { + if (acceptedEncoding != null) { + // it seems jetty may handle multi-value headers differently + var stream = + acceptedEncoding.size() > 1 + ? acceptedEncoding.stream() + : Arrays.stream(acceptedEncoding.getFirst().split(",")); + + return stream + .map(e -> e.trim().split(";")[0]) + .map(e -> "*".equals(e) ? "gzip" : e.toLowerCase()) + .map(compression::forType) + .filter(Objects::nonNull) + .findFirst(); + } + return Optional.empty(); + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/compression/CompressionConfig.java b/avaje-jex/src/main/java/io/avaje/jex/compression/CompressionConfig.java new file mode 100644 index 00000000..b1f318b2 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/compression/CompressionConfig.java @@ -0,0 +1,114 @@ +package io.avaje.jex.compression; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** Configuration for compression settings. */ +public final class CompressionConfig { + + private static final int HTTP_PACKET_SIZE = 1500; + + private static final Set excludedMimeTypes = + Set.of( + "application/compress", + "application/zip", + "application/gzip", + "application/bzip2", + "application/brotli", + "application/x-xz", + "application/x-rar-compressed"); + + private boolean enabled = true; + + private int minSizeForCompression = HTTP_PACKET_SIZE; + + private final Map compressors = + new HashMap<>(Map.of(GzipCompressor.ENCODING, new GzipCompressor())); + + private final Set allowedExcludedTypes = Set.of("image/svg+xml"); + + /** + * Adds a compressor for a given encoding type. + * + * @param compressor The compressor to use. + */ + public CompressionConfig compressor(Compressor compressor) { + compressors.put(compressor.encoding(), compressor); + return this; + } + + /** + * Sets the default GZIP compression level. + * + * @param level The new compression level (0-9). + */ + public void gzipCompressionLevel(int level) { + compressors.put(GzipCompressor.ENCODING, new GzipCompressor(level)); + } + + /** Disables compression. */ + public void disableCompression() { + enabled = false; + compressors.clear(); + } + + /** + * Gets the minimum size for compression. + * + * @return The minimum size for compression. + */ + public int minSizeForCompression() { + return minSizeForCompression; + } + + /** + * Sets the minimum size for compression and returns the updated configuration. + * + * @param minSizeForCompression The new minimum size for compression. + * @return The updated configuration. + * @throws IllegalArgumentException If the minimum size is less than a network packet size. + */ + public CompressionConfig minSizeForCompression(int minSizeForCompression) { + this.minSizeForCompression = minSizeForCompression; + if (minSizeForCompression < HTTP_PACKET_SIZE) { + throw new IllegalArgumentException( + "Compression should only happen on payloads bigger than an http packet"); + } + return this; + } + + /** + * Checks if compression is enabled. + * + * @return True if compression is enabled, false otherwise. + */ + public boolean compressionEnabled() { + return enabled; + } + + /** + * Determines if a given content type is allowed for compression. + * + * @param contentType The content type to check. + * @return True if the content type is allowed for compression, false otherwise. + */ + public boolean allowsForCompression(String contentType) { + return contentType == null + || allowedExcludedTypes.contains(contentType) + || !excludedMimeTypes.contains(contentType) + && !contentType.startsWith("image/") + && !contentType.startsWith("audio/") + && !contentType.startsWith("video/"); + } + + /** + * Gets the appropriate compressor for a given encoding type. + * + * @param encoding The Content-Encoding value. + * @return The compressor for the given Content-Encoding value, or null if not found. + */ + Compressor forType(String encoding) { + return compressors.get(encoding); + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/compression/Compressor.java b/avaje-jex/src/main/java/io/avaje/jex/compression/Compressor.java new file mode 100644 index 00000000..7bd1588c --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/compression/Compressor.java @@ -0,0 +1,26 @@ +package io.avaje.jex.compression; + +import java.io.IOException; +import java.io.OutputStream; + +/** Compressor interface defines methods for compressing an output stream. */ +public interface Compressor { + + /** + * Gets the content encoding for this compressor (e.g., "gzip"). + * + * @see MDN + * Content-Encoding + * @return the content encoding + */ + String encoding(); + + /** + * Compresses the provided output stream. + * + * @param out the output stream to compress + * @return the compressed output stream + * @throws IOException if an error occurs during compression + */ + OutputStream compress(OutputStream out) throws IOException; +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/compression/GzipCompressor.java b/avaje-jex/src/main/java/io/avaje/jex/compression/GzipCompressor.java new file mode 100644 index 00000000..52778950 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/compression/GzipCompressor.java @@ -0,0 +1,39 @@ +package io.avaje.jex.compression; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.zip.GZIPOutputStream; + +final class GzipCompressor implements Compressor { + static final String ENCODING = "gzip"; + private final int level; + + GzipCompressor() { + level = 6; + } + + GzipCompressor(int level) { + if (level < 0 || level > 9) { + throw new IllegalArgumentException("Valid range for parameter level is 0 to 9"); + } + this.level = level; + } + + @Override + public String encoding() { + return ENCODING; + } + + @Override + public OutputStream compress(OutputStream out) throws IOException { + return new LeveledGzipStream(out, level); + } + + private static final class LeveledGzipStream extends GZIPOutputStream { + + private LeveledGzipStream(OutputStream out, int level) throws IOException { + super(out); + this.def.setLevel(level); + } + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/compression/package-info.java b/avaje-jex/src/main/java/io/avaje/jex/compression/package-info.java new file mode 100644 index 00000000..e054aa39 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/compression/package-info.java @@ -0,0 +1,2 @@ +/** Classes Governing Http Compression */ +package io.avaje.jex.compression; diff --git a/avaje-jex/src/main/java/io/avaje/jex/core/BaseFilterChain.java b/avaje-jex/src/main/java/io/avaje/jex/core/BaseFilterChain.java new file mode 100644 index 00000000..095feab3 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/core/BaseFilterChain.java @@ -0,0 +1,38 @@ +package io.avaje.jex.core; + +import java.util.Iterator; + +import io.avaje.jex.http.ExchangeHandler; +import io.avaje.jex.http.HttpFilter; +import io.avaje.jex.http.HttpFilter.FilterChain; + +final class BaseFilterChain implements FilterChain { + + private final Iterator filters; + private final ExchangeHandler handler; + private final JdkContext ctx; + private final ServiceManager mgr; + + BaseFilterChain(Iterator filters, ExchangeHandler handler, JdkContext ctx, ServiceManager mgr) { + this.filters = filters; + this.handler = handler; + this.ctx = ctx; + this.mgr = mgr; + } + + @Override + public void proceed() { + if (filters.hasNext()) { + filters.next().filter(ctx, this); + } else { + try { + if (!ctx.responseSent()) { + handler.handle(ctx); + } + } catch (Exception t) { + mgr.handleException(ctx, t); + } + } + ctx.setMode(Mode.AFTER); + } +} 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 deleted file mode 100644 index 1895a6e5..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/core/BootstapServiceManager.java +++ /dev/null @@ -1,12 +0,0 @@ -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/BootstrapServer.java b/avaje-jex/src/main/java/io/avaje/jex/core/BootstrapServer.java new file mode 100644 index 00000000..9348feba --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/core/BootstrapServer.java @@ -0,0 +1,90 @@ +package io.avaje.jex.core; + +import static java.lang.System.Logger.Level.DEBUG; +import static java.lang.System.Logger.Level.INFO; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; + +import com.sun.net.httpserver.HttpServer; + +import io.avaje.jex.AppLifecycle; +import io.avaje.jex.Jex; +import io.avaje.jex.JexConfig; +import io.avaje.jex.routes.RoutesBuilder; +import io.avaje.jex.routes.SpiRoutes; + +public final class BootstrapServer { + + private BootstrapServer() {} + + private static final System.Logger log = System.getLogger("io.avaje.jex"); + + public static Jex.Server start(Jex jex) { + final var config = jex.config(); + if (config.health()) { + jex.plugin(new HealthPlugin()); + } + + CoreServiceLoader.plugins().forEach(p -> p.apply(jex)); + + final SpiRoutes routes = new RoutesBuilder(jex.routing(), config).build(); + + return start(jex, routes); + } + + static Jex.Server start(Jex jex, SpiRoutes routes) { + try { + final var config = jex.config(); + final var socketAddress = createSocketAddress(config); + final var https = config.httpsConfig(); + final var provider = config.serverProvider(); + final HttpServer server; + if (https != null) { + var httpsServer = provider.createHttpsServer(socketAddress, config.socketBacklog()); + httpsServer.setHttpsConfigurator(https); + server = httpsServer; + } else { + server = provider.createHttpServer(socketAddress, config.socketBacklog()); + } + + final var scheme = config.scheme(); + final var contextPath = config.contextPath(); + ServiceManager serviceManager = ServiceManager.create(jex); + final var handler = new RoutingHandler(routes, serviceManager); + + final var serverClass = server.getClass(); + + // jetty's server does not support setExecutor with virtual threads (VT) + // as it has it's own impl that will auto-use VTs + if (!serverClass.getName().contains("jetty")) { + server.setExecutor(config.executor()); + } + + server.createContext(contextPath, handler); + server.start(); + var actualAddress = server.getAddress(); + jex.lifecycle().status(AppLifecycle.Status.STARTED); + log.log( + INFO, + "Avaje Jex started {0} on {1}://{2}:{3,number,#}", + serverClass, + scheme, + actualAddress.getHostName(), + actualAddress.getPort()); + log.log(DEBUG, routes); + return new JdkJexServer(server, jex.lifecycle(), handler); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static InetSocketAddress createSocketAddress(JexConfig config) + throws UnknownHostException { + final var inetAddress = config.host() == null ? null : InetAddress.getByName(config.host()); + return new InetSocketAddress(inetAddress, config.port()); + } +} diff --git a/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/BufferedOutStream.java b/avaje-jex/src/main/java/io/avaje/jex/core/BufferedOutStream.java similarity index 58% rename from avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/BufferedOutStream.java rename to avaje-jex/src/main/java/io/avaje/jex/core/BufferedOutStream.java index cc7065f6..43cffd30 100644 --- a/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/BufferedOutStream.java +++ b/avaje-jex/src/main/java/io/avaje/jex/core/BufferedOutStream.java @@ -1,23 +1,23 @@ -package io.avaje.jex.jdk; - -import com.sun.net.httpserver.HttpExchange; +package io.avaje.jex.core; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; -class BufferedOutStream extends OutputStream { +import com.sun.net.httpserver.HttpExchange; + +final class BufferedOutStream extends OutputStream { - private final JdkContext context; private final long max; + private final JdkContext context; private ByteArrayOutputStream buffer; private OutputStream stream; private long count; - BufferedOutStream(JdkContext context, long max, int bufferSize) { + BufferedOutStream(JdkContext context, int initial, long max) { this.context = context; this.max = max; - this.buffer = new ByteArrayOutputStream(bufferSize); + this.buffer = new ByteArrayOutputStream(initial); } @Override @@ -25,32 +25,46 @@ public void write(int b) throws IOException { if (stream != null) { stream.write(b); } else { - buffer.write(b); if (count++ > max) { initialiseChunked(); + stream.write(b); + return; + } + buffer.write(b); + } + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + if (stream != null) { + stream.write(b, off, len); + } else { + count += len; + if (count > max) { + initialiseChunked(); + stream.write(b, off, len); + return; } + buffer.write(b, off, len); } } - /** - * Use responseLength 0 and chunked response. - */ + /** 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.writeTo(stream); buffer = null; } @Override public void close() throws IOException { if (stream != null) { - stream.flush(); stream.close(); } else { - context.writeBytes(buffer.toByteArray()); + context.write(buffer.toByteArray()); } } } diff --git a/avaje-jex/src/main/java/io/avaje/jex/core/Constants.java b/avaje-jex/src/main/java/io/avaje/jex/core/Constants.java new file mode 100644 index 00000000..fc6a9132 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/core/Constants.java @@ -0,0 +1,22 @@ +package io.avaje.jex.core; + +public final class Constants { + + private Constants() {} + + public static final String ACCEPT = "Accept"; + public static final String CONTENT_ENCODING = "Content-encoding"; + public static final String CONTENT_LENGTH = "Content-length"; + public static final String CONTENT_TYPE = "Content-type"; + public static final String LOCATION = "Location"; + public static final String HOST = "Host"; + public static final String USER_AGENT = "User-agent"; + public static final String ACCEPT_ENCODING = "Accept-encoding"; + + public static final String TEXT_HTML = "text/html"; + public static final String TEXT_PLAIN = "text/plain"; + public static final String TEXT_HTML_UTF8 = "text/html;charset=utf-8"; + public static final String TEXT_PLAIN_UTF8 = "text/plain;charset=utf-8"; + public static final String APPLICATION_JSON = "application/json"; + public static final String APPLICATION_X_JSON_STREAM = "application/x-json-stream"; +} diff --git a/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/CookieParser.java b/avaje-jex/src/main/java/io/avaje/jex/core/CookieParser.java similarity index 80% rename from avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/CookieParser.java rename to avaje-jex/src/main/java/io/avaje/jex/core/CookieParser.java index 9948fb26..861654bf 100644 --- a/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/CookieParser.java +++ b/avaje-jex/src/main/java/io/avaje/jex/core/CookieParser.java @@ -1,4 +1,4 @@ -package io.avaje.jex.jdk; +package io.avaje.jex.core; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -10,7 +10,7 @@ /** * Parse cookies based on RFC6265 skipping parameters. */ -class CookieParser { +final class CookieParser { private static final String QUOTE = "\""; private static final char[] QUOTE_CHARS = QUOTE.toCharArray(); @@ -30,7 +30,7 @@ private CookieParser() { * * @param rawHeader a value of '{@code Cookie:}' header. */ - public static Map parse(String rawHeader) { + static Map parse(String rawHeader) { if (rawHeader == null) { return emptyMap(); } @@ -57,10 +57,7 @@ public static Map parse(String rawHeader) { 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)) { + if (name.isEmpty() || (isRfc2965 && name.charAt(0) == '$' && ignore(name))) { continue; // Skip RFC2965 attributes } final String value = unwrap(token.substring(eqInd + 1).trim()); @@ -104,25 +101,23 @@ static List tokenize(char separator, String text) { quoted = false; } token.append(ch); + } else if (ch == separator) { + if (!token.isEmpty()) { + result.add(token.toString()); + } + token.setLength(0); } else { - if (ch == separator) { - if (token.length() > 0) { - result.add(token.toString()); + for (char quote : CookieParser.QUOTE_CHARS) { + if (ch == quote) { + quoted = true; + lastQuoteCharacter = ch; + break; } - token.setLength(0); - } else { - for (char quote : CookieParser.QUOTE_CHARS) { - if (ch == quote) { - quoted = true; - lastQuoteCharacter = ch; - break; - } - } - token.append(ch); } + token.append(ch); } } - if (token.length() > 0) { + if (!token.isEmpty()) { result.add(token.toString()); } return result; diff --git a/avaje-jex/src/main/java/io/avaje/jex/core/CoreServiceLoader.java b/avaje-jex/src/main/java/io/avaje/jex/core/CoreServiceLoader.java new file mode 100644 index 00000000..ebe2bba7 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/core/CoreServiceLoader.java @@ -0,0 +1,45 @@ +package io.avaje.jex.core; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.ServiceLoader; + +import io.avaje.jex.spi.JexExtension; +import io.avaje.jex.spi.JexPlugin; +import io.avaje.jex.spi.JsonService; +import io.avaje.jex.spi.TemplateRender; + +/** Loads SPI Services. */ +final class CoreServiceLoader { + + private static final CoreServiceLoader INSTANCE = new CoreServiceLoader(); + + private final JsonService jsonService; + private final List renders = new ArrayList<>(); + private final List plugins = new ArrayList<>(); + + CoreServiceLoader() { + JsonService spiJsonService = null; + for (var spi : ServiceLoader.load(JexExtension.class)) { + switch (spi) { + case JsonService s -> spiJsonService = s; + case TemplateRender r -> renders.add(r); + case JexPlugin p -> plugins.add(p); + } + } + jsonService = spiJsonService; + } + + static Optional jsonService() { + return Optional.ofNullable(INSTANCE.jsonService); + } + + static List getRenders() { + return INSTANCE.renders; + } + + static List plugins() { + return INSTANCE.plugins; + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/core/CoreServiceManager.java b/avaje-jex/src/main/java/io/avaje/jex/core/CoreServiceManager.java deleted file mode 100644 index bf73da9f..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/core/CoreServiceManager.java +++ /dev/null @@ -1,197 +0,0 @@ -package io.avaje.jex.core; - -import io.avaje.applog.AppLog; -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 java.io.UncheckedIOException; -import java.io.UnsupportedEncodingException; -import java.lang.System.Logger.Level; -import java.net.URLDecoder; -import java.util.*; -import java.util.stream.Stream; - -/** - * Core implementation of SpiServiceManager provided to specific implementations like jetty etc. - */ -class CoreServiceManager implements SpiServiceManager { - - private static final System.Logger log = AppLog.getLogger("io.avaje.jex"); - public static final String UTF_8 = "UTF-8"; - - private final HttpMethodMap methodMap = new HttpMethodMap(); - private final JsonService jsonService; - private final ExceptionManager exceptionHandler; - private final TemplateManager templateManager; - - static SpiServiceManager create(Jex jex) { - return new Builder(jex).build(); - } - - CoreServiceManager(JsonService jsonService, ErrorHandling errorHandling, TemplateManager templateManager) { - this.jsonService = jsonService; - this.exceptionHandler = new ExceptionManager(errorHandling); - this.templateManager = templateManager; - } - - @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); - } finally { - maybeClose(iterator); - } - } - - @Override - public void maybeClose(Object iterator) { - if (AutoCloseable.class.isAssignableFrom(iterator.getClass())) { - try { - ((AutoCloseable) iterator).close(); - } catch (Exception e) { - throw new RuntimeException("Error closing iterator " + iterator, 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); - } - - - @Override - public String requestCharset(Context ctx) { - return parseCharset(ctx.header(HeaderKeys.CONTENT_TYPE)); - } - - 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; - } - - @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 { - private final Jex jex; - - Builder(Jex jex) { - this.jex = jex; - } - - SpiServiceManager build() { - return new CoreServiceManager(initJsonService(), jex.errorHandling(), initTemplateMgr()); - } - - JsonService initJsonService() { - final JsonService jsonService = jex.config().jsonService(); - if (jsonService != null) { - return jsonService; - } - return ServiceLoader.load(JsonService.class) - .findFirst() - .orElseGet(this::defaultJsonService); - } - - /** - * Create a reasonable default JsonService if Jackson or avaje-jsonb are present. - */ - JsonService defaultJsonService() { - if (detectJackson()) { - try { - return new JacksonJsonService(); - } catch (IllegalAccessError errorNotInModulePath) { - // not in module path - log.log(Level.DEBUG, "Not using Jackson due to module path {0}", errorNotInModulePath.getMessage()); - } - } - return detectJsonb() ? new JsonbJsonService() : null; - } - - boolean detectJackson() { - return detectTypeExists("com.fasterxml.jackson.databind.ObjectMapper"); - } - - boolean detectJsonb() { - return detectTypeExists("io.avaje.jsonb.Jsonb"); - } - - private boolean detectTypeExists(String className) { - try { - Class.forName(className); - return true; - } catch (ClassNotFoundException e) { - return false; - } - } - - TemplateManager initTemplateMgr() { - TemplateManager mgr = new TemplateManager(); - mgr.register(jex.config().renderers()); - for (TemplateRender render : ServiceLoader.load(TemplateRender.class)) { - mgr.registerDefault(render); - } - return mgr; - } - - } -} 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 960585f1..58543396 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,102 +1,95 @@ package io.avaje.jex.core; -import io.avaje.applog.AppLog; -import io.avaje.jex.ErrorHandling; -import io.avaje.jex.ExceptionHandler; +import static java.lang.System.Logger.Level.ERROR; + +import java.util.Map; + +import io.avaje.jex.http.HttpStatus; +import io.avaje.jex.http.Context; +import io.avaje.jex.http.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 io.avaje.jex.http.InternalServerErrorException; -import static java.lang.System.Logger.Level.WARNING; +final class ExceptionManager { -class ExceptionManager { + private static final String APPLICATION_JSON = "application/json"; - private static final System.Logger log = AppLog.getLogger("io.avaje.jex"); + private static final System.Logger log = System.getLogger("io.avaje.jex"); - private final ErrorHandling errorHandling; + private final Map, ExceptionHandler> handlers; - ExceptionManager(ErrorHandling errorHandling) { - this.errorHandling = errorHandling; + ExceptionManager(Map, ExceptionHandler> handlers) { + this.handlers = handlers; } - void handle(SpiContext ctx, Exception e) { - if (!isRedirect(e)) { - if (ctx.isCommitted()) { - log.log(WARNING, "Response is already committed when handling exception", e); - throw new InternalServerErrorResponse("Response already committed on error " + e); - } else { - // reset the status, headers and buffers in order to write the error content - ctx.reset(); + @SuppressWarnings("unchecked") + ExceptionHandler find(Class exceptionType) { + Class type = exceptionType; + do { + final var handler = handlers.get(type); + if (handler != null) { + return (ExceptionHandler) handler; } - } - final ExceptionHandler handler = errorHandling.find(e.getClass()); + type = type.getSuperclass(); + } while (type != null); + return null; + } + + void handle(JdkContext ctx, Exception e) { + final var handler = find(e.getClass()); if (handler != null) { - handler.handle(e, ctx); - } else { - if (canHandle(e)) { - defaultHandling(ctx, e); - } else { - unhandledException(ctx, e); + try { + handler.handle(ctx, e); + } catch (Exception ex) { + unhandledException(ctx, ex); } + } else if (e instanceof HttpResponseException ex) { + defaultHandling(ctx, ex); + } else { + unhandledException(ctx, e); } } - private void unhandledException(SpiContext ctx, Exception e) { - log.log(WARNING, "Uncaught exception", e); - defaultHandling(ctx, new InternalServerErrorResponse()); - } - - private boolean canHandle(Exception e) { - return HttpResponseException.class.isAssignableFrom(e.getClass()); + private void unhandledException(JdkContext ctx, Exception e) { + log.log(ERROR, "Uncaught exception", e); + defaultHandling(ctx, new InternalServerErrorException("Internal Server Error")); } - private boolean isRedirect(Exception e) { - return RedirectResponse.class.isAssignableFrom(e.getClass()); - } + private void defaultHandling(JdkContext ctx, HttpResponseException exception) { + if (ctx.responseSent()) { + // if already sent headers, can't send again + return; + } - private void defaultHandling(SpiContext ctx, Exception exception) { - final HttpResponseException e = unwrap(exception); - ctx.status(e.getStatus()); - if (isRedirect(e)) { + ctx.status(exception.status()); + var jsonResponse = exception.jsonResponse(); + if (exception.status() == HttpStatus.FOUND_302.status()) { ctx.performRedirect(); + } else if (jsonResponse != null) { + ctx.json(jsonResponse); } else if (useJson(ctx)) { - ctx.contentType("application/json").write(asJsonContent(e)); + ctx.contentType(APPLICATION_JSON).write(asJsonContent(exception)); } else { - ctx.text(asTextContent(e)); + ctx.text(exception.getMessage()); } } - private String asTextContent(HttpResponseException e) { - return e.getMessage(); - // + "\n" details - } - private String asJsonContent(HttpResponseException e) { - return "{\"title\": " + jsonEscape(e.getMessage()) + ", " + - "\"status\": " + e.getStatus() + - //+ ", " "\"type\": " + ", " + - jsonDetails(e) + "}"; + return "{\"title\": " + + jsonEscape(e.getMessage()) + + ", " + + "\"status\": " + + e.status() + + "}"; } private String jsonEscape(String message) { return message; } - private String jsonDetails(HttpResponseException e) { - return ""; + private boolean useJson(Context ctx) { + final String acceptHeader = ctx.header(Constants.ACCEPT); + return (acceptHeader != null && acceptHeader.contains(APPLICATION_JSON) + || APPLICATION_JSON.equals(ctx.responseHeader(Constants.CONTENT_TYPE))); } - - private HttpResponseException unwrap(Exception e) { - return (HttpResponseException) e; - //(if (e is CompletionException) e.cause else e) as HttpResponseException - } - - private boolean useJson(SpiContext ctx) { - final String acceptHeader = ctx.header(HeaderKeys.ACCEPT); - return (acceptHeader != null && acceptHeader.contains("application/json") - || "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 index 8ea2da11..6a5a5277 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/core/HealthPlugin.java +++ b/avaje-jex/src/main/java/io/avaje/jex/core/HealthPlugin.java @@ -1,15 +1,15 @@ 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; +import io.avaje.jex.http.Context; +import io.avaje.jex.spi.JexPlugin; /** * Health plugin with liveness and readiness support based on * the application lifecycle support. */ -public class HealthPlugin implements Plugin { +final class HealthPlugin implements JexPlugin { private AppLifecycle lifecycle; 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 deleted file mode 100644 index d44dca71..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/core/HttpMethodMap.java +++ /dev/null @@ -1,21 +0,0 @@ -package io.avaje.jex.core; - -import io.avaje.jex.Routing; - -import java.util.HashMap; -import java.util.Map; - -final class HttpMethodMap { - - private final Map map = new HashMap<>(); - - HttpMethodMap() { - for (Routing.Type value : Routing.Type.values()) { - map.put(value.name(), value); - } - } - - 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 deleted file mode 100644 index 81b06cda..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/core/JacksonJsonService.java +++ /dev/null @@ -1,82 +0,0 @@ -package io.avaje.jex.core; - -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import io.avaje.jex.spi.JsonService; -import io.avaje.jex.spi.SpiContext; - -import java.io.IOException; -import java.io.OutputStream; -import java.io.UncheckedIOException; -import java.util.Iterator; - -public class JacksonJsonService implements JsonService { - - private final ObjectMapper mapper; - - public JacksonJsonService() { - this.mapper = new ObjectMapper() - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - } - - public JacksonJsonService(ObjectMapper mapper) { - this.mapper = mapper; - } - - @Override - public T jsonRead(Class clazz, SpiContext ctx) { - try { - // TODO: Handle gzipped content - // read direct - return mapper.readValue(ctx.inputStream(), clazz); - //return mapper.readValue(ctx.bodyAsBytes(), clazz); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } - - @Override - public void jsonWrite(Object bean, SpiContext ctx) { - try { - // gzip compression etc ? - OutputStream os = ctx.outputStream(); - try (JsonGenerator generator = mapper.createGenerator(os)) { - // only flush to underlying OutputStream on success - generator.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET); - generator.disable(JsonGenerator.Feature.FLUSH_PASSED_TO_STREAM); - generator.disable(JsonGenerator.Feature.AUTO_CLOSE_JSON_CONTENT); - mapper.writeValue(generator, bean); - generator.flush(); - } - os.flush(); - os.close(); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } - - @Override - public void jsonWriteStream(Iterator iterator, SpiContext ctx) { - final JsonGenerator generator; - try { - generator = mapper.createGenerator(ctx.outputStream()); - generator.setPrettyPrinter(null); - try { - 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-jdk/src/main/java/io/avaje/jex/jdk/JdkContext.java b/avaje-jex/src/main/java/io/avaje/jex/core/JdkContext.java similarity index 58% rename from avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/JdkContext.java rename to avaje-jex/src/main/java/io/avaje/jex/core/JdkContext.java index 450fa9de..cf02ceb0 100644 --- a/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/JdkContext.java +++ b/avaje-jex/src/main/java/io/avaje/jex/core/JdkContext.java @@ -1,269 +1,293 @@ -package io.avaje.jex.jdk; +package io.avaje.jex.core; -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 static io.avaje.jex.core.Constants.APPLICATION_JSON; +import static io.avaje.jex.core.Constants.APPLICATION_X_JSON_STREAM; +import static io.avaje.jex.core.Constants.TEXT_HTML_UTF8; +import static io.avaje.jex.core.Constants.TEXT_PLAIN_UTF8; +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.UncheckedIOException; +import java.lang.reflect.Type; import java.net.InetAddress; import java.net.InetSocketAddress; +import java.net.URI; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.Base64; +import java.util.HashMap; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Stream; -import static java.util.Collections.emptyList; -import static java.util.Collections.emptyMap; +import javax.net.ssl.SSLSession; -class JdkContext implements Context, SpiContext { +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpsExchange; + +import io.avaje.jex.http.Context; +import io.avaje.jex.http.HttpStatus; +import io.avaje.jex.http.RedirectException; +import io.avaje.jex.security.BasicAuthCredentials; +import io.avaje.jex.security.Role; +import io.avaje.jex.spi.JsonService; + +final class JdkContext implements Context { - 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 String matchedPath; private final Map pathParams; + private final Map attributes = new HashMap<>(); + private final Set roles; private final HttpExchange exchange; - private Routing.Type mode; + private Mode mode; private Map> formParams; private Map> queryParams; private Map cookieMap; private int statusCode; - private String characterEncoding; + private byte[] bodyBytes; + + private Charset characterEncoding; - JdkContext(ServiceManager mgr, HttpExchange exchange, String path, Map pathParams) { + JdkContext( + ServiceManager mgr, + HttpExchange exchange, + String path, + Map pathParams, + Set roles) { this.mgr = mgr; + this.roles = roles; this.exchange = exchange; - this.path = path; + this.matchedPath = path; this.pathParams = pathParams; } - /** - * Create when no route matched. - */ - JdkContext(ServiceManager mgr, HttpExchange exchange, String path) { + /** Create when no route matched. */ + JdkContext(ServiceManager mgr, HttpExchange exchange, String path, Set roles) { this.mgr = mgr; + this.roles = roles; this.exchange = exchange; - this.path = path; + this.matchedPath = path; this.pathParams = null; } @Override - public String matchedPath() { - return path; + @SuppressWarnings("unchecked") + public T attribute(String key) { + return (T) attributes.get(key); } @Override public Context attribute(String key, Object value) { - exchange.setAttribute(key, value); + attributes.put(key, value); return this; } @Override - @SuppressWarnings("unchecked") - public T attribute(String key) { - return (T) exchange.getAttribute(key); + public BasicAuthCredentials basicAuthCredentials() { + return getBasicAuthCredentials(header("Authorization")); } - private Map parseCookies() { - final String cookieHeader = header(exchange.getRequestHeaders(), COOKIE); - if (cookieHeader == null || cookieHeader.isEmpty()) { - return emptyMap(); + private static BasicAuthCredentials getBasicAuthCredentials(String authorizationHeader) { + if (authorizationHeader == null || !authorizationHeader.startsWith("Basic ")) { + return null; } - return CookieParser.parse(cookieHeader); + + String base64Credentials = authorizationHeader.substring("Basic ".length()); + byte[] decodedCredentials = Base64.getDecoder().decode(base64Credentials); + String credentialsString = new String(decodedCredentials); + + String[] credentials = credentialsString.split(":", 2); + if (credentials.length != 2) { + throw new IllegalStateException("Invalid Basic Auth header"); + } + + return new BasicAuthCredentials(credentials[0], credentials[1]); } @Override - public Map cookieMap() { - if (cookieMap == null) { - cookieMap = parseCookies(); + public String body() { + return new String(bodyAsBytes(), characterEncoding()); + } + + @Override + public byte[] bodyAsBytes() { + try { + if (bodyBytes == null) { + bodyBytes = exchange.getRequestBody().readAllBytes(); + } + return bodyBytes; + } catch (IOException e) { + throw new UncheckedIOException(e); } - return cookieMap; } @Override - public String cookie(String name) { - return cookieMap().get(name); + public InputStream bodyAsInputStream() { + return exchange.getRequestBody(); } @Override - public Context cookie(Cookie cookie) { - header(SET_COOKIE, cookie.toString()); - return this; + public T bodyAsType(Type beanType) { + return mgr.fromJson(beanType, bodyAsBytes()); } @Override - public Context cookie(String name, String value) { - header(SET_COOKIE, Cookie.of(name, value).toString()); - return this; + public T bodyStreamAsType(Type beanType) { + return bodyBytes == null ? mgr.fromJson(beanType, bodyAsInputStream()) : bodyAsType(beanType); + } + + private Charset characterEncoding() { + if (characterEncoding == null) { + characterEncoding = mgr.requestCharset(this); + } + return characterEncoding; } @Override - public Context cookie(String name, String value, int maxAge) { - header(SET_COOKIE, Cookie.of(name, value).maxAge(Duration.ofSeconds(maxAge)).toString()); - return this; + public long contentLength() { + final String len = header(Constants.CONTENT_LENGTH); + return len == null ? 0 : Long.parseLong(len); } @Override - public Context removeCookie(String name) { - header(SET_COOKIE, Cookie.expired(name).path("/").toString()); - return this; + public String contentType() { + return header(exchange.getRequestHeaders(), Constants.CONTENT_TYPE); } @Override - public Context removeCookie(String name, String path) { - header(SET_COOKIE, Cookie.expired(name).path(path).toString()); + public Context contentType(String contentType) { + exchange.getResponseHeaders().set(Constants.CONTENT_TYPE, contentType); return this; } @Override - public void redirect(String location) { - redirect(location, SC_MOVED_TEMPORARILY); + public String contextPath() { + return exchange.getHttpContext().getPath(); } @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(); - } + public Context cookie(Cookie cookie) { + header(SET_COOKIE, cookie.toString()); + return this; } @Override - public void performRedirect() { - try { - exchange.sendResponseHeaders(statusCode(), 0); - exchange.getResponseBody().close(); - } catch (IOException e) { - throw new UncheckedIOException(e); - } + public String cookie(String name) { + return cookieMap().get(name); } @Override - public T bodyAsClass(Class beanType) { - return mgr.jsonRead(beanType, this); + public Context cookie(String name, String value) { + header(SET_COOKIE, Cookie.of(name, value).toString()); + return this; } @Override - public byte[] bodyAsBytes() { - try { - return exchange.getRequestBody().readAllBytes(); - } catch (IOException e) { - throw new UncheckedIOException(e); - } + public Context cookie(String name, String value, int maxAge) { + header(SET_COOKIE, Cookie.of(name, value).maxAge(Duration.ofSeconds(maxAge)).toString()); + return this; } - private String characterEncoding() { - if (characterEncoding == null) { - characterEncoding = mgr.requestCharset(this); + @Override + public Map cookieMap() { + if (cookieMap == null) { + cookieMap = parseCookies(); } - return characterEncoding; + return cookieMap; } @Override - public String body() { - return new String(bodyAsBytes(), Charset.forName(characterEncoding())); + public HttpExchange exchange() { + return exchange; } @Override - public long contentLength() { - final String len = header(HeaderKeys.CONTENT_LENGTH); - return len == null ? 0 : Long.parseLong(len); + public Map> formParamMap() { + if (formParams == null) { + formParams = initFormParamMap(); + } + return formParams; } - @Override - public String contentType() { - return header(exchange.getRequestHeaders(), HeaderKeys.CONTENT_TYPE); + private String header(Headers headers, String name) { + return headers.getFirst(name); } @Override - public String responseHeader(String key) { - return header(exchange.getResponseHeaders(), key); + public String header(String key) { + return header(exchange.getRequestHeaders(), 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 List headerValues(String key) { + return exchange.getRequestHeaders().get(key); } @Override - public Context contentType(String contentType) { - exchange.getResponseHeaders().set(HeaderKeys.CONTENT_TYPE, contentType); + public Context header(String key, List value) { + exchange.getResponseHeaders().put(key, value); return this; } @Override - public Map pathParamMap() { - return pathParams; + public Context header(String key, String value) { + exchange.getResponseHeaders().add(key, value); + return this; } @Override - public String pathParam(String name) { - return pathParams.get(name); + public Map headerMap() { + Map map = new LinkedHashMap<>(); + for (var entry : exchange.getRequestHeaders().entrySet()) { + final List value = entry.getValue(); + if (!value.isEmpty()) { + map.put(entry.getKey(), value.getFirst()); + } + } + return map; } @Override - public String queryParam(String name) { - final List vals = queryParams(name); - return vals == null || vals.isEmpty() ? null : vals.get(0); + public Context headerMap(Map> map) { + exchange.getResponseHeaders().putAll(map); + return this; } - private Map> queryParams() { - if (queryParams == null) { - queryParams = mgr.parseParamMap(queryString(), UTF8); - } - return queryParams; + @Override + public Headers requestHeaders() { + return exchange.getRequestHeaders(); } @Override - public List queryParams(String name) { - final List vals = queryParams().get(name); - return vals == null ? emptyList() : vals; + public Headers responseHeaders() { + return exchange.getResponseHeaders(); } @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; + public List responseHeaderValues(String key) { + return exchange.getResponseHeaders().get(key); } @Override - public String queryString() { - return exchange.getRequestURI().getQuery(); + public String host() { + return header(Constants.HOST); } @Override - public Map> formParamMap() { - if (formParams == null) { - formParams = initFormParamMap(); - } - return formParams; + public void html(String content) { + contentType(TEXT_HTML_UTF8); + write(content); } private Map> initFormParamMap() { @@ -271,220 +295,245 @@ private Map> initFormParamMap() { } @Override - public String scheme() { - return mgr.scheme(); + 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 Context sessionAttribute(String key, Object value) { - throw new UnsupportedOperationException(); + public void json(Object bean) { + contentType(APPLICATION_JSON); + mgr.toJson(bean, outputStream()); } @Override - public T sessionAttribute(String key) { - throw new UnsupportedOperationException(); + public void jsonStream(Iterator iterator) { + contentType(APPLICATION_X_JSON_STREAM); + mgr.toJsonStream(iterator, outputStream()); } @Override - public Map sessionAttributeMap() { - throw new UnsupportedOperationException(); + public void jsonStream(Stream stream) { + contentType(APPLICATION_X_JSON_STREAM); + mgr.toJsonStream(stream, outputStream()); } @Override - public String url() { - return scheme() + "://" + host() + path; + public String matchedPath() { + return matchedPath; } @Override - public String contextPath() { - return mgr.contextPath(); + public String method() { + return exchange.getRequestMethod(); } @Override - public Context status(int statusCode) { - this.statusCode = statusCode; - return this; + public OutputStream outputStream() { + return mgr.createOutputStream(this); + } + + private Map parseCookies() { + final String cookieHeader = header(exchange.getRequestHeaders(), COOKIE); + if (cookieHeader == null || cookieHeader.isEmpty()) { + return emptyMap(); + } + return CookieParser.parse(cookieHeader); } @Override - public int status() { - return statusCode; + public String path() { + return exchange.getRequestURI().getPath(); } @Override - public Context json(Object bean) { - contentType(APPLICATION_JSON); - mgr.jsonWrite(bean, this); - return this; + public String pathParam(String name) { + return pathParams.get(name); } @Override - public Context jsonStream(Stream stream) { - contentType(APPLICATION_X_JSON_STREAM); - mgr.jsonWriteStream(stream, this); - return this; + public Map pathParamMap() { + return pathParams; + } + + public void performRedirect() { + try { + exchange.sendResponseHeaders(statusCode(), -1); + exchange.getResponseBody().close(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } } @Override - public Context jsonStream(Iterator iterator) { - contentType(APPLICATION_X_JSON_STREAM); - mgr.jsonWriteStream(iterator, this); - return this; + public int port() { + return exchange.getLocalAddress().getPort(); } @Override - public Context text(String content) { - contentType(TEXT_PLAIN_UTF8); - return write(content); + public String protocol() { + return exchange.getProtocol(); } @Override - public Context html(String content) { - contentType(TEXT_HTML_UTF8); - return write(content); + public String queryParam(String name) { + final List vals = queryParams(name); + return vals.isEmpty() ? null : vals.getFirst(); } @Override - public Context write(String content) { - try { - writeBytes(content.getBytes(StandardCharsets.UTF_8)); - return this; - } catch (IOException e) { - throw new UncheckedIOException(e); + 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.getFirst()); + } + } + return single; } - void writeBytes(byte[] bytes) throws IOException { - exchange.sendResponseHeaders(statusCode(), bytes.length); - final OutputStream os = exchange.getResponseBody(); - os.write(bytes); - os.flush(); - os.close(); + private Map> queryParams() { + if (queryParams == null) { + queryParams = mgr.parseParamMap(queryString(), StandardCharsets.UTF_8); + } + return queryParams; } @Override - public boolean isCommitted() { - // no support for this - return false; + public List queryParams(String name) { + final List vals = queryParams().get(name); + return vals == null ? emptyList() : vals; } @Override - public void reset() { - // do nothing - } - - int statusCode() { - return statusCode == 0 ? 200 : statusCode; + public String queryString() { + return exchange.getRequestURI().getQuery(); } @Override - public Context render(String name, Map model) { - mgr.render(this, name, model); - return this; + public void redirect(String location) { + redirect(location, HttpStatus.FOUND_302.status()); } @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)); - } + public void redirect(String location, int statusCode) { + header(Constants.LOCATION, location); + status(statusCode); + if (mode != Mode.EXCHANGE) { + throw new RedirectException("Redirect"); + } else { + performRedirect(); } - return map; } @Override - public String header(String key) { - return header(exchange.getRequestHeaders(), key); + public Context removeCookie(String name) { + header(SET_COOKIE, Cookie.expired(name).path("/").toString()); + return this; } @Override - public Context header(String key, String value) { - exchange.getResponseHeaders().add(key, value); + public Context removeCookie(String name, String path) { + header(SET_COOKIE, Cookie.expired(name).path(path).toString()); return this; } @Override - public String host() { - return header(HeaderKeys.HOST); + public Context render(String name, Map model) { + mgr.render(this, name, model); + return this; } @Override - public String ip() { - final InetSocketAddress remote = exchange.getRemoteAddress(); - if (remote == null) { - return ""; - } - InetAddress address = remote.getAddress(); - return address == null ? remote.getHostString() : address.getHostAddress(); + public String responseHeader(String key) { + return header(exchange.getResponseHeaders(), key); } @Override - public boolean isMultipart() { - // not really supported - return false; + public boolean responseSent() { + return exchange.getResponseCode() > 1; } @Override - public boolean isMultipartFormData() { - // not really supported - return false; + public Set routeRoles() { + return roles; } @Override - public String method() { - return exchange.getRequestMethod(); + public String scheme() { + return mgr.scheme(); } - @Override - public String path() { - return path; + void setMode(Mode type) { + this.mode = type; } @Override - public int port() { - return exchange.getLocalAddress().getPort(); + public SSLSession sslSession() { + return exchange instanceof HttpsExchange ex ? ex.getSSLSession() : null; } @Override - public String protocol() { - return exchange.getProtocol(); + public int status() { + return statusCode; } @Override - public UploadedFile uploadedFile(String name) { - throw new UnsupportedOperationException(); + public Context status(int statusCode) { + this.statusCode = statusCode; + return this; } - @Override - public List uploadedFiles(String name) { - throw new UnsupportedOperationException(); + int statusCode() { + return statusCode == 0 ? 200 : statusCode; } @Override - public List uploadedFiles() { - throw new UnsupportedOperationException(); + public void text(String content) { + contentType(TEXT_PLAIN_UTF8); + write(content); } @Override - public OutputStream outputStream() { - return mgr.createOutputStream(this); + public URI uri() { + return exchange.getRequestURI(); } @Override - public InputStream inputStream() { - return exchange.getRequestBody(); + public void write(byte[] bufferBytes, int length) { + try (var os = exchange.getResponseBody()) { + exchange.sendResponseHeaders(statusCode(), length == 0 ? -1 : length); + os.write(bufferBytes, 0, length); + } catch (IOException e) { + throw new UncheckedIOException(e); + } } @Override - public void setMode(Routing.Type type) { - this.mode = type; + public void write(InputStream is) { + try (is; var os = outputStream()) { + is.transferTo(os); + } catch (IOException e) { + throw new UncheckedIOException(e); + } } - HttpExchange exchange() { - return exchange; + @Override + public void write(String content) { + write(content.getBytes(StandardCharsets.UTF_8)); } + @Override + public JsonService jsonService() { + return mgr.jsonService(); + } } diff --git a/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/JdkJexServer.java b/avaje-jex/src/main/java/io/avaje/jex/core/JdkJexServer.java similarity index 69% rename from avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/JdkJexServer.java rename to avaje-jex/src/main/java/io/avaje/jex/core/JdkJexServer.java index d07d019c..4418792a 100644 --- a/avaje-jex-jdk/src/main/java/io/avaje/jex/jdk/JdkJexServer.java +++ b/avaje-jex/src/main/java/io/avaje/jex/core/JdkJexServer.java @@ -1,21 +1,21 @@ -package io.avaje.jex.jdk; +package io.avaje.jex.core; + +import java.lang.System.Logger.Level; import com.sun.net.httpserver.HttpServer; -import io.avaje.applog.AppLog; + import io.avaje.jex.AppLifecycle; import io.avaje.jex.Jex; -import java.lang.System.Logger.Level; - -class JdkJexServer implements Jex.Server { +final class JdkJexServer implements Jex.Server { - private static final System.Logger log = AppLog.getLogger("io.avaje.jex"); + private static final System.Logger log = System.getLogger("io.avaje.jex"); private final HttpServer server; private final AppLifecycle lifecycle; - private final BaseHandler handler; + private final RoutingHandler handler; - JdkJexServer(HttpServer server, AppLifecycle lifecycle, BaseHandler handler) { + JdkJexServer(HttpServer server, AppLifecycle lifecycle, RoutingHandler handler) { this.server = server; this.lifecycle = lifecycle; this.handler = handler; @@ -36,4 +36,9 @@ public void shutdown() { log.log(Level.TRACE, "server http listeners stopped"); lifecycle.status(AppLifecycle.Status.STOPPED); } + + @Override + public int port() { + return server.getAddress().getPort(); + } } diff --git a/avaje-jex/src/main/java/io/avaje/jex/core/JsonbJsonService.java b/avaje-jex/src/main/java/io/avaje/jex/core/JsonbJsonService.java deleted file mode 100644 index a3825c50..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/core/JsonbJsonService.java +++ /dev/null @@ -1,60 +0,0 @@ -package io.avaje.jex.core; - -import io.avaje.jex.spi.JsonService; -import io.avaje.jex.spi.SpiContext; -import io.avaje.jsonb.JsonType; -import io.avaje.jsonb.JsonWriter; -import io.avaje.jsonb.Jsonb; - -import java.util.Iterator; - -/** - * Provides JsonService using avaje-jsonb. - */ -public class JsonbJsonService implements JsonService { - - private final Jsonb jsonb; - - /** - * Create with defaults for Jsonb. - */ - public JsonbJsonService() { - this.jsonb = Jsonb.builder().build(); - } - - /** - * Create with a Jsonb instance that might have custom configuration. - */ - public JsonbJsonService(Jsonb jsonb) { - this.jsonb = jsonb; - } - - @Override - public T jsonRead(Class clazz, SpiContext ctx) { - // TODO: Handle gzipped content - return jsonb.type(clazz).fromJson(ctx.inputStream()); - } - - @Override - public void jsonWrite(Object bean, SpiContext ctx) { - // gzip compression etc ? - jsonb.toJson(bean, ctx.outputStream()); - } - - @Override - public void jsonWriteStream(Iterator iterator, SpiContext ctx) { - try (JsonWriter writer = jsonb.writer(ctx.outputStream())) { - writer.pretty(false); - if (iterator.hasNext()) { - T first = iterator.next(); - JsonType type = jsonb.typeOf(first); - type.toJson(first, writer); - writer.writeNewLine(); - while (iterator.hasNext()) { - type.toJson(iterator.next(), writer); - writer.writeNewLine(); - } - } - } - } -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/core/Mode.java b/avaje-jex/src/main/java/io/avaje/jex/core/Mode.java new file mode 100644 index 00000000..1b242a40 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/core/Mode.java @@ -0,0 +1,8 @@ +package io.avaje.jex.core; + +/** status of the request */ +enum Mode { + BEFORE, + EXCHANGE, + AFTER +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/core/RoutingHandler.java b/avaje-jex/src/main/java/io/avaje/jex/core/RoutingHandler.java new file mode 100644 index 00000000..f224049d --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/core/RoutingHandler.java @@ -0,0 +1,67 @@ +package io.avaje.jex.core; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; + +import io.avaje.jex.http.HttpFilter; +import io.avaje.jex.http.NotFoundException; +import io.avaje.jex.routes.SpiRoutes; + +final class RoutingHandler implements HttpHandler { + + private final SpiRoutes routes; + private final ServiceManager mgr; + private final List filters; + + RoutingHandler(SpiRoutes routes, ServiceManager mgr) { + this.mgr = mgr; + this.routes = routes; + this.filters = routes.filters(); + } + + void waitForIdle(long maxSeconds) { + routes.waitForIdle(maxSeconds); + } + + @Override + public void handle(HttpExchange exchange) { + final var uri = exchange.getRequestURI().getPath(); + final var routeType = mgr.lookupRoutingType(exchange.getRequestMethod()); + final var route = routes.match(routeType, uri); + + if (route == null) { + var ctx = new JdkContext(mgr, exchange, uri, Set.of()); + mgr.handleException( + ctx, + new NotFoundException( + "No route matching http method %s, with path %s".formatted(routeType.name(), uri))); + } else { + route.inc(); + try { + final Map params = route.pathParams(uri); + JdkContext ctx = new JdkContext(mgr, exchange, route.matchPath(), params, route.roles()); + try { + ctx.setMode(Mode.BEFORE); + new BaseFilterChain(filters.iterator(), route.handler(), ctx, mgr).proceed(); + handleNoResponse(exchange); + } catch (Exception e) { + mgr.handleException(ctx, e); + } + } finally { + route.dec(); + exchange.close(); + } + } + } + + private void handleNoResponse(HttpExchange exchange) throws IOException { + if (exchange.getResponseCode() < 1) { + exchange.sendResponseHeaders(204, -1); + } + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/core/ServiceManager.java b/avaje-jex/src/main/java/io/avaje/jex/core/ServiceManager.java new file mode 100644 index 00000000..8f6509d4 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/core/ServiceManager.java @@ -0,0 +1,230 @@ +package io.avaje.jex.core; + +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.System.Logger.Level; +import java.lang.reflect.Type; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import io.avaje.jex.Jex; +import io.avaje.jex.Routing; +import io.avaje.jex.compression.CompressedOutputStream; +import io.avaje.jex.compression.CompressionConfig; +import io.avaje.jex.core.json.JacksonJsonService; +import io.avaje.jex.core.json.JsonbJsonService; +import io.avaje.jex.http.Context; +import io.avaje.jex.routes.UrlDecode; +import io.avaje.jex.spi.JsonService; +import io.avaje.jex.spi.TemplateRender; + +/** Core service methods available to Context implementations. */ +final class ServiceManager { + + private static final System.Logger log = System.getLogger("io.avaje.jex"); + + private final CompressionConfig compressionConfig; + private final JsonService jsonService; + private final ExceptionManager exceptionHandler; + private final TemplateManager templateManager; + private final String scheme; + private final int bufferInitial; + private final long bufferMax; + + static ServiceManager create(Jex jex) { + return new Builder(jex).build(); + } + + ServiceManager( + CompressionConfig compressionConfig, + JsonService jsonService, + ExceptionManager manager, + TemplateManager templateManager, + String scheme, + long bufferMax, + int bufferInitial) { + this.compressionConfig = compressionConfig; + this.jsonService = jsonService; + this.exceptionHandler = manager; + this.templateManager = templateManager; + this.scheme = scheme; + this.bufferInitial = bufferInitial; + this.bufferMax = bufferMax; + } + + OutputStream createOutputStream(JdkContext jdkContext) { + var out = new BufferedOutStream(jdkContext, bufferInitial, bufferMax); + if (compressionConfig.compressionEnabled()) { + return new CompressedOutputStream(compressionConfig, jdkContext, out); + } + return out; + } + + JsonService jsonService() { + return jsonService; + } + + T fromJson(Type type, InputStream is) { + return jsonService.fromJson(type, is); + } + + T fromJson(Type type, byte[] data) { + return jsonService.fromJson(type, data); + } + + void toJson(Object bean, OutputStream os) { + jsonService.toJson(bean, os); + } + + void toJsonStream(Stream stream, OutputStream os) { + try (stream) { + jsonService.toJsonStream(stream.iterator(), os); + } + } + + void toJsonStream(Iterator iterator, OutputStream os) { + try { + jsonService.toJsonStream(iterator, os); + } finally { + maybeClose(iterator); + } + } + + void maybeClose(Object iterator) { + if (iterator instanceof AutoCloseable closeable) { + try { + closeable.close(); + } catch (Exception e) { + throw new RuntimeException("Error closing iterator " + iterator, e); + } + } + } + + Routing.Type lookupRoutingType(String method) { + try { + return Routing.Type.valueOf(method); + } catch (Exception e) { + return null; + } + } + + void handleException(JdkContext ctx, Exception t) { + exceptionHandler.handle(ctx, t); + } + + void render(Context ctx, String name, Map model) { + templateManager.render(ctx, name, model); + } + + Charset requestCharset(Context ctx) { + return parseCharset(ctx.header(Constants.CONTENT_TYPE)); + } + + static Charset parseCharset(String header) { + if (header != null) { + for (String val : header.split(";")) { + val = val.trim(); + if (val.regionMatches(true, 0, "charset", 0, "charset".length())) { + return Charset.forName(val.split("=")[1].trim()); + } + } + } + return StandardCharsets.UTF_8; + } + + Map> formParamMap(Context ctx, Charset charset) { + return parseParamMap(ctx.body(), charset); + } + + Map> parseParamMap(String body, Charset charset) { + if (body == null || body.isEmpty()) { + return Collections.emptyMap(); + } + Map> map = new LinkedHashMap<>(); + for (String pair : body.split("&")) { + final String[] split1 = pair.split("=", 2); + String key = UrlDecode.decode(split1[0], charset); + String val = split1.length > 1 ? UrlDecode.decode(split1[1], charset) : ""; + map.computeIfAbsent(key, s -> new ArrayList<>()).add(val); + } + return map; + } + + String scheme() { + return scheme; + } + + private static final class Builder { + + private final Jex jex; + + Builder(Jex jex) { + this.jex = jex; + } + + ServiceManager build() { + return new ServiceManager( + jex.config().compression(), + initJsonService(), + new ExceptionManager(jex.routing().errorHandlers()), + initTemplateMgr(), + jex.config().scheme(), + jex.config().maxStreamBufferSize(), + jex.config().initialStreamBufferSize()); + } + + JsonService initJsonService() { + final JsonService jsonService = jex.config().jsonService(); + if (jsonService != null) { + return jsonService; + } + + var json = CoreServiceLoader.jsonService().orElseGet(this::defaultJsonService); + + if (json == null) log.log(Level.WARNING, "No Json library configured"); + + return json; + } + + /** Create a reasonable default JsonService if Jackson or avaje-jsonb are present. */ + JsonService defaultJsonService() { + if (detectJsonb()) { + return new JsonbJsonService(); + } + return detectJackson() ? new JacksonJsonService() : null; + } + + boolean detectJackson() { + return detectTypeExists("com.fasterxml.jackson.databind.ObjectMapper"); + } + + boolean detectJsonb() { + return detectTypeExists("io.avaje.jsonb.Jsonb"); + } + + private boolean detectTypeExists(String className) { + try { + Class.forName(className); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } + + TemplateManager initTemplateMgr() { + TemplateManager mgr = new TemplateManager(); + mgr.register(jex.config().renderers()); + for (TemplateRender render : CoreServiceLoader.getRenders()) { + mgr.registerDefault(render); + } + return mgr; + } + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/core/TemplateManager.java b/avaje-jex/src/main/java/io/avaje/jex/core/TemplateManager.java index 76fd4f76..f72c5f33 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/core/TemplateManager.java +++ b/avaje-jex/src/main/java/io/avaje/jex/core/TemplateManager.java @@ -1,7 +1,7 @@ package io.avaje.jex.core; -import io.avaje.jex.Context; -import io.avaje.jex.TemplateRender; +import io.avaje.jex.http.Context; +import io.avaje.jex.spi.TemplateRender; import java.util.HashMap; import java.util.HashSet; @@ -11,29 +11,26 @@ /** * Render templates typically as html. */ -public class TemplateManager { +final class TemplateManager { private final Map map = new HashMap<>(); - private final Set> renderTypes = new HashSet<>(); /** * Register all the extension renderer pairs. */ - public void register(Map source) { + void register(Map source) { map.putAll(source); - map.values().stream().forEach(templateRender -> renderTypes.add(templateRender.getClass())); + map.values().forEach(templateRender -> renderTypes.add(templateRender.getClass())); } /** * Auto register via ServiceLoader if it has not already been explicitly registered. */ - public void registerDefault(TemplateRender render) { + void registerDefault(TemplateRender render) { if (!renderTypes.contains(render.getClass())) { for (String extension : render.defaultExtensions()) { - if (!map.containsKey(extension)) { - map.put(extension, render); - } + map.computeIfAbsent(extension, k->render); } } } @@ -41,7 +38,7 @@ public void registerDefault(TemplateRender render) { /** * Register an extension and renderer. */ - public void register(String extn, TemplateRender renderer) { + void register(String extn, TemplateRender renderer) { map.put(extn, renderer); } @@ -52,7 +49,7 @@ public void register(String extn, TemplateRender renderer) { * @param name The name of the template * @param model The model key value pairs to render use with the template */ - public void render(Context ctx, String name, Map model) { + void render(Context ctx, String name, Map model) { final String extn = extension(name); if (extn == null) { throw new IllegalArgumentException("No extension, not handled yet - " + name); diff --git a/avaje-jex/src/main/java/io/avaje/jex/core/json/JacksonJsonService.java b/avaje-jex/src/main/java/io/avaje/jex/core/json/JacksonJsonService.java new file mode 100644 index 00000000..ac8ee4b5 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/core/json/JacksonJsonService.java @@ -0,0 +1,104 @@ +package io.avaje.jex.core.json; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.lang.reflect.Type; +import java.util.Iterator; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.avaje.jex.spi.JsonService; + +/** Jackson JsonService */ +public final class JacksonJsonService implements JsonService { + + private final ObjectMapper mapper; + private final Map javaTypes = new ConcurrentHashMap<>(); + + /** Create with defaults for Jackson */ + public JacksonJsonService() { + this.mapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + } + + /** Create with a Jackson instance that might have custom configuration. */ + public JacksonJsonService(ObjectMapper mapper) { + this.mapper = mapper; + } + + @Override + public T fromJson(Type type, InputStream is) { + try { + final var javaType = javaType(type); + return mapper.readValue(is, javaType); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public T fromJson(Type type, byte[] data) { + try { + final var javaType = javaType(type); + return mapper.readValue(data, javaType); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private JavaType javaType(Type type) { + return javaTypes.computeIfAbsent(type.getTypeName(), k -> mapper.getTypeFactory().constructType(type)); + } + + @Override + public void toJson(Object bean, OutputStream os) { + try { + try (var generator = mapper.createGenerator(os)) { + // only flush to underlying OutputStream on success + generator.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET); + generator.disable(JsonGenerator.Feature.FLUSH_PASSED_TO_STREAM); + generator.disable(JsonGenerator.Feature.AUTO_CLOSE_JSON_CONTENT); + mapper.writeValue(generator, bean); + } + os.close(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public String toJsonString(Object bean) { + try { + return mapper.writeValueAsString(bean); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public void toJsonStream(Iterator iterator, OutputStream os) { + try (var generator = mapper.createGenerator(os)) { + generator.setPrettyPrinter(null); + while (iterator.hasNext()) { + write(iterator, generator); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private void write(Iterator iterator, final JsonGenerator generator) { + try { + mapper.writeValue(generator, iterator.next()); + generator.writeRaw('\n'); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/core/json/JsonbJsonService.java b/avaje-jex/src/main/java/io/avaje/jex/core/json/JsonbJsonService.java new file mode 100644 index 00000000..e07f4bc3 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/core/json/JsonbJsonService.java @@ -0,0 +1,64 @@ +package io.avaje.jex.core.json; + +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.reflect.Type; +import java.util.Iterator; + +import io.avaje.jex.spi.JsonService; +import io.avaje.json.JsonWriter; +import io.avaje.jsonb.JsonType; +import io.avaje.jsonb.Jsonb; + +/** Provides JsonService using avaje-jsonb. */ +public final class JsonbJsonService implements JsonService { + + private final Jsonb jsonb; + + /** Create with defaults for Jsonb. */ + public JsonbJsonService() { + this.jsonb = Jsonb.builder().build(); + } + + /** Create with a Jsonb instance that might have custom configuration. */ + public JsonbJsonService(Jsonb jsonb) { + this.jsonb = jsonb; + } + + @Override + public T fromJson(Type clazz, InputStream is) { + return jsonb.type(clazz).fromJson(is); + } + + @Override + public T fromJson(Type clazz, byte[] data) { + return jsonb.type(clazz).fromJson(data); + } + + @Override + public void toJson(Object bean, OutputStream os) { + jsonb.toJson(bean, new NoFlushJsonOutput(os)); + } + + @Override + public String toJsonString(Object bean) { + return jsonb.toJson(bean); + } + + @Override + public void toJsonStream(Iterator iterator, OutputStream os) { + try (JsonWriter writer = jsonb.writer(os)) { + writer.pretty(false); + if (iterator.hasNext()) { + T first = iterator.next(); + JsonType type = jsonb.typeOf(first); + type.toJson(first, writer); + writer.writeNewLine(); + while (iterator.hasNext()) { + type.toJson(iterator.next(), writer); + writer.writeNewLine(); + } + } + } + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/core/json/JsonbOutput.java b/avaje-jex/src/main/java/io/avaje/jex/core/json/JsonbOutput.java new file mode 100644 index 00000000..a977ea17 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/core/json/JsonbOutput.java @@ -0,0 +1,62 @@ +package io.avaje.jex.core.json; + +import java.io.IOException; +import java.io.OutputStream; + +import io.avaje.jex.http.Context; +import io.avaje.json.stream.JsonOutput; + +/** + * avaje-jsonb output that allows for writing fixed length content + * straight from the avaje-jsonb buffer, avoiding the jex side buffer. + */ +public final class JsonbOutput implements JsonOutput { + + private final Context context; + private OutputStream os; + + public static JsonOutput of(Context context) { + return new JsonbOutput(context); + } + + private JsonbOutput(Context context) { + this.context = context; + } + + @Override + public void write(byte[] content, int offset, int length) throws IOException { + if (os == null) { + // exceeds the avaje-jsonb buffer size + os = context.outputStream(); + } + os.write(content, offset, length); + } + + @Override + public void writeLast(byte[] content, int offset, int length) throws IOException { + if (os == null) { + // write as fixed length content straight from the avaje-jsonb buffer + context.write(content, length); + } else { + os.write(content, offset, length); + } + } + + @Override + public void flush() { + // shouldn't manually flush + } + + @Override + public void close() throws IOException { + if (os != null) { + os.close(); + } + } + + @Override + public OutputStream unwrapOutputStream() { + return context.outputStream(); + } + +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/core/json/NoFlushJsonOutput.java b/avaje-jex/src/main/java/io/avaje/jex/core/json/NoFlushJsonOutput.java new file mode 100644 index 00000000..9c877b79 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/core/json/NoFlushJsonOutput.java @@ -0,0 +1,35 @@ +package io.avaje.jex.core.json; + +import java.io.IOException; +import java.io.OutputStream; + +import io.avaje.json.stream.JsonOutput; + +final class NoFlushJsonOutput implements JsonOutput { + + private final OutputStream outputStream; + + NoFlushJsonOutput(OutputStream outputStream) { + this.outputStream = outputStream; + } + + @Override + public void write(byte[] content, int offset, int length) throws IOException { + outputStream.write(content, offset, length); + } + + @Override + public void flush() throws IOException { + // no flush + } + + @Override + public void close() throws IOException { + outputStream.close(); + } + + @Override + public OutputStream unwrapOutputStream() { + return outputStream; + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/core/json/package-info.java b/avaje-jex/src/main/java/io/avaje/jex/core/json/package-info.java new file mode 100644 index 00000000..83defa32 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/core/json/package-info.java @@ -0,0 +1,2 @@ +/** Optional JsonServices */ +package io.avaje.jex.core.json; diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/BadGatewayResponse.java b/avaje-jex/src/main/java/io/avaje/jex/http/BadGatewayResponse.java deleted file mode 100644 index 96ab8597..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/http/BadGatewayResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package io.avaje.jex.http; - -public class BadGatewayResponse extends HttpResponseException { - - public BadGatewayResponse(String message) { - super(502, message); - } - - public BadGatewayResponse() { - super(502, "Bad gateway"); - } - -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/BadRequestException.java b/avaje-jex/src/main/java/io/avaje/jex/http/BadRequestException.java new file mode 100644 index 00000000..c8d688fa --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/http/BadRequestException.java @@ -0,0 +1,15 @@ +package io.avaje.jex.http; + +/** Thrown when request is invalid */ +public class BadRequestException extends HttpResponseException { + + /** Create with a message. */ + public BadRequestException(String message) { + super(400, message); + } + + /** Create with a response that will sent as JSON. */ + public BadRequestException(Object jsonResponse) { + super(400, jsonResponse); + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/BadRequestResponse.java b/avaje-jex/src/main/java/io/avaje/jex/http/BadRequestResponse.java deleted file mode 100644 index 4f3096f1..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/http/BadRequestResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package io.avaje.jex.http; - -public class BadRequestResponse extends HttpResponseException { - - public BadRequestResponse(String message) { - super(400, message); - } - - public BadRequestResponse() { - super(400, "Bad request"); - } - -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/ConflictResponse.java b/avaje-jex/src/main/java/io/avaje/jex/http/ConflictResponse.java deleted file mode 100644 index 00286ae1..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/http/ConflictResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package io.avaje.jex.http; - -public class ConflictResponse extends HttpResponseException { - - public ConflictResponse(String message) { - super(409, message); - } - - public ConflictResponse() { - super(409, "Conflict"); - } - -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/ContentType.java b/avaje-jex/src/main/java/io/avaje/jex/http/ContentType.java new file mode 100644 index 00000000..95d0ca1c --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/http/ContentType.java @@ -0,0 +1,76 @@ +package io.avaje.jex.http; + +/** Common Content Types */ +public enum ContentType { + TEXT_PLAIN("text/plain"), + TEXT_CSS("text/css"), + TEXT_CSV("text/csv"), + TEXT_HTML("text/html"), + TEXT_JS("text/javascript"), + TEXT_MARKDOWN("text/markdown"), + TEXT_PROPERTIES("text/x-java-properties"), + TEXT_XML("text/xml"), + + IMAGE_AVIF("image/avif"), + IMAGE_BMP("image/bmp"), + IMAGE_GIF("image/gif"), + IMAGE_ICO("image/vnd.microsoft.icon"), + IMAGE_JPEG("image/jpeg"), + IMAGE_PNG("image/png"), + IMAGE_SVG("image/svg+xml"), + IMAGE_TIFF("image/tiff"), + IMAGE_WEBP("image/webp"), + + AUDIO_AAC("audio/aac"), + AUDIO_MIDI("audio/midi"), + AUDIO_MPEG("audio/mpeg"), + AUDIO_OGA("audio/ogg"), + AUDIO_OPUS("audio/opus"), + AUDIO_WAV("audio/wav"), + AUDIO_WEBA("audio/weba"), + + VIDEO_AVI("video/x-msvideo"), + VIDEO_MP4("video/mp4"), + VIDEO_MPEG("video/mpeg"), + VIDEO_OGG("video/ogg"), + VIDEO_WEBM("video/webm"), + + FONT_OTF("font/otf"), + FONT_TTF("font/ttf"), + FONT_WOFF("font/woff"), + FONT_WOFF2("font/woff2"), + + APPLICATION_OCTET_STREAM("application/octet-stream"), + APPLICATION_BZ("application/x-bzip"), + APPLICATION_BZ2("application/x-bzip2"), + APPLICATION_CDN("application/cdn"), + APPLICATION_DOC("application/msword"), + APPLICATION_DOCX("application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + APPLICATION_EPUB("application/epub+zip"), + APPLICATION_GZ("application/gzip"), + APPLICATION_JSON("application/json"), + APPLICATION_MPKG("application/vnd.apple.installer+xml"), + APPLICATION_JAR("application/java-archive"), + APPLICATION_PDF("application/pdf"), + APPLICATION_POM("application/xml"), + APPLICATION_RAR("application/vnd.rar"), + APPLICATION_SH("application/x-sh"), + APPLICATION_SWF("application/x-shockwave-flash"), + APPLICATION_TAR("application/x-tar"), + APPLICATION_XHTML("application/xhtml+xml"), + APPLICATION_YAML("application/yaml"), + APPLICATION_ZIP("application/zip"), + APPLICATION_7Z("application/x-7z-compressed"), + + MULTIPART_FORM_DATA("multipart/form-data"); + + private final String content; + + ContentType(String contentType) { + this.content = contentType; + } + + public String contentType() { + return content; + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/Context.java b/avaje-jex/src/main/java/io/avaje/jex/http/Context.java new file mode 100644 index 00000000..d4ccf5b1 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/http/Context.java @@ -0,0 +1,721 @@ +package io.avaje.jex.http; + +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; + +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.reflect.Type; +import java.net.URI; +import java.time.Duration; +import java.time.ZonedDateTime; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import javax.net.ssl.SSLSession; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; + +import io.avaje.jex.core.Constants; +import io.avaje.jex.core.json.JsonbOutput; +import io.avaje.jex.security.BasicAuthCredentials; +import io.avaje.jex.security.Role; +import io.avaje.jex.spi.JsonService; +import io.avaje.jsonb.JsonType; + +/** Provides access to functions for handling the request and response. */ +public interface Context { + + /** + * Gets the attribute with the specified key from the request. + * + * @param The type of the attribute. + * @param key The attribute key. + * @return The attribute value, or null if not found. + */ + T attribute(String key); + + /** + * Sets an attribute on the request, accessible to other handlers in the request lifecycle. + * + * @param key The attribute key. + * @param value The attribute value. + */ + Context attribute(String key, Object value); + + /** + * Gets basic-auth credentials from the request. + * + * @return The Base64 decoded username and password from the + * Authorization header, or null if no header is sent + * + * @throws IllegalStateException if the Authorization header is malformed + */ + BasicAuthCredentials basicAuthCredentials(); + + /** Return the request body as String. */ + String body(); + + /** + * Returns the request body as a byte array. + * + * @return The request body as a byte array. + */ + byte[] bodyAsBytes(); + + /** + * Return the request body as bean. + * + * @param beanType The bean type + */ + default T bodyAsClass(Class beanType) { + return bodyAsType(beanType); + } + + /** + * Returns the request body as an input stream. + * + * @return The request body as an input stream. + * @implNote will return an empty stream if any of the various body methods were called + */ + InputStream bodyAsInputStream(); + + /** + * Return the request body as bean. + * + * @param beanType The bean type + */ + T bodyAsType(Type beanType); + + /** + * Return the request body as bean using {@link #bodyAsInputStream()}. + * + * @param beanType The bean type + */ + default T bodyStreamAsClass(Class beanType) { + return bodyAsType(beanType); + } + + /** + * Return the request body as bean of the given type using {@link #bodyAsInputStream()}. + * + * @param beanType The bean type + */ + T bodyStreamAsType(Type beanType); + + /** Return the request content length. */ + long contentLength(); + + /** Return the request content type. */ + String contentType(); + + /** Set the response content type. */ + Context contentType(String contentType); + + /** Set the response content type. */ + default Context contentType(ContentType contentType) { + return contentType(contentType.contentType()); + } + + /** Return the request context path. */ + String contextPath(); + + /** + * Sets a cookie using the provided {@link Cookie} object. + * + * @param cookie The cookie object to set. + */ + Context cookie(Cookie cookie); + + /** + * Returns the value of a cookie with the specified name from the request. + * + * @param name The name of the cookie. + * @return The value of the cookie, or null if the cookie is not found. + */ + String cookie(String name); + + /** + * Sets a cookie with the specified name and value, with no expiration date. + * + * @param name The name of the cookie. + * @param value The value of the cookie. + */ + Context cookie(String name, String value); + + /** + * Sets a cookie with the specified name, value, and maximum age in seconds. + * + * @param name The name of the cookie. + * @param value The value of the cookie. + * @param maxAge The maximum age of the cookie in seconds. + */ + Context cookie(String name, String value, int maxAge); + + /** + * Returns a map containing all the cookie names and their corresponding values from the request. + * + * @return A map of cookie names to their values. + */ + Map cookieMap(); + + /** Return the underlying JDK {@link HttpExchange} object backing the context */ + HttpExchange exchange(); + + /** Return the first form param value for the specified key or null. */ + default String formParam(String key) { + return formParam(key, null); + } + + /** Return the first form param value for the specified key or the default value. */ + default String formParam(String key, String defaultValue) { + final var values = formParamMap().get(key); + return values == null || values.isEmpty() ? defaultValue : values.getFirst(); + } + + /** Returns a map with all the form param keys and values. */ + Map> formParamMap(); + + /** Return the form params for the specified key, or empty list. */ + default List formParams(String key) { + final var values = formParamMap().get(key); + return values != null ? values : emptyList(); + } + + /** Return the full request url, including query string (if present) */ + default String fullUrl() { + var uri = uri().toString(); + return uri.charAt(0) != '/' ? uri : scheme() + "://" + host() + uri; + } + + /** + * Return the request header value by name. + * + * @param key The first value of the header + */ + String header(String key); + + /** + * Return the request headers. + * + * @param key all values of the header key + */ + List headerValues(String key); + + /** + * Set the response header. + * + * @param key The header key + * @param value The header value + */ + Context header(String key, List value); + + /** + * Set the response header. + * + * @param key The header key + * @param value The header value + */ + Context header(String key, String value); + + /** + * Return all the request headers as a map. + * + * @return all the headers as a single value Map + */ + Map headerMap(); + + /** + * Sets the response headers using the provided map. + * + * @param headers A map containing the header names as keys and their corresponding values as + * lists. + * @return The updated context object. + */ + Context headerMap(Map> headers); + + /** + * Return underlying request headers. + * + * @return the request headers + */ + Headers requestHeaders(); + + /** + * Return underlying response headers. + * + * @return the response headers + */ + Headers responseHeaders(); + + /** Add the response headers using the provided map. */ + default Context headers(Map headers) { + headers.forEach(this::header); + return this; + } + + /** + * Returns the host name of the request. + * + * @return The host name of the request, or null if not available. + */ + String host(); + + /** Write html content to the response. */ + void html(String content); + + /** + * Returns the IP address of the client making the request. + * + * @return The IP address of the client. + */ + String ip(); + + /** + * Set the content type as application/json and write the response. + * + * @param bean the object to serialize and write + */ + void json(Object bean); + + /** + * Optimized json write using avaje jsonb + * + * @param jsonType the serializer for the value. + * @param value the pojo to serialize + */ + default void jsonb(JsonType jsonType, T value) { + jsonType.toJson(value, JsonbOutput.of(this.contentType(ContentType.APPLICATION_JSON))); + } + + /** + * Write the stream as a JSON stream with new line delimiters {@literal + * application/x-json-stream}. + * + * @param iterator The iterator of beans to write as json + */ + void jsonStream(Iterator iterator); + + /** + * Write the stream as a JSON stream with new line delimiters {@literal + * application/x-json-stream}. + * + * @param stream The stream of beans to write as json + */ + void jsonStream(Stream stream); + + /** + * Returns the configured {@link JsonService} instance.} + * + * @return The json service if configured. null otherwise. + */ + JsonService jsonService(); + + /** + * Returns the matched path as a raw expression, without any parameter substitution. + * + * @return The matched path as a raw string. + */ + String matchedPath(); + + /** + * Returns the HTTP method used in the request (e.g., GET, POST, PUT, DELETE). + * + * @return The HTTP method of the request. + */ + String method(); + + /** + * Return the outputStream to write content. It is expected that + * the {@link #contentType(String)} has been set prior to obtaining + * and writing to the outputStream. + * + * @return The outputStream to write content to. + */ + OutputStream outputStream(); + + /** + * Returns the path part of the request URI. + * + * @return The path part of the request URI. + */ + String path(); + + /** + * Return the path parameter. + * + * @param name The path parameter name. + */ + String pathParam(String name); + + /** Return all the path parameters as a map. */ + Map pathParamMap(); + + /** + * Returns the port number used in the request. + * + * @return The port number of the request. + */ + int port(); + + /** + * Returns the protocol used in the request (e.g., HTTP/1.1). + * + * @return The protocol of the request. + */ + String protocol(); + + /** + * Return the first query parameter value. + * + * @param name The query parameter name + */ + String queryParam(String name); + + /** + * Return the first query parameter value or the default value if it does not exist. + * + * @param name The query parameter name + */ + default String queryParam(String name, String defaultValue) { + String val = queryParam(name); + return val != null ? val : defaultValue; + } + + /** + * Return all the query parameters as a map. + * + *

Note this returns the first value for any given key if that key has multiple values. + */ + Map queryParamMap(); + + /** Return all the query parameters for the given parameter name. */ + List queryParams(String name); + + /** Return the request query string, or null. */ + String queryString(); + + /** + * Redirects the client to the specified location using a 302 (Found) status code. + * + * @param location The URL to redirect to. + */ + void redirect(String location); + + /** + * Redirects the client to the specified location using the given HTTP status code. + * + * @param location The URL to redirect to. + * @param httpStatusCode The HTTP status code to use for the redirect. + */ + void redirect(String location, int httpStatusCode); + + /** + * Removes a cookie with the specified name. + * + * @param name The name of the cookie to remove. + */ + Context removeCookie(String name); + + /** + * Removes a cookie with the specified name and path. + * + * @param name The name of the cookie to remove. + * @param path The path of the cookie to remove. + */ + Context removeCookie(String name, String path); + + /** + * Render a template typically as html. + * + * @param name The template name + */ + default Context render(String name) { + return render(name, emptyMap()); + } + + /** + * Render a template typically as html with the given model. + * + * @param name The template name + * @param model The model used with the template + */ + Context render(String name, Map model); + + /** + * Returns the value of the specified response header. + * + * @param key The name of the header. + * @return The value of the header, or null if not found. + */ + String responseHeader(String key); + + /** + * Returns the value of the specified response header. + * + * @param key The name of the header. + * @return The value of the header, or null if not found. + */ + List responseHeaderValues(String key); + + /** Return true if the response has been sent. */ + boolean responseSent(); + + /** + * Returns a set of roles associated with the current route. + * + * @return A set of roles. + */ + Set routeRoles(); + + /** Return the request scheme. */ + String scheme(); + + /** + * Get the {@link SSLSession} for this exchange. + * + * @return the {@code SSLSession} + */ + SSLSession sslSession(); + + /** Return the current response status. */ + int status(); + + /** Set the status code on the response. */ + Context status(int statusCode); + + /** Set the status code on the response. */ + default Context status(HttpStatus statusCode) { + return status(statusCode.status()); + } + + /** Write plain text content to the response. */ + void text(String content); + + /** Return the request uri. */ + URI uri(); + + /** Return the request user agent, or null. */ + default String userAgent() { + return header(Constants.USER_AGENT); + } + + /** + * Writes the given bytes directly to the response. + * + * @param bytes The byte array to write. + */ + default void write(byte[] bytes) { + write(bytes, bytes.length); + } + + /** + * Writes the given length of bytes from this buffer directly to the response. + * + *

The bytes written will be from position 0 to length. + * + * @param bufferBytes The byte array to write. + * @param length The number of bytes to write from the buffer. + */ + void write(byte[] bufferBytes, int length); + + /** + * Writes the content from the given InputStream directly to the response body. + * + * @param is The input stream containing the content to write. + */ + void write(InputStream is); + + /** + * Writes the given string content directly to the response. + * + * @param content The string content to write. + */ + void write(String content); + + + /** + * This interface represents a cookie used in HTTP communication. Cookies are small pieces of data + * sent from a server to a web browser and stored on the user's computer. They can be used to + * store information about a user's session, preferences, or other data. + */ + interface Cookie { + + /** + * Cookie SameSite options. + */ + enum SameSite { + Strict, Lax, None + } + + /** + * Creates and returns a new expired cookie with the given name. This cookie will be sent to the + * browser but will be immediately discarded. It's useful for removing existing cookies. + * + * @param name The name of the cookie. + * @return A new expired cookie with the given name. + */ + static Cookie expired(String name) { + return DCookie.expired(name); + } + + /** + * Creates and returns a new cookie with the given name and value. + * + * @param name The name of the cookie. + * @param value The value to store in the cookie. + * @return A new cookie with the given name and value. + */ + static Cookie of(String name, String value) { + return DCookie.of(name, value); + } + + /** + * Returns the domain for which this cookie is valid. + * + * @return The domain associated with the cookie, or null if not set. + */ + String domain(); + + /** + * Sets the domain for which this cookie is valid. + * + * @param domain The domain for which the cookie should be valid. + * @return A new cookie instance with the updated domain. + */ + Cookie domain(String domain); + + /** + * Returns the date and time when this cookie expires. + * + * @return The expiration date and time of the cookie, or null if not set. + */ + ZonedDateTime expires(); + + /** + * Sets the date and time when this cookie expires. + * + * @param expires The date and time when the cookie should expire. + * @return A new cookie instance with the updated expires value. + */ + Cookie expires(ZonedDateTime expires); + + /** + * Indicates if the HttpOnly attribute is enabled for this cookie. + * + *

The HttpOnly attribute ensures that the cookie is inaccessible to JavaScript, helping to + * mitigate cross-site scripting (XSS) attacks. + * + * @return {@code true} if the cookie has the HttpOnly attribute enabled, {@code false} + * otherwise + */ + boolean httpOnly(); + + /** + * Sets the HttpOnly attribute for this cookie. + * + *

When enabled, the cookie will not be accessible via client-side scripts, providing + * additional security against XSS attacks. + * + * @param httpOnly {@code true} to enable the HttpOnly attribute, {@code false} to disable it + * @return this cookie instance with the updated HttpOnly attribute + */ + Cookie httpOnly(boolean httpOnly); + + /** + * Returns the maximum age (in seconds) of this cookie. An expired cookie (maxAge of 0) will be + * deleted immediately by the browser. + * + * @return The maximum age of the cookie in seconds, or null if not set. + */ + Duration maxAge(); + + /** + * Sets the maximum age (in seconds) of this cookie. An expired cookie (maxAge of 0) will be + * deleted immediately by the browser. + * + * @param maxAge The maximum age of the cookie in seconds. + * @return A new cookie instance with the updated maxAge. + */ + Cookie maxAge(Duration maxAge); + + /** + * Returns the name of this cookie. + * + * @return The name of the cookie. + */ + String name(); + + /** + * Indicates if the Partitioned attribute is enabled for this cookie. + */ + boolean partitioned(); + + /** + * Set the Partitioned attribute for this cookie. + */ + Cookie partitioned(boolean partitioned); + + /** + * Returns the path on the server for which this cookie is valid. Cookies are only sent to the + * browser if the URL path starts with this value. + * + * @return The path associated with the cookie, or null if not set. + */ + String path(); + + /** + * Sets the path on the server for which this cookie is valid. Cookies are only sent to the + * browser if the URL path starts with this value. + * + * @param path The path on the server for which the cookie should be valid. + * @return A new cookie instance with the updated path. + */ + Cookie path(String path); + + /** + * Return the SameSite setting. + */ + SameSite sameSite(); + + /** + * Set the SameSite setting for this cookie. + */ + Cookie sameSite(SameSite sameSite); + + /** + * Indicates whether this cookie should only be sent over secure connections (HTTPS). + * + * @return True if the cookie should only be sent over secure connections, false otherwise. + */ + boolean secure(); + + /** + * Sets the secure attribute for this cookie. + * + *

When enabled, the cookie will only be sent over secure HTTPS connections. + * + * @param secure {@code true} to enable the secure attribute, {@code false} to disable it + * @return this cookie instance with the updated secure attribute + */ + Cookie secure(boolean secure); + + /** + * Returns content of the cookie as a 'Set-Cookie:' header value specified by RFC6265. + */ + @Override + String toString(); + + /** + * Returns the value stored in this cookie. + * + * @return The value of the cookie. + */ + String value(); + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/DCookie.java b/avaje-jex/src/main/java/io/avaje/jex/http/DCookie.java new file mode 100644 index 00000000..33887b9a --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/http/DCookie.java @@ -0,0 +1,169 @@ +package io.avaje.jex.http; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; + +final class DCookie implements Context.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 SameSite sameSite; // ;SameSite=Strict|Lax|None + private boolean secure; // ;Secure ... e.g. use SSL + private boolean httpOnly; + private boolean partitioned; + + private DCookie(String name, String value) { + if (name == null || name.isEmpty()) { + throw new IllegalArgumentException("name required"); + } + this.name = name; + this.value = value; + } + + static Context.Cookie expired(String name) { + return new DCookie(name, "").expires(EXPIRED); + } + + static Context.Cookie of(String name, String value) { + return new DCookie(name, value); + } + + @Override + public String name() { + return name; + } + + @Override + public String value() { + return value; + } + + @Override + public String domain() { + return domain; + } + + @Override + public Context.Cookie domain(String domain) { + this.domain = domain; + return this; + } + + @Override + public Duration maxAge() { + return maxAge; + } + + @Override + public Context.Cookie maxAge(Duration maxAge) { + this.maxAge = maxAge; + return this; + } + + @Override + public ZonedDateTime expires() { + return expires; + } + + @Override + public Context.Cookie expires(ZonedDateTime expires) { + this.expires = expires; + return this; + } + + @Override + public String path() { + return path; + } + + @Override + public Context.Cookie path(String path) { + this.path = path; + return this; + } + + @Override + public boolean secure() { + return secure; + } + + @Override + public Context.Cookie secure(boolean secure) { + this.secure = secure; + return this; + } + + @Override + public boolean httpOnly() { + return httpOnly; + } + + @Override + public Context.Cookie httpOnly(boolean httpOnly) { + this.httpOnly = httpOnly; + return this; + } + + @Override + public boolean partitioned() { + return partitioned; + } + + @Override + public Context.Cookie partitioned(boolean partitioned) { + this.partitioned = partitioned; + return this; + } + + @Override + public SameSite sameSite() { + return sameSite; + } + + @Override + public Context.Cookie sameSite(SameSite sameSite) { + this.sameSite = sameSite; + return this; + } + + @Override + public String toString() { + StringBuilder result = new StringBuilder(60); + result.append(name).append('=').append(value); + if (expires != null) { + result.append(PARAM_SEPARATOR).append("Expires=").append(expires.format(RFC_1123_DATE_TIME)); + } + if ((maxAge != null) && !maxAge.isNegative() && !maxAge.isZero()) { + result.append(PARAM_SEPARATOR).append("Max-Age=").append(maxAge.getSeconds()); + } + if (domain != null) { + result.append(PARAM_SEPARATOR).append("Domain=").append(domain); + } + if (path != null) { + result.append(PARAM_SEPARATOR).append("Path=").append(path); + } + if (sameSite != null) { + result.append(PARAM_SEPARATOR).append("SameSite=").append(sameSite); + } + if (secure) { + result.append(PARAM_SEPARATOR).append("Secure"); + } + if (httpOnly) { + result.append(PARAM_SEPARATOR).append("HttpOnly"); + } + if (partitioned) { + result.append(PARAM_SEPARATOR).append("Partitioned"); + } + return result.toString(); + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/ExceptionHandler.java b/avaje-jex/src/main/java/io/avaje/jex/http/ExceptionHandler.java new file mode 100644 index 00000000..b4932c51 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/http/ExceptionHandler.java @@ -0,0 +1,21 @@ +package io.avaje.jex.http; + +import io.avaje.jex.Routing; + +/** + * The routing error handler. Can be mapped to the error cause in {@link Routing}. + * + * @param type of throwable handled by this handler + */ +@FunctionalInterface +public interface ExceptionHandler { + + /** + * Error handling consumer. Do not throw an exception from an error handler, it would make this + * error handler invalid and the exception would be ignored. + * + * @param ctx the server context + * @param exception the cause of the error + */ + void handle(Context ctx, T exception); +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/ExchangeHandler.java b/avaje-jex/src/main/java/io/avaje/jex/http/ExchangeHandler.java new file mode 100644 index 00000000..70577324 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/http/ExchangeHandler.java @@ -0,0 +1,27 @@ +package io.avaje.jex.http; + +import java.io.IOException; + +/** + * A functional interface representing an HTTP request handler. + * + *

Implementations of this interface are responsible for processing incoming HTTP requests and + * generating appropriate responses. The {@code handle} method provides access to a {@link Context} + * object, which encapsulates the request and response details. + * + * @see Context + */ +@FunctionalInterface +public interface ExchangeHandler { + + /** + * Handles the given HTTP request and generates a response. + * + *

The {@link Context} object provides access to request information such as headers, + * parameters, and body, as well as methods for constructing and sending the response. + * + * @param ctx The context object containing the request and response details. + * @throws IOException if an I/O error occurs during request processing or response generation. + */ + void handle(Context ctx) throws Exception; +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/ForbiddenResponse.java b/avaje-jex/src/main/java/io/avaje/jex/http/ForbiddenResponse.java deleted file mode 100644 index 737fe8fc..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/http/ForbiddenResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package io.avaje.jex.http; - -public class ForbiddenResponse extends HttpResponseException { - - public ForbiddenResponse(String message) { - super(403, message); - } - - public ForbiddenResponse() { - super(403, "Forbidden"); - } - -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/GatewayTimeoutResponse.java b/avaje-jex/src/main/java/io/avaje/jex/http/GatewayTimeoutResponse.java deleted file mode 100644 index efacb6da..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/http/GatewayTimeoutResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package io.avaje.jex.http; - -public class GatewayTimeoutResponse extends HttpResponseException { - - public GatewayTimeoutResponse(String message) { - super(504, message); - } - - public GatewayTimeoutResponse() { - super(504, "Gateway timeout"); - } - -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/GoneResponse.java b/avaje-jex/src/main/java/io/avaje/jex/http/GoneResponse.java deleted file mode 100644 index 279ee227..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/http/GoneResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package io.avaje.jex.http; - -public class GoneResponse extends HttpResponseException { - - public GoneResponse(String message) { - super(410, message); - } - - public GoneResponse() { - super(410, "Gone"); - } - -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/HttpFilter.java b/avaje-jex/src/main/java/io/avaje/jex/http/HttpFilter.java new file mode 100644 index 00000000..9cb1e53f --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/http/HttpFilter.java @@ -0,0 +1,63 @@ +package io.avaje.jex.http; + +import com.sun.net.httpserver.Filter; +import com.sun.net.httpserver.HttpExchange; + +/** + * A filter used to pre/post-process incoming requests. Pre-processing occurs before the + * application's exchange handler is invoked, and post-processing occurs after the exchange handler + * returns. Filters are organized in chains, and are associated with {@link Context} instances. + * + *

Each {@code HttpFilter} in the chain, invokes the next filter within its own {@link + * #filter(Context, FilterChain)} implementation. The final {@code HttpFilter} in the chain invokes + * the applications exchange handler. + */ +@FunctionalInterface +public interface HttpFilter { + + /** + * Asks this filter to pre/post-process the given request. The filter can: + * + *

    + *
  • Examine or modify the request headers. + *
  • Set attribute objects in the context, which other filters or the handler can access. + *
  • Decide to either: + *
      + *
    1. Invoke the next filter in the chain, by calling {@link FilterChain#proceed}. + *
    2. Terminate the chain of invocation, by not calling {@link + * FilterChain#proceed()}. + *
    + *
  • If option 1. above is taken, then when filter() returns all subsequent filters in the + * Chain have been called, and the response headers can be examined or modified. + *
  • If option 2. above is taken, then this Filter must use the Context to send back an + * appropriate response. + *
+ * + * @param ctx the {@code Context} of the current request + * @param chain the {@code FilterChain} which allows the next filter to be invoked + */ + void filter(Context ctx, FilterChain chain); + + /** + * Filter chain that contains all subsequent filters that are configured, as well as the final + * route. + */ + @FunctionalInterface + interface FilterChain { + + /** + * Calls the next filter in the chain, or else the user's exchange handler, if this is the final + * filter in the chain. The {@link HttpFilter} may decide to terminate the chain, by not calling + * this method. In this case, the filter must send the response to the request, because + * the application's {@linkplain HttpExchange exchange} handler will not be invoked. + */ + void proceed(); + } + + /** + * Convert a JDK {@link Filter} into a Jex HttpFilter. + */ + static HttpFilter fromJdkFilter(Filter filter) { + return new JdkFilter(filter); + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/HttpResponseException.java b/avaje-jex/src/main/java/io/avaje/jex/http/HttpResponseException.java index a5ce0d64..45ed8eee 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/http/HttpResponseException.java +++ b/avaje-jex/src/main/java/io/avaje/jex/http/HttpResponseException.java @@ -1,28 +1,44 @@ package io.avaje.jex.http; -import java.util.Collections; -import java.util.Map; - +/** + * Throwing an uncaught {@code HttpResponseException} will interrupt http processing and set the + * status code and response body with the given message or json body + */ public class HttpResponseException extends RuntimeException { - private int status; - private Map details; + private final int status; + private final Object jsonResponse; - public HttpResponseException(int status, String message, Map details) { + /** + * Create with a status and message. + * + * @param status the http status to send + * @param message the exception message that will be sent back in the response + */ + public HttpResponseException(int status, String message) { super(message); this.status = status; - this.details = details; + this.jsonResponse = null; } - public HttpResponseException(int status, String message) { - this(status, message, Collections.emptyMap()); + /** + * Create with a status and response that will sent as JSON. + * + * @param status the http status to send + * @param jsonResponse the response body that will be sent back as json + */ + public HttpResponseException(int status, Object jsonResponse) { + this.status = status; + this.jsonResponse = jsonResponse; } - public int getStatus() { + /** Return the status code. */ + public int status() { return status; } - public Map getDetails() { - return details; + /** Return the response body that will sent as JSON. */ + public Object jsonResponse() { + return jsonResponse; } } diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/HttpStatus.java b/avaje-jex/src/main/java/io/avaje/jex/http/HttpStatus.java new file mode 100644 index 00000000..d664fabe --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/http/HttpStatus.java @@ -0,0 +1,78 @@ +package io.avaje.jex.http; + +/** Http Status codes */ +public enum HttpStatus { + + // 1xx Informational + CONTINUE_100(100), + SWITCHING_PROTOCOLS_101(101), + + // 2xx Success + OK_200(200), + CREATED_201(201), + ACCEPTED_202(202), + NON_AUTHORITATIVE_INFORMATION_203(203), + NO_CONTENT_204(204), + RESET_CONTENT_205(205), + PARTIAL_CONTENT_206(206), + MULTI_STATUS_207(207), + + // 3xx Redirection + MOVED_PERMANENTLY_301(301), + FOUND_302(302), + SEE_OTHER_303(303), + NOT_MODIFIED_304(304), + USE_PROXY_305(305), + TEMPORARY_REDIRECT_307(307), + PERMANENT_REDIRECT_308(308), + + // 4xx Client Error + BAD_REQUEST_400(400), + UNAUTHORIZED_401(401), + PAYMENT_REQUIRED_402(402), + FORBIDDEN_403(403), + NOT_FOUND_404(404), + METHOD_NOT_ALLOWED_405(405), + NOT_ACCEPTABLE_406(406), + PROXY_AUTHENTICATION_REQUIRED_407(407), + REQUEST_TIMEOUT_408(408), + CONFLICT_409(409), + GONE_410(410), + LENGTH_REQUIRED_411(411), + PRECONDITION_FAILED_412(412), + REQUEST_ENTITY_TOO_LARGE_413(413), + REQUEST_URI_TOO_LONG_414(414), + UNSUPPORTED_MEDIA_TYPE_415(415), + REQUESTED_RANGE_NOT_SATISFIABLE_416(416), + EXPECTATION_FAILED_417(417), + I_AM_A_TEAPOT_418(418), + MISDIRECTED_REQUEST_421(421), + UNPROCESSABLE_CONTENT_422(422), + LOCKED_423(423), + FAILED_DEPENDENCY_424(424), + UPGRADE_REQUIRED_426(426), + PRECONDITION_REQUIRED_428(428), + TOO_MANY_REQUESTS_429(429), + + // 5xx Server Error + INTERNAL_SERVER_ERROR_500(500), + NOT_IMPLEMENTED_501(501), + BAD_GATEWAY_502(502), + SERVICE_UNAVAILABLE_503(503), + GATEWAY_TIMEOUT_504(504), + HTTP_VERSION_NOT_SUPPORTED_505(505), + INSUFFICIENT_STORAGE_507(507), + LOOP_DETECTED_508(508), + NOT_EXTENDED_510(510), + NETWORK_AUTHENTICATION_REQUIRED_511(511); + + private final int status; + + HttpStatus(int status) { + this.status = status; + } + + public int status() { + return status; + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/InternalServerErrorException.java b/avaje-jex/src/main/java/io/avaje/jex/http/InternalServerErrorException.java new file mode 100644 index 00000000..f3f5f7e9 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/http/InternalServerErrorException.java @@ -0,0 +1,15 @@ +package io.avaje.jex.http; + +/** Thrown when server has an internal error */ +public class InternalServerErrorException extends HttpResponseException { + + /** Create with a message. */ + public InternalServerErrorException(String message) { + super(500, message); + } + + /** Create with a status and response that will sent as JSON. */ + public InternalServerErrorException(Object jsonResponse) { + super(500, jsonResponse); + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/InternalServerErrorResponse.java b/avaje-jex/src/main/java/io/avaje/jex/http/InternalServerErrorResponse.java deleted file mode 100644 index 04f44c1c..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/http/InternalServerErrorResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package io.avaje.jex.http; - -public class InternalServerErrorResponse extends HttpResponseException { - - public InternalServerErrorResponse(String message) { - super(500, message); - } - - public InternalServerErrorResponse() { - super(500, "Internal server error"); - } - -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/JdkFilter.java b/avaje-jex/src/main/java/io/avaje/jex/http/JdkFilter.java new file mode 100644 index 00000000..60f69798 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/http/JdkFilter.java @@ -0,0 +1,26 @@ +package io.avaje.jex.http; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.List; + +import com.sun.net.httpserver.Filter; +import com.sun.net.httpserver.Filter.Chain; + +final class JdkFilter implements HttpFilter { + + private final Filter delegate; + + JdkFilter(Filter delegate) { + this.delegate = delegate; + } + + @Override + public void filter(Context ctx, FilterChain chain) { + try { + delegate.doFilter(ctx.exchange(), new Chain(List.of(), ex -> chain.proceed())); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/MethodNotAllowedResponse.java b/avaje-jex/src/main/java/io/avaje/jex/http/MethodNotAllowedResponse.java deleted file mode 100644 index dabff066..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/http/MethodNotAllowedResponse.java +++ /dev/null @@ -1,15 +0,0 @@ -package io.avaje.jex.http; - -import java.util.Map; - -public class MethodNotAllowedResponse extends HttpResponseException { - - public MethodNotAllowedResponse(String message, Map details) { - super(403, message, details); - } - - public MethodNotAllowedResponse(String message) { - super(403, message); - } - -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/NotFoundException.java b/avaje-jex/src/main/java/io/avaje/jex/http/NotFoundException.java new file mode 100644 index 00000000..f23d6cba --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/http/NotFoundException.java @@ -0,0 +1,15 @@ +package io.avaje.jex.http; + +/** Thrown when unable to find a route/resource */ +public class NotFoundException extends HttpResponseException { + + /** Create with a message. */ + public NotFoundException(String message) { + super(404, message); + } + + /** Create with a response that will sent as JSON. */ + public NotFoundException(Object jsonResponse) { + super(404, jsonResponse); + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/NotFoundResponse.java b/avaje-jex/src/main/java/io/avaje/jex/http/NotFoundResponse.java deleted file mode 100644 index cead2669..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/http/NotFoundResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package io.avaje.jex.http; - -public class NotFoundResponse extends HttpResponseException { - - public NotFoundResponse(String message) { - super(404, message); - } - - public NotFoundResponse() { - super(404, "Not found"); - } - -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/RedirectException.java b/avaje-jex/src/main/java/io/avaje/jex/http/RedirectException.java new file mode 100644 index 00000000..ae46149d --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/http/RedirectException.java @@ -0,0 +1,10 @@ +package io.avaje.jex.http; + +/** Thrown when redirecting */ +public class RedirectException extends HttpResponseException { + + /** Create with a message. */ + public RedirectException(String message) { + super(302, message); + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/RedirectResponse.java b/avaje-jex/src/main/java/io/avaje/jex/http/RedirectResponse.java deleted file mode 100644 index 0295c0c2..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/http/RedirectResponse.java +++ /dev/null @@ -1,26 +0,0 @@ -package io.avaje.jex.http; - -public class RedirectResponse extends HttpResponseException { - - /** - * Redirect with the given message. - */ - public RedirectResponse(String message) { - super(302, message); - } - - /** - * Redirect with the given http status code. - */ - public RedirectResponse(int statusCode) { - super(statusCode, null); - } - - /** - * Redirect with 302 http status code. - */ - public RedirectResponse() { - super(302, "Redirect"); - } - -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/ServiceUnavailableResponse.java b/avaje-jex/src/main/java/io/avaje/jex/http/ServiceUnavailableResponse.java deleted file mode 100644 index 84625911..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/http/ServiceUnavailableResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package io.avaje.jex.http; - -public class ServiceUnavailableResponse extends HttpResponseException { - - public ServiceUnavailableResponse(String message) { - super(503, message); - } - - public ServiceUnavailableResponse() { - super(503, "Service unavailable"); - } - -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/UnauthorizedResponse.java b/avaje-jex/src/main/java/io/avaje/jex/http/UnauthorizedResponse.java deleted file mode 100644 index b47c27cf..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/http/UnauthorizedResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package io.avaje.jex.http; - -public class UnauthorizedResponse extends HttpResponseException { - - public UnauthorizedResponse(String message) { - super(401, message); - } - - public UnauthorizedResponse() { - super(401, "Unauthorized"); - } - -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/package-info.java b/avaje-jex/src/main/java/io/avaje/jex/http/package-info.java new file mode 100644 index 00000000..c95e9040 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/http/package-info.java @@ -0,0 +1,2 @@ +/** Http Constructs (Handlers, Filters, etc.) */ +package io.avaje.jex.http; diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/sse/Emitter.java b/avaje-jex/src/main/java/io/avaje/jex/http/sse/Emitter.java new file mode 100644 index 00000000..36d49490 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/http/sse/Emitter.java @@ -0,0 +1,69 @@ +package io.avaje.jex.http.sse; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.locks.ReentrantLock; + +final class Emitter { + public static final String COMMENT_PREFIX = ":"; + public static final String NEW_LINE = "\n"; + + private final ReentrantLock lock = new ReentrantLock(); + private final OutputStream response; + private boolean closed = false; + + Emitter(OutputStream outputStream) { + this.response = outputStream; + } + + boolean isClosed() { + return closed; + } + + void emit(String event, InputStream data, String id) { + try { + lock.lock(); + + if (id != null) { + write("id: " + id + NEW_LINE); + } + write("event: " + event + NEW_LINE); + + try (var reader = + new BufferedReader(new InputStreamReader(data, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + write("data: " + line + NEW_LINE); + } + } + + write(NEW_LINE); + response.flush(); + + } catch (final IOException ignored) { + closed = true; + } finally { + lock.unlock(); + } + } + + void emit(String comment) { + try { + final var lines = comment.split(NEW_LINE); + for (final String line : lines) { + write(COMMENT_PREFIX + " " + line + NEW_LINE); + } + response.flush(); + } catch (final IOException ignored) { + closed = true; + } + } + + private void write(String value) throws IOException { + response.write(value.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/sse/SseClient.java b/avaje-jex/src/main/java/io/avaje/jex/http/sse/SseClient.java new file mode 100644 index 00000000..97f7577d --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/http/sse/SseClient.java @@ -0,0 +1,80 @@ +package io.avaje.jex.http.sse; + +import java.io.Closeable; +import java.util.function.Consumer; + +import io.avaje.jex.http.Context; +import io.avaje.jex.http.ExchangeHandler; +import io.avaje.jex.spi.JsonService; + +/** + * A client for Server-Sent Events (SSE). This class handles the setup of the SSE connection, + * sending events and comments to the client, and managing the lifecycle of the connection. It + * ensures proper headers are set and provides methods for sending various types of data. + * + *

This class implements {@link Closeable} to allow for proper resource management. The + * connection is automatically closed if the client disconnects or if an error occurs during event + * emission. + */ +public interface SseClient extends Closeable { + + /** Return an SseClient handler. */ + static ExchangeHandler handler(Consumer consumer) { + return new SseHandler(consumer); + } + + /** Close the SseClient and release keepAlive block if any */ + @Override + void close(); + + /** + * Return the request Context. + * + * @return the request + */ + Context ctx(); + + /** + * By blocking the SSE connection, you can share this client outside the handler to notify it from + * other sources. Keep in mind that this function will block the handler until the SSE client is + * released by another thread. + */ + void keepAlive(); + + /** + * Add a callback that will be called either when connection is closed through {@link #close()}, + * or when the {@link Emitter} is detected as closed. + * + * @param task task to run + */ + void onClose(Runnable task); + + /** + * Attempt to send a comment. If the {@link Emitter} fails to emit (remote client has + * disconnected), the {@link #close()} function will be called instead. + */ + void sendComment(String comment); + + /** Calls {@link #sendEvent(String, Object, String)} with event set to "message" */ + void sendEvent(Object data); + + /** Calls {@link #sendEvent(String, Object, String)} with id set to null */ + void sendEvent(String event, Object data); + + /** + * Attempt to send an event. If the {@link Emitter} fails to emit (remote client has + * disconnected), the {@link #close()} function will be called instead. + * + * @param event The name of the event. + * @param data The data to send in the event. This can be a String, an InputStream, or any object + * that can be serialized to JSON using the configured {@link JsonService}. + * @param id The ID of the event. + */ + void sendEvent(String event, Object data, String id); + + /** + * Returns true if {@link #close()} has been called. This can either be by the user, or by Jex + * upon detecting that the {@link Emitter} is closed. + */ + boolean terminated(); +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/sse/SseClientImpl.java b/avaje-jex/src/main/java/io/avaje/jex/http/sse/SseClientImpl.java new file mode 100644 index 00000000..5db9c7a3 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/http/sse/SseClientImpl.java @@ -0,0 +1,110 @@ +package io.avaje.jex.http.sse; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.lang.System.Logger.Level; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; + +import io.avaje.jex.http.Context; +import io.avaje.jex.spi.JsonService; + +final class SseClientImpl implements SseClient { + + private static final System.Logger log = System.getLogger(SseClient.class.getCanonicalName()); + + private final AtomicBoolean terminated = new AtomicBoolean(false); + private final Emitter emitter; + private final JsonService jsonService; + private final Context ctx; + private CompletableFuture blockingFuture; + private Runnable closeCallback = () -> {}; + + SseClientImpl(Context ctx) { + this.emitter = new Emitter(ctx.exchange().getResponseBody()); + jsonService = ctx.jsonService(); + this.ctx = ctx; + } + + @Override + public void onClose(Runnable task) { + this.closeCallback = task; + } + + @Override + public void close() { + if (terminated.getAndSet(true)) return; + closeCallback.run(); + if (blockingFuture != null) { + blockingFuture.complete(null); + } + } + + @Override + public Context ctx() { + return ctx; + } + + @Override + public void keepAlive() { + + if (terminated.get()) return; + + this.blockingFuture = new CompletableFuture<>(); + blockingFuture.join(); + } + + private void logTerminated() { + log.log(Level.WARNING, "Cannot send data, SseClient has been terminated."); + } + + @Override + public void sendComment(String comment) { + if (terminated.get()) { + logTerminated(); + return; + } + emitter.emit(comment); + if (emitter.isClosed()) { // can't detect if closed before we try emitting + close(); + } + } + + @Override + public void sendEvent(Object data) { + sendEvent("message", data); + } + + @Override + public void sendEvent(String event, Object data) { + sendEvent(event, data, null); + } + + @Override + public void sendEvent(String event, Object data, String id) { + if (terminated.get()) { + logTerminated(); + return; + } + + final var inputStream = + switch (data) { + case final InputStream is -> is; + case final String s -> new ByteArrayInputStream(s.getBytes(UTF_8)); + default -> new ByteArrayInputStream(jsonService.toJsonString(data).getBytes(UTF_8)); + }; + + emitter.emit(event, inputStream, id); + + if (emitter.isClosed()) { // can't detect if closed before we try emitting + close(); + } + } + + @Override + public boolean terminated() { + return terminated.get(); + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/sse/SseHandler.java b/avaje-jex/src/main/java/io/avaje/jex/http/sse/SseHandler.java new file mode 100644 index 00000000..4ceea3b4 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/http/sse/SseHandler.java @@ -0,0 +1,43 @@ +package io.avaje.jex.http.sse; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.function.Consumer; + +import io.avaje.jex.core.Constants; +import io.avaje.jex.http.BadRequestException; +import io.avaje.jex.http.Context; +import io.avaje.jex.http.ExchangeHandler; + +/** Handler that configures a request for Server Sent Events */ +final class SseHandler implements ExchangeHandler { + + private static final String TEXT_EVENT_STREAM = "text/event-stream"; + private final Consumer consumer; + + SseHandler(Consumer consumer) { + this.consumer = consumer; + } + + @Override + public void handle(Context ctx) throws Exception { + + if (!TEXT_EVENT_STREAM.equals(ctx.header(Constants.ACCEPT))) { + throw new BadRequestException("SSE Requests must have an 'Accept: text/event-stream' header"); + } + final var exchange = ctx.exchange(); + final var headers = exchange.getResponseHeaders(); + headers.add(Constants.CONTENT_TYPE, TEXT_EVENT_STREAM); + headers.add(Constants.CONTENT_ENCODING, "UTF-8"); + headers.add("Connection", "close"); + headers.add("Cache-Control", "no-cache"); + headers.add("X-Accel-Buffering", "no"); // See https://serverfault.com/a/801629 + + try (var sse = new SseClientImpl(ctx)) { + exchange.sendResponseHeaders(200, 0); + consumer.accept(sse); + } catch (final IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/http/sse/package-info.java b/avaje-jex/src/main/java/io/avaje/jex/http/sse/package-info.java new file mode 100644 index 00000000..065474b6 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/http/sse/package-info.java @@ -0,0 +1,2 @@ +/** Server Sent Event Classes */ +package io.avaje.jex.http.sse; diff --git a/avaje-jex/src/main/java/io/avaje/jex/package-info.java b/avaje-jex/src/main/java/io/avaje/jex/package-info.java new file mode 100644 index 00000000..fd9e77f4 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/package-info.java @@ -0,0 +1,15 @@ +/** + * Avaje Jex API - see {@link io.avaje.jex.Jex}. + * + *

{@code
+ * final Jex.Server app = Jex.create()
+ *   .get("/", ctx -> ctx.text("hello world"))
+ *   .get("/one", ctx -> ctx.text("one"))
+ *   .port(8080)
+ *   .start();
+ *
+ * app.shutdown();
+ *
+ * }
+ */ +package io.avaje.jex; 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 deleted file mode 100644 index 49b6449b..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/routes/BootstrapRoutes.java +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 340ca1a3..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/routes/FilterEntry.java +++ /dev/null @@ -1,77 +0,0 @@ -package io.avaje.jex.routes; - -import io.avaje.jex.Context; -import io.avaje.jex.Handler; -import io.avaje.jex.Routing; -import io.avaje.jex.spi.SpiRoutes; - -import java.util.Map; - -/** - * Filter with special matchAll. - */ -class FilterEntry implements SpiRoutes.Entry { - - private final String path; - private final boolean matchAll; - private final PathParser pathParser; - private final Handler handler; - - FilterEntry(Routing.Entry entry, boolean ignoreTrailingSlashes) { - this.path = entry.getPath(); - this.matchAll = "/*".equals(path) || "*".equals(path); - this.pathParser = matchAll ? null : new PathParser(path, ignoreTrailingSlashes); - 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; - } - - @Override - public boolean matches(String requestUri) { - return matchAll || pathParser.matches(requestUri); - } - - @Override - public void handle(Context ctx) { - handler.handle(ctx); - } - - @Override - public Map pathParams(String uri) { - throw new IllegalStateException("not allowed"); - } - - @Override - public int segmentCount() { - throw new IllegalStateException("not allowed"); - } - - @Override - public boolean multiSlash() { - return pathParser != null && pathParser.multiSlash(); - } - - @Override - public boolean literal() { - return pathParser != null && pathParser.literal(); - } -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/routes/MultiHandler.java b/avaje-jex/src/main/java/io/avaje/jex/routes/MultiHandler.java new file mode 100644 index 00000000..599cb2d9 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/routes/MultiHandler.java @@ -0,0 +1,23 @@ +package io.avaje.jex.routes; + +import io.avaje.jex.http.Context; +import io.avaje.jex.http.ExchangeHandler; + +final class MultiHandler implements ExchangeHandler { + + private final ExchangeHandler[] handlers; + + MultiHandler(ExchangeHandler[] handlers) { + this.handlers = handlers; + } + + @Override + public void handle(Context ctx) throws Exception { + for (ExchangeHandler handler : handlers) { + handler.handle(ctx); + if (ctx.responseSent()) { + break; + } + } + } +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/routes/PathParser.java b/avaje-jex/src/main/java/io/avaje/jex/routes/PathParser.java index 231b12c0..c60d0b71 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/routes/PathParser.java +++ b/avaje-jex/src/main/java/io/avaje/jex/routes/PathParser.java @@ -4,7 +4,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; -class PathParser { +final class PathParser { private final String rawPath; private final List paramNames = new ArrayList<>(); @@ -16,7 +16,7 @@ class PathParser { PathParser(String path, boolean ignoreTrailingSlashes) { this.rawPath = path; - final RegBuilder regBuilder = new RegBuilder(); + final RegBuilder regBuilder = new RegBuilder(ignoreTrailingSlashes); for (String rawSeg : path.split("/")) { if (!rawSeg.isEmpty()) { segmentCount++; @@ -33,11 +33,11 @@ class PathParser { this.literal = segmentCount > 1 && regBuilder.literal(); } - public boolean matches(String url) { + boolean matches(String url) { return matchRegex.matcher(url).matches(); } - public Map extractPathParams(String uri) { + Map extractPathParams(String uri) { Map pathMap = new LinkedHashMap<>(); final List values = values(uri); for (int i = 0; i < values.size(); i++) { @@ -70,28 +70,28 @@ private PathSegment parseSegment(String segment) { /** * Return the raw path that was parsed (match path). */ - public String raw() { + String raw() { return rawPath; } /** * Return the number of path segments. */ - public int segmentCount() { + int segmentCount() { return segmentCount; } /** * Return true if one of the segments is wildcard or slash accepting. */ - public boolean multiSlash() { + boolean multiSlash() { return multiSlash; } /** * Return true if all path segments are literal. */ - public boolean literal() { + boolean literal() { return literal; } } diff --git a/avaje-jex/src/main/java/io/avaje/jex/routes/PathSegment.java b/avaje-jex/src/main/java/io/avaje/jex/routes/PathSegment.java index e82fba7f..c4a1bf5d 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/routes/PathSegment.java +++ b/avaje-jex/src/main/java/io/avaje/jex/routes/PathSegment.java @@ -4,7 +4,7 @@ import static java.util.stream.Collectors.joining; -abstract class PathSegment { +abstract sealed class PathSegment { abstract String asRegexString(boolean extract); @@ -18,13 +18,13 @@ boolean multiSlash() { return false; } - static class SlashIgnoringParameter extends Parameter { + static final class SlashIgnoringParameter extends Parameter { SlashIgnoringParameter(String param) { super(param, "[^/]+?"); // Accepting everything except slash;); } } - static class SlashAcceptingParameter extends Parameter { + static final class SlashAcceptingParameter extends Parameter { SlashAcceptingParameter(String param) { super(param, ".+?"); // Accept everything } @@ -35,7 +35,7 @@ boolean multiSlash() { } } - private abstract static class Parameter extends PathSegment { + private abstract static sealed class Parameter extends PathSegment { private final String name; private final String regex; @@ -60,7 +60,7 @@ public void addParamName(List paramNames) { } } - static class Multi extends PathSegment { + static final class Multi extends PathSegment { private final List segments; @@ -93,7 +93,7 @@ void addParamName(List paramNames) { } } - static class Literal extends PathSegment { + static final class Literal extends PathSegment { private final String content; Literal(String content) { @@ -116,7 +116,7 @@ public void addParamName(List paramNames) { } } - static class Wildcard extends PathSegment { + static final class Wildcard extends PathSegment { @Override boolean multiSlash() { diff --git a/avaje-jex/src/main/java/io/avaje/jex/routes/PathSegmentParser.java b/avaje-jex/src/main/java/io/avaje/jex/routes/PathSegmentParser.java index 0f89c0d5..3e33b6db 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/routes/PathSegmentParser.java +++ b/avaje-jex/src/main/java/io/avaje/jex/routes/PathSegmentParser.java @@ -5,18 +5,16 @@ import java.util.regex.MatchResult; import java.util.regex.Pattern; -import static java.util.stream.Collectors.toList; - -class PathSegmentParser { +final class PathSegmentParser { private static final PathSegment WILDCARD = new PathSegment.Wildcard(); private static final String[] ADJACENT_VIOLATIONS = {"*{", "*<", "}*", ">*"}; - private static final String NAME_STR = "[a-zA-Z0-9_-]+"; - private static final String NAME_OPT = "([:][^/^{]+([{][0-9]+[}])?)?"; // Optional regex - private static final String SLASH_STR = "[<]" + NAME_STR + "[>]"; - private static final String PARAM_STR = "[{]" + NAME_STR + NAME_OPT + "[}]"; + private static final String NAME_STR = "[\\w-]+"; + private static final String NAME_OPT = "(:[^/^{]+([{]\\d+})?)?"; // Optional regex + private static final String SLASH_STR = "<" + NAME_STR + ">"; + private static final String PARAM_STR = "[{]" + NAME_STR + NAME_OPT + "}"; private static final String MULTI_STR = "(" + PARAM_STR + "|" + SLASH_STR + "|[*]|" + "[^{')) { return new PathSegment.SlashAcceptingParameter(trim(segment)); } - if (matchOnlyStartEnd('{', '}')) { - return new PathSegment.SlashIgnoringParameter(trim(segment)); - } - if (matchParamWithRegex(segment)) { + if (matchOnlyStartEnd('{', '}') || matchParamWithRegex(segment)) { return new PathSegment.SlashIgnoringParameter(trim(segment)); } if (matchLiteral(segment)) { @@ -72,11 +67,11 @@ private PathSegment parseMultiSegment() { } private PathSegment tokenSegment(String token) { - if (token.equals("*")) { + if ("*".equals(token)) { return WILDCARD; - } else if (token.startsWith("<")) { + } else if (token.charAt(0) == '<') { return slashAccepting(token); - } else if (token.startsWith("{")) { + } else if (token.charAt(0) == '{') { return slashIgnoring(token); } else { return new PathSegment.Literal(token); @@ -96,9 +91,7 @@ static boolean matchParamWithRegex(String input) { } static List multi(String input) { - return MATCH_MULTI.matcher(input).results() - .map(MatchResult::group) - .collect(toList()); + return MATCH_MULTI.matcher(input).results().map(MatchResult::group).toList(); } static boolean matchLiteral(String segment) { @@ -133,5 +126,4 @@ boolean endCharOnly(char c) { // last char matches and no prior matching char return segment.indexOf(c) == segment.length() - 1; } - } diff --git a/avaje-jex/src/main/java/io/avaje/jex/routes/RegBuilder.java b/avaje-jex/src/main/java/io/avaje/jex/routes/RegBuilder.java index ae400018..7501352c 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/routes/RegBuilder.java +++ b/avaje-jex/src/main/java/io/avaje/jex/routes/RegBuilder.java @@ -7,13 +7,18 @@ /** * Helper for PathParser to build regex for the path. */ -class RegBuilder { +final class RegBuilder { private final StringJoiner full = new StringJoiner("/"); private final StringJoiner extract = new StringJoiner("/"); + private final boolean ignoreTrailingSlashes; private boolean trailingSlash; private boolean multiSlash; private boolean literal = true; + public RegBuilder(boolean ignoreTrailingSlashes) { + this.ignoreTrailingSlashes = ignoreTrailingSlashes; + } + void add(PathSegment pathSegment, List paramNames) { full.add(pathSegment.asRegexString(false)); extract.add(pathSegment.asRegexString(true)); @@ -48,6 +53,11 @@ private String wrap(String parts) { if (trailingSlash) { parts += "\\/"; } + + if (!ignoreTrailingSlashes) { + return "^/" + parts; + } + return "^/" + parts + "/?$"; } 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 3c5d1a71..30fc06c1 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 @@ -1,21 +1,29 @@ package io.avaje.jex.routes; -import io.avaje.jex.Context; -import io.avaje.jex.Handler; -import io.avaje.jex.spi.SpiRoutes; - import java.util.Map; +import java.util.Set; import java.util.concurrent.atomic.AtomicLong; -class RouteEntry implements SpiRoutes.Entry { +import io.avaje.jex.http.ExchangeHandler; +import io.avaje.jex.security.Role; + +final class RouteEntry implements SpiRoutes.Entry { private final AtomicLong active = new AtomicLong(); private final PathParser path; - private final Handler handler; + private final ExchangeHandler handler; + private final Set roles; - RouteEntry(PathParser path, Handler handler) { + RouteEntry(PathParser path, ExchangeHandler handler, Set roles) { this.path = path; this.handler = handler; + this.roles = roles; + } + + @Override + public SpiRoutes.Entry multiHandler(ExchangeHandler[] handlers) { + final var multi = new MultiHandler(handlers); + return new RouteEntry(path, multi, roles); } @Override @@ -39,8 +47,8 @@ public boolean matches(String requestUri) { } @Override - public void handle(Context ctx) { - handler.handle(ctx); + public ExchangeHandler handler() { + return handler; } @Override @@ -67,4 +75,14 @@ public boolean multiSlash() { public boolean literal() { return path.literal(); } + + @Override + public Set roles() { + return roles; + } + + @Override + public String toString() { + return path.raw(); + } } 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 62127c95..c75eb3a6 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 @@ -1,38 +1,52 @@ package io.avaje.jex.routes; -import io.avaje.jex.spi.SpiRoutes; +import static java.util.stream.Collectors.joining; -import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.stream.Stream; -class RouteIndex { +final class RouteIndex { /** * Partition entries by the number of path segments. */ - private final RouteIndex.Entry[] entries = new RouteIndex.Entry[6]; + private final IndexEntry[] entries; /** * Wildcard/splat based route entries. */ - private final List wildcardEntries = new ArrayList<>(); + private final SpiRoutes.Entry[] wildcardEntries; + + RouteIndex(List wildcards, List> pathEntries) { + this.wildcardEntries = wildcards.toArray(new SpiRoutes.Entry[0]); + this.entries = pathEntries.stream() + .map(RouteIndex::toEntry) + .toList() + .toArray(new IndexEntry[0]); + } - RouteIndex() { - for (int i = 0; i < entries.length; i++) { - entries[i] = new RouteIndex.Entry(); - } + @Override + public String toString() { + + return "RouteIndex{" + + Stream.concat( + Arrays.stream(entries) + .filter(i -> i.pathEntries.length > 0) + .map(i -> i.pathEntries) + .flatMap(Arrays::stream) + .map(Object::toString), + Arrays.stream(wildcardEntries).map(Object::toString)) + .sorted().collect(joining(", ")) + + '}'; } - private int index(int segmentCount) { - return Math.min(segmentCount, 5); + private static IndexEntry toEntry(List routeEntries) { + return new IndexEntry(routeEntries.toArray(new SpiRoutes.Entry[0])); } - void add(SpiRoutes.Entry entry) { - if (entry.multiSlash()) { - wildcardEntries.add(entry); - } else { - entries[index(entry.segmentCount())].add(entry); - } + private int index(int segmentCount) { + return Math.min(segmentCount, 5); } SpiRoutes.Entry match(String pathInfo) { @@ -65,7 +79,7 @@ private int segmentCount(String pathInfo) { long activeRequests() { long total = 0; - for (RouteIndex.Entry entry : entries) { + for (IndexEntry entry : entries) { total += entry.activeRequests(); } for (SpiRoutes.Entry entry : wildcardEntries) { @@ -74,21 +88,21 @@ long activeRequests() { return total; } - private static class Entry { + private static final class IndexEntry { - private final List list = new ArrayList<>(); + private final SpiRoutes.Entry[] pathEntries; - void add(SpiRoutes.Entry entry) { - if (entry.literal()) { - // add literal paths to the beginning - list.add(0, entry); - } else { - list.add(entry); - } + IndexEntry(SpiRoutes.Entry[] pathEntries) { + this.pathEntries = pathEntries; + } + + @Override + public String toString() { + return Arrays.toString(pathEntries); } SpiRoutes.Entry match(String pathInfo) { - for (SpiRoutes.Entry entry : list) { + for (SpiRoutes.Entry entry : pathEntries) { if (entry.matches(pathInfo)) { return entry; } @@ -98,7 +112,7 @@ SpiRoutes.Entry match(String pathInfo) { long activeRequests() { long total = 0; - for (SpiRoutes.Entry entry : list) { + for (SpiRoutes.Entry entry : pathEntries) { total += entry.activeRequests(); } return total; diff --git a/avaje-jex/src/main/java/io/avaje/jex/routes/RouteIndexBuild.java b/avaje-jex/src/main/java/io/avaje/jex/routes/RouteIndexBuild.java new file mode 100644 index 00000000..b25a5529 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/routes/RouteIndexBuild.java @@ -0,0 +1,82 @@ +package io.avaje.jex.routes; + +import java.util.*; + +import io.avaje.jex.http.ExchangeHandler; + +/** + * Build the RouteIndex. + */ +final class RouteIndexBuild { + + /** + * Partition entries by the number of path segments. + */ + private final RouteIndexBuild.Entry[] entries = new RouteIndexBuild.Entry[6]; + + /** + * Wildcard/splat based route entries. + */ + private final List wildcardEntries = new ArrayList<>(); + + RouteIndexBuild() { + for (int i = 0; i < entries.length; i++) { + entries[i] = new RouteIndexBuild.Entry(); + } + } + + private int index(int segmentCount) { + return Math.min(segmentCount, 5); + } + + void add(SpiRoutes.Entry entry) { + if (entry.multiSlash()) { + wildcardEntries.add(entry); + } else { + entries[index(entry.segmentCount())].add(entry); + } + } + + /** + * Build and return the RouteIndex. + */ + RouteIndex build() { + final List> pathEntries = new ArrayList<>(); + for (Entry entry : entries) { + pathEntries.add(entry.build()); + } + return new RouteIndex(wildcardEntries, pathEntries); + } + + private static class Entry { + + private final List list = new ArrayList<>(); + private final Map> pathMap = new LinkedHashMap<>(); + + void add(SpiRoutes.Entry entry) { + if (entry.literal()) { + // add literal paths to the beginning + list.addFirst(entry); + } else { + pathMap.computeIfAbsent(entry.matchPath(), k -> new ArrayList<>(2)).add(entry); + } + } + + List build() { + List result = new ArrayList<>(list.size() + pathMap.size()); + result.addAll(list); + pathMap.values().forEach(pathList -> { + if (pathList.size() == 1) { + result.add(pathList.getFirst()); + } else { + ExchangeHandler[] handlers = pathList.stream() + .map(SpiRoutes.Entry::handler) + .toList() + .toArray(new ExchangeHandler[0]); + result.add(pathList.getFirst().multiHandler(handlers)); + } + }); + return result; + } + } +} 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 4fb07131..85fcdf13 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 @@ -1,19 +1,17 @@ package io.avaje.jex.routes; -import io.avaje.applog.AppLog; -import io.avaje.jex.Routing; -import io.avaje.jex.spi.SpiContext; -import io.avaje.jex.spi.SpiRoutes; - import java.lang.System.Logger.Level; import java.util.EnumMap; import java.util.List; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.LockSupport; -class Routes implements SpiRoutes { +import io.avaje.jex.Routing; +import io.avaje.jex.http.HttpFilter; + +final class Routes implements SpiRoutes { - private static final System.Logger log = AppLog.getLogger("io.avaje.jex"); + private static final System.Logger log = System.getLogger("io.avaje.jex"); /** * The "real" handlers by http method. @@ -21,21 +19,20 @@ class Routes implements SpiRoutes { private final EnumMap typeMap; /** - * The before filters. + * The filters. */ - private final List before; - - /** - * The after filters. - */ - private final List after; + private final List filters; private final AtomicLong noRouteCounter = new AtomicLong(); - Routes(EnumMap typeMap, List before, List after) { + Routes(EnumMap typeMap, List filters) { this.typeMap = typeMap; - this.before = before; - this.after = after; + this.filters = filters; + } + + @Override + public String toString() { + return "Routes{" + typeMap + ", filters=" + filters + '}'; } @Override @@ -85,22 +82,7 @@ public Entry match(Routing.Type type, String pathInfo) { } @Override - public void before(String pathInfo, SpiContext ctx) { - ctx.setMode(Routing.Type.BEFORE); - for (Entry beforeFilter : before) { - if (beforeFilter.matches(pathInfo)) { - beforeFilter.handle(ctx); - } - } - } - - @Override - public void after(String pathInfo, SpiContext ctx) { - ctx.setMode(Routing.Type.AFTER); - for (Entry afterFilter : after) { - if (afterFilter.matches(pathInfo)) { - afterFilter.handle(ctx); - } - } + public List filters() { + return filters; } } 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 96fd816f..3602cf0d 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 @@ -1,76 +1,38 @@ package io.avaje.jex.routes; -import io.avaje.jex.*; -import io.avaje.jex.spi.SpiRoutes; - -import java.util.ArrayList; import java.util.EnumMap; +import java.util.LinkedHashMap; import java.util.List; -import java.util.Set; -class RoutesBuilder { +import io.avaje.jex.JexConfig; +import io.avaje.jex.Routing; +import io.avaje.jex.http.HttpFilter; + +public final class RoutesBuilder { private final EnumMap typeMap = new EnumMap<>(Routing.Type.class); - private final List before = new ArrayList<>(); - private final List after = new ArrayList<>(); private final boolean ignoreTrailingSlashes; - private final AccessManager accessManager; - - RoutesBuilder(Routing routing, AccessManager accessManager, boolean ignoreTrailingSlashes) { - this.accessManager = accessManager; - this.ignoreTrailingSlashes = ignoreTrailingSlashes; - for (Routing.Entry handler : routing.all()) { - switch (handler.getType()) { - case BEFORE: - before.add(filter(handler)); - break; - case AFTER: - after.add(filter(handler)); - break; - default: - typeMap.computeIfAbsent(handler.getType(), h -> new RouteIndex()).add(convert(handler)); - } + private final List filters; + private final String contextPath; + + public RoutesBuilder(Routing routing, JexConfig config) { + this.ignoreTrailingSlashes = config.ignoreTrailingSlashes(); + final var buildMap = new LinkedHashMap(); + this.contextPath = config.contextPath().transform(s -> "/".equals(s) ? "" : s); + for (var handler : routing.handlers()) { + buildMap.computeIfAbsent(handler.getType(), h -> new RouteIndexBuild()).add(convert(handler)); } - } - - private FilterEntry filter(Routing.Entry entry) { - return new FilterEntry(entry, ignoreTrailingSlashes); + buildMap.forEach((key, value) -> typeMap.put(key, value.build())); + filters = List.copyOf(routing.filters()); } private SpiRoutes.Entry convert(Routing.Entry handler) { - final PathParser pathParser = new PathParser(handler.getPath(), ignoreTrailingSlashes); - return new RouteEntry(pathParser, extractHandler(handler)); - } - - private Handler extractHandler(Routing.Entry entry) { - if (entry.getRoles().isEmpty() || accessManager == null) { - return entry.getHandler(); - } - return new AccessHandler(accessManager, entry.getHandler(), entry.getRoles()); + final PathParser pathParser = + new PathParser(contextPath + handler.getPath(), ignoreTrailingSlashes); + return new RouteEntry(pathParser, handler.getHandler(), handler.getRoles()); } public SpiRoutes build() { - return new Routes(typeMap, before, after); - } - - /** - * Wrap the handler with access check based on permitted roles. - */ - static class AccessHandler implements Handler { - - private final AccessManager manager; - private final Handler handler; - private final Set roles; - - AccessHandler(AccessManager manager, Handler handler, Set roles) { - this.manager = manager; - this.handler = handler; - this.roles = roles; - } - - @Override - public void handle(Context ctx) { - manager.manage(handler, ctx, roles); - } + return new Routes(typeMap, filters); } } diff --git a/avaje-jex/src/main/java/io/avaje/jex/spi/SpiRoutes.java b/avaje-jex/src/main/java/io/avaje/jex/routes/SpiRoutes.java similarity index 73% rename from avaje-jex/src/main/java/io/avaje/jex/spi/SpiRoutes.java rename to avaje-jex/src/main/java/io/avaje/jex/routes/SpiRoutes.java index 7e819044..5dc91a65 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/spi/SpiRoutes.java +++ b/avaje-jex/src/main/java/io/avaje/jex/routes/SpiRoutes.java @@ -1,30 +1,24 @@ -package io.avaje.jex.spi; - -import io.avaje.jex.Context; -import io.avaje.jex.Routing; +package io.avaje.jex.routes; +import java.util.List; import java.util.Map; +import java.util.Set; + +import io.avaje.jex.Routing; +import io.avaje.jex.http.ExchangeHandler; +import io.avaje.jex.http.HttpFilter; +import io.avaje.jex.security.Role; /** * Route matching and filter handling. */ -public interface SpiRoutes { +public sealed interface SpiRoutes permits Routes { /** * Find the matching handler entry given the type and request URI. */ Entry match(Routing.Type type, String pathInfo); - /** - * Execute all appropriate before filters for the given request URI. - */ - void before(String pathInfo, SpiContext ctx); - - /** - * Execute all appropriate after filters for the given request URI. - */ - void after(String pathInfo, SpiContext ctx); - /** * Increment active request count for no route match. */ @@ -45,6 +39,11 @@ public interface SpiRoutes { */ void waitForIdle(long maxSeconds); + /** + * Get filters + */ + List filters(); + /** * A route entry. */ @@ -56,9 +55,9 @@ interface Entry { boolean matches(String requestUri); /** - * Handle the request. + * Handler for the request. */ - void handle(Context ctx); + ExchangeHandler handler(); /** * Return the path parameter map given the uri. @@ -99,5 +98,12 @@ interface Entry { * Return the active request count for the route. */ long activeRequests(); + + /** Return the authentication roles for the route. */ + Set roles(); + + /** Create and return a new Entry with multiple handlers. */ + Entry multiHandler(ExchangeHandler[] handlers); } + } diff --git a/avaje-jex/src/main/java/io/avaje/jex/routes/UrlDecode.java b/avaje-jex/src/main/java/io/avaje/jex/routes/UrlDecode.java index 00f0f3ef..5db32f66 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/routes/UrlDecode.java +++ b/avaje-jex/src/main/java/io/avaje/jex/routes/UrlDecode.java @@ -1,16 +1,17 @@ package io.avaje.jex.routes; -import java.io.UnsupportedEncodingException; import java.net.URLDecoder; +import java.nio.charset.Charset; -public class UrlDecode { +import static java.nio.charset.StandardCharsets.UTF_8; + +public final class UrlDecode { public static String decode(String s) { - try { - return URLDecoder.decode(s.replace("+", "%2B"), "UTF-8").replace("%2B", "+"); - } catch (UnsupportedEncodingException e) { - throw new RuntimeException("Not expected", e); - } + return decode(s, UTF_8); } + public static String decode(String s, Charset charset) { + return URLDecoder.decode(s.replace("+", "%2B"), charset).replace("%2B", "+"); + } } diff --git a/avaje-jex/src/main/java/io/avaje/jex/security/BasicAuthCredentials.java b/avaje-jex/src/main/java/io/avaje/jex/security/BasicAuthCredentials.java new file mode 100644 index 00000000..9b8e75df --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/security/BasicAuthCredentials.java @@ -0,0 +1,9 @@ +package io.avaje.jex.security; + +/** + * Http Basic Auth credentials + * + * @param userName the username + * @param password the password + */ +public record BasicAuthCredentials(String userName, String password) {} diff --git a/avaje-jex/src/main/java/io/avaje/jex/security/Role.java b/avaje-jex/src/main/java/io/avaje/jex/security/Role.java new file mode 100644 index 00000000..7e898a6b --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/security/Role.java @@ -0,0 +1,6 @@ +package io.avaje.jex.security; + +import io.avaje.jex.http.Context; + +/** Marker interface for roles used in route declarations. See {@link Context#routeRoles()}. */ +public interface Role {} diff --git a/avaje-jex/src/main/java/io/avaje/jex/security/package-info.java b/avaje-jex/src/main/java/io/avaje/jex/security/package-info.java new file mode 100644 index 00000000..bf145990 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/security/package-info.java @@ -0,0 +1,2 @@ +/** Security Classes */ +package io.avaje.jex.security; diff --git a/avaje-jex/src/main/java/io/avaje/jex/spi/HeaderKeys.java b/avaje-jex/src/main/java/io/avaje/jex/spi/HeaderKeys.java deleted file mode 100644 index e99047ab..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/spi/HeaderKeys.java +++ /dev/null @@ -1,16 +0,0 @@ -package io.avaje.jex.spi; - -public class HeaderKeys { - - public static final String ACCEPT = "Accept"; - public static final String CONTENT_ENCODING = "Content-Encoding"; - public static final String CONTENT_DISPOSITION = "Content-Disposition"; - public static final String CONTENT_LANGUAGE = "Content-Language"; - public static final String CONTENT_LENGTH = "Content-Length"; - public static final String CONTENT_LOCATION = "Content-Location"; - public static final String CONTENT_RANGE = "Content-Range"; - public static final String CONTENT_TYPE = "Content-Type"; - public static final String LOCATION = "Location"; - public static final String HOST = "Host"; - public static final String USER_AGENT = "User-Agent"; -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/spi/JexExtension.java b/avaje-jex/src/main/java/io/avaje/jex/spi/JexExtension.java new file mode 100644 index 00000000..0c0f465e --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/spi/JexExtension.java @@ -0,0 +1,12 @@ +package io.avaje.jex.spi; + +import io.avaje.spi.Service; + +/** + * Extension point for all Jex SPI interfaces + * + *

All types that implement this interface must be registered as an entry in {@code + * META-INF/services/io.avaje.jex.spi.JexExtension } for it to be auto loaded + */ +@Service +public sealed interface JexExtension permits JsonService, TemplateRender, JexPlugin {} diff --git a/avaje-jex/src/main/java/io/avaje/jex/spi/JexPlugin.java b/avaje-jex/src/main/java/io/avaje/jex/spi/JexPlugin.java new file mode 100644 index 00000000..746f9814 --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/spi/JexPlugin.java @@ -0,0 +1,16 @@ +package io.avaje.jex.spi; + +import io.avaje.jex.Jex; + +/** + * A plugin that can register things like routes, exception handlers and configure the current Jex + * instance. + * + * @see JexExtension SPI registration details. + */ +@FunctionalInterface +public non-sealed interface JexPlugin extends JexExtension { + + /** Register the plugin features with jex. */ + void apply(Jex jex); +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/spi/JsonService.java b/avaje-jex/src/main/java/io/avaje/jex/spi/JsonService.java index 8a85151b..bab6d9f0 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/spi/JsonService.java +++ b/avaje-jex/src/main/java/io/avaje/jex/spi/JsonService.java @@ -1,25 +1,66 @@ package io.avaje.jex.spi; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.reflect.Type; import java.util.Iterator; /** - * Service used to convert request/response bodies to beans. + * Service responsible for handling JSON-based request and response bodies. + * + * @see JexExtension SPI registration details. */ -public interface JsonService { +public non-sealed interface JsonService extends JexExtension { /** - * Read the request body as a bean and return the bean. + * **Writes a Java Object as JSON to an OutputStream** + * + *

Serializes a Java object into JSON format and writes the resulting JSON to the specified + * output stream. + * + * @param bean the Java object to be serialized + * @param os the output stream to write the JSON data to */ - T jsonRead(Class type, SpiContext ctx); + void toJson(Object bean, OutputStream os); /** - * Write the bean as JSON response content. + * **Writes a Java Object as a JSON string** + * + *

Serializes a Java object into JSON string format and writes the resulting JSON to the + * specified output stream. + * + * @param bean the Java object to be serialized + * @return the serialized JSON string */ - void jsonWrite(Object bean, SpiContext ctx); + String toJsonString(Object bean); /** - * Write the beans as {@literal x-json-stream } JSON with new line delimiter. + * Deserializes a json input stream and deserializes it into a Java object of the specified type. + * + * @param type the Type object of the desired type + * @param is the input stream containing the JSON data + * @return the deserialized object */ - void jsonWriteStream(Iterator stream, SpiContext ctx); + T fromJson(Type type, InputStream is); + /** + * Deserializes a json byte[] into a Java object of the specified type. + * + * @param type the Type object of the desired type + * @param data the byte[] containing the JSON data + * @return the deserialized object + */ + T fromJson(Type type, byte[] data); + + /** + * Serializes a stream of Java objects into a JSON-Stream format, using the {@code x-json-stream} + * media type. Each object in the stream is serialized as a separate JSON object, and the objects + * are separated by newlines. + * + * @param iterator the stream of objects to be serialized + * @param os the output stream to write the JSON-Stream data to + */ + default void toJsonStream(Iterator iterator, OutputStream os) { + throw new UnsupportedOperationException("toJsonStream is unimplemented in this JsonService"); + } } 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 deleted file mode 100644 index 994b850e..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/spi/ProxyServiceManager.java +++ /dev/null @@ -1,79 +0,0 @@ -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 deleted file mode 100644 index 0f8d46a6..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/spi/SpiContext.java +++ /dev/null @@ -1,40 +0,0 @@ -package io.avaje.jex.spi; - -import io.avaje.jex.Context; -import io.avaje.jex.Routing; - -import java.io.InputStream; -import java.io.OutputStream; - -/** - * Extension to Context for processing the request. - */ -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"; - - /** - * Return the response outputStream to write content to. - */ - OutputStream outputStream(); - - /** - * Return the request inputStream to read content from. - */ - InputStream inputStream(); - - /** - * 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/SpiRoutesProvider.java b/avaje-jex/src/main/java/io/avaje/jex/spi/SpiRoutesProvider.java deleted file mode 100644 index d6a4e118..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/spi/SpiRoutesProvider.java +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 017f23ba..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/spi/SpiServiceManager.java +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index 90449ce5..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/spi/SpiServiceManagerProvider.java +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index fa068a15..00000000 --- a/avaje-jex/src/main/java/io/avaje/jex/spi/SpiStartServer.java +++ /dev/null @@ -1,15 +0,0 @@ -package io.avaje.jex.spi; - -import io.avaje.jex.Jex; - -/** - * Start the server. - */ -public interface SpiStartServer { - - /** - * Return the started server. - */ - Jex.Server start(Jex jex, SpiRoutes routes, SpiServiceManager serviceManager); - -} diff --git a/avaje-jex/src/main/java/io/avaje/jex/spi/TemplateRender.java b/avaje-jex/src/main/java/io/avaje/jex/spi/TemplateRender.java new file mode 100644 index 00000000..407e72ee --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/spi/TemplateRender.java @@ -0,0 +1,30 @@ +package io.avaje.jex.spi; + +import java.util.Map; + +import io.avaje.jex.http.Context; + +/** + * Template rendering typically of html. + * + * @see JexExtension SPI registration details. + */ +public non-sealed interface TemplateRender extends JexExtension { + + /** + * Return the extensions this template renders for by default. + * + *

When the template render is not explicitly registered, it can be automatically registered + * via ServiceLoader with the provided extensions by default. + */ + String[] defaultExtensions(); + + /** + * Render the template and model typically as html to the given context. + * + * @param context The context to render the template to + * @param name The template name + * @param model The model of key value pairs used when rendering the template + */ + void render(Context context, String name, Map model); +} diff --git a/avaje-jex/src/main/java/io/avaje/jex/spi/package-info.java b/avaje-jex/src/main/java/io/avaje/jex/spi/package-info.java new file mode 100644 index 00000000..9656b44c --- /dev/null +++ b/avaje-jex/src/main/java/io/avaje/jex/spi/package-info.java @@ -0,0 +1,6 @@ +/** + * SPI extension interfaces + * + * @see {@link io.avaje.jex.spi.JexExtension} + */ +package io.avaje.jex.spi; diff --git a/avaje-jex/src/main/java/module-info.java b/avaje-jex/src/main/java/module-info.java index a4b1eaf5..c1fe1579 100644 --- a/avaje-jex/src/main/java/module-info.java +++ b/avaje-jex/src/main/java/module-info.java @@ -1,32 +1,41 @@ -import io.avaje.jex.TemplateRender; -import io.avaje.jex.core.BootstapServiceManager; -import io.avaje.jex.routes.BootstrapRoutes; -import io.avaje.jex.spi.JsonService; -import io.avaje.jex.spi.SpiRoutesProvider; -import io.avaje.jex.spi.SpiServiceManagerProvider; -import io.avaje.jex.spi.SpiStartServer; +import io.avaje.jex.spi.JexExtension; +/** + * Defines the Jex HTTP server API, for running a minimal HTTP server. + * + *

{@code
+ * final Jex.Server app = Jex.create()
+ *   .get("/", ctx -> ctx.text("hello world"))
+ *   .get("/one", ctx -> ctx.text("one"))
+ *   .port(8080)
+ *   .start();
+ *
+ * app.shutdown();
+ *
+ * }
+ * + * @uses JexExtension + * + */ module io.avaje.jex { exports io.avaje.jex; + exports io.avaje.jex.compression; exports io.avaje.jex.http; + exports io.avaje.jex.http.sse; + exports io.avaje.jex.core to io.avaje.jex.staticcontent; + exports io.avaje.jex.core.json; + exports io.avaje.jex.security; exports io.avaje.jex.spi; - exports io.avaje.jex.core; requires transitive java.net.http; - requires transitive io.avaje.applog; + requires transitive jdk.httpserver; requires static com.fasterxml.jackson.core; requires static com.fasterxml.jackson.databind; requires static io.avaje.jsonb; requires static io.avaje.inject; requires static io.avaje.config; + requires static io.avaje.spi; - uses TemplateRender; - uses SpiRoutesProvider; - uses SpiServiceManagerProvider; - uses SpiStartServer; - uses JsonService; - - provides SpiRoutesProvider with BootstrapRoutes; - provides SpiServiceManagerProvider with BootstapServiceManager; + uses JexExtension; } diff --git a/avaje-jex/src/main/javadoc/overview.html b/avaje-jex/src/main/javadoc/overview.html new file mode 100644 index 00000000..b12b25ca --- /dev/null +++ b/avaje-jex/src/main/javadoc/overview.html @@ -0,0 +1,136 @@ + + + + Avaje Jex Overview + + + +

Avaje Jex

+ +

+ Avaje Jex is a wrapper over the JDK's built-in HTTP server, providing an + elegant and developer-friendly API for building web applications in Java. +

+ +

Getting Started

+ +

Here's a simple example to create a basic web server with Jex:

+ +
+Jex.create()
+    .get("/", ctx -> ctx.text("hello"))
+    .get("/one/{id}", ctx -> ctx.text("one-" + ctx.pathParam("id")))
+    .filter(
+        (ctx, chain) -> {
+          System.out.println("before request");
+          chain.proceed();
+          System.out.println("after request");
+        })
+    .error(
+        IllegalStateException.class,
+        (ctx, exception) -> ctx.status(500).text(exception.getMessage()))
+    .port(8080)
+    .start();
+    
+ +

Key Concepts

+ +

Request Handling

+

+ Jex provides three main handler types: +

+
    +
  • Endpoint Handlers - Define API endpoints for HTTP methods (GET, POST, etc.)
  • +
  • Filters - Pre/post process requests for authentication, logging, etc.
  • +
  • Exception Handlers - Handle exceptions during request processing
  • +
+ +

Context Object

+

+ The Context object is central to Jex's API, providing methods for: +

+
    +
  • Reading request data (headers, parameters, body)
  • +
  • Setting response data (status, headers, content)
  • +
  • Managing cookies
  • +
  • Handling request attributes and path information
  • +
+ +

Path Parameters

+

+ Jex supports flexible path parameter options: +

+
+// Standard path parameters with {} syntax
+app.get("/hello/{name}", ctx -> ctx.write("Hello: " + ctx.pathParam("name")));
+
+// Path parameters that can include slashes with <> syntax
+app.get("/hello/<name>", ctx -> ctx.write("Hello: " + ctx.pathParam("name")));
+
+// Wildcard parameters
+app.get("/path/*", ctx -> ctx.write("Matched: " + ctx.matchedPath()));
+    
+ +

Advanced Features

+ +

JSON Support

+

+ Jex provides a JsonService SPI for JSON serialization/deserialization, with automatic + detection of Jackson or Avaje-jsonb libraries: +

+
+Jex.create()
+    .jsonService(new JacksonJsonService())
+    .post(
+        "/json",
+        ctx -> {
+          MyBody body = ctx.bodyAsClass(MyBody.class);
+          ctx.json(new CustomResponse());
+        });
+    
+ +

Server-Sent Events

+

+ Jex supports Server-Sent Events (SSE) for real-time updates: +

+
+app.sse("/sse", client -> {
+    client.sendEvent("connected", "Hello, SSE");
+    client.onClose(() -> System.out.println("Client disconnected"));
+});
+    
+ +

Access Management

+

+ Jex provides built-in support for role-based access control: +

+
+// Custom enum for access roles
+enum Access implements Role {
+  USER,
+  ADMIN
+}
+
+Jex.create()
+    .get("/user", ctx -> ctx.text("user"), Access.USER)
+    .get("/admin", ctx -> ctx.text("admin"), Access.ADMIN)
+    .filter(
+        (ctx, chain) -> {
+          Access userRole = getUserRole(ctx);
+          if (!ctx.routeRoles().contains(userRole)) {
+            throw new HttpResponseException(403, "unauthorized");
+          }
+          chain.proceed();
+        });
+    
+ +

Additional Resources

+

+ For more informationm visit: +

+ + + 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 deleted file mode 100644 index 8d8e8739..00000000 --- a/avaje-jex/src/main/resources/META-INF/services/io.avaje.jex.spi.SpiRoutesProvider +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index deef4fcd..00000000 --- a/avaje-jex/src/main/resources/META-INF/services/io.avaje.jex.spi.SpiServiceManagerProvider +++ /dev/null @@ -1 +0,0 @@ -io.avaje.jex.core.BootstapServiceManager diff --git a/avaje-jex/src/test/java/io/avaje/jex/AvajeJexTest.java b/avaje-jex/src/test/java/io/avaje/jex/AvajeJexTest.java new file mode 100644 index 00000000..57457d3b --- /dev/null +++ b/avaje-jex/src/test/java/io/avaje/jex/AvajeJexTest.java @@ -0,0 +1,13 @@ +package io.avaje.jex; + +import static org.assertj.core.api.Assertions.assertThatNoException; + +import org.junit.jupiter.api.Test; + +public class AvajeJexTest { + + @Test + void canStart() { + assertThatNoException().isThrownBy(() -> AvajeJex.start().shutdown()); + } +} diff --git a/avaje-jex/src/test/java/io/avaje/jex/CookieTest.java b/avaje-jex/src/test/java/io/avaje/jex/CookieTest.java index 03f2dff9..9f39670d 100644 --- a/avaje-jex/src/test/java/io/avaje/jex/CookieTest.java +++ b/avaje-jex/src/test/java/io/avaje/jex/CookieTest.java @@ -1,9 +1,12 @@ package io.avaje.jex; -import io.avaje.jex.Context.Cookie; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertEquals; +import io.avaje.jex.http.Context.Cookie; + +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.*; class CookieTest { @@ -12,15 +15,61 @@ 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; Max-Age=10", Cookie.of("key", "val").maxAge(Duration.ofSeconds(10)).toString()); 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; Partitioned", Cookie.of("key", "val").partitioned(true).toString()); + assertEquals("key=val; SameSite=Strict", Cookie.of("key", "val").sameSite(Cookie.SameSite.Strict).toString()); assertEquals("key=val; Secure; HttpOnly", Cookie.of("key", "val").httpOnly(true).secure(true).toString()); } + @Test + void partitioned() { + var c = Cookie.of("k", "v").secure(true).partitioned(true); + assertTrue(c.partitioned()); + + c.partitioned(false); + assertFalse(c.partitioned()); + + assertEquals("k=v; Secure; Partitioned", Cookie.of("k", "v").secure(true).partitioned(true).toString()); + assertEquals("k=v; Secure", Cookie.of("k", "v").secure(true).partitioned(false).toString()); + + } + @Test + void sameSite() { + + var c = Cookie.of("k", "v"); + assertNull(c.sameSite()); + c.sameSite(Cookie.SameSite.Strict); + assertEquals(Cookie.SameSite.Strict, c.sameSite()); + c.sameSite(Cookie.SameSite.Lax); + assertEquals(Cookie.SameSite.Lax, c.sameSite()); + c.sameSite(Cookie.SameSite.None); + assertEquals(Cookie.SameSite.None, c.sameSite()); + c.sameSite(null); + assertNull(c.sameSite()); + + assertEquals("key=val; SameSite=Strict; Secure", Cookie.of("key", "val").secure(true).sameSite(Cookie.SameSite.Strict).toString()); + assertEquals("key=val; SameSite=Lax; Secure", Cookie.of("key", "val").secure(true).sameSite(Cookie.SameSite.Lax).toString()); + assertEquals("key=val; SameSite=None; Secure", Cookie.of("key", "val").secure(true).sameSite(Cookie.SameSite.None).toString()); + assertEquals("key=val; Secure", Cookie.of("key", "val").secure(true).sameSite(null).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()); + var cookie = Cookie.of("key", "val") + .domain("dom") + .path("/pt") + .secure(true) + .httpOnly(true) + .partitioned(true) + .sameSite(Cookie.SameSite.Strict); + + assertTrue(cookie.secure()); + assertTrue(cookie.httpOnly()); + assertTrue(cookie.partitioned()); + + assertEquals("key=val; Domain=dom; Path=/pt; SameSite=Strict; Secure; HttpOnly; Partitioned", + cookie.toString()); } } diff --git a/avaje-jex/src/test/java/io/avaje/jex/CtxPathTest.java b/avaje-jex/src/test/java/io/avaje/jex/CtxPathTest.java new file mode 100644 index 00000000..c74a6314 --- /dev/null +++ b/avaje-jex/src/test/java/io/avaje/jex/CtxPathTest.java @@ -0,0 +1,40 @@ +package io.avaje.jex; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.http.HttpResponse; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +import io.avaje.jex.core.TestPair; + +class CtxPathTest { + + static final TestPair pair = init(); + + static TestPair init() { + final Jex app = Jex.create().contextPath("/ctx/").get("/", ctx -> ctx.text("ctx")); + + return TestPair.create(app); + } + + @AfterAll + static void end() { + pair.shutdown(); + } + + @Test + void get() { + HttpResponse res = pair.request().path("ctx").GET().asString(); + + assertThat(res.body()).isEqualTo("ctx"); + } + + @Test + void getRoot404() { + HttpResponse res = pair.request().GET().asString(); + + assertThat(res.statusCode()).isEqualTo(404); + } +} diff --git a/avaje-jex/src/test/java/io/avaje/jex/ShutDownTest.java b/avaje-jex/src/test/java/io/avaje/jex/ShutDownTest.java new file mode 100644 index 00000000..488ecba0 --- /dev/null +++ b/avaje-jex/src/test/java/io/avaje/jex/ShutDownTest.java @@ -0,0 +1,25 @@ +package io.avaje.jex; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +class ShutDownTest { + + @Test + void shutDownHooks() { + + List results = new ArrayList<>(); + var jex = Jex.create().config(c -> c.socketBacklog(0)); + jex.lifecycle().onShutdown(() -> results.add("onShut")); + jex.lifecycle().registerShutdownHook(() -> results.add("onHook")); + var server = jex.start(); + + server.onShutdown(() -> results.add("serverShut")); + server.shutdown(); + assertThat(results).hasSize(2); // 2 because jvm shutdown won't run in a junit + } +} diff --git a/avaje-jex/src/test/java/io/avaje/jex/compression/CompressionTest.java b/avaje-jex/src/test/java/io/avaje/jex/compression/CompressionTest.java new file mode 100644 index 00000000..2ee5612a --- /dev/null +++ b/avaje-jex/src/test/java/io/avaje/jex/compression/CompressionTest.java @@ -0,0 +1,72 @@ +package io.avaje.jex.compression; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.net.http.HttpResponse; +import java.util.zip.GZIPInputStream; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +import io.avaje.jex.Jex; +import io.avaje.jex.core.Constants; +import io.avaje.jex.core.TestPair; +import io.avaje.jex.http.ContentType; + +class CompressionTest { + + static TestPair pair = init(); + + static TestPair init() { + + final Jex app = + Jex.create() + .routing( + r -> + r.get( + "/compress", + ctx -> + ctx.contentType(ContentType.APPLICATION_JSON) + .write(CompressionTest.class.getResourceAsStream("/64KB.json"))) + .get( + "/sus", + ctx -> + ctx.write( + CompressionTest.class.getResourceAsStream("/public/sus.txt")))); + + return TestPair.create(app); + } + + @AfterAll + static void end() { + pair.shutdown(); + } + + @Test + void testCompression() throws IOException { + var res = + pair.request() + .header(Constants.ACCEPT_ENCODING, "deflate, gzip;q=1.0, *;q=0.5") + .path("compress") + .GET() + .asInputStream(); + assertThat(res.statusCode()).isEqualTo(200); + assertThat(res.headers().firstValue(Constants.CONTENT_ENCODING)).contains("gzip"); + + var expected = CompressionTest.class.getResourceAsStream("/64KB.json").readAllBytes(); + + final var gzipInputStream = new GZIPInputStream(res.body()); + var decompressed = gzipInputStream.readAllBytes(); + gzipInputStream.close(); + assertThat(decompressed).isEqualTo(expected); + } + + @Test + void testNoCompression() { + HttpResponse res = + pair.request().header(Constants.ACCEPT_ENCODING, "gzip").path("sus").GET().asString(); + assertThat(res.statusCode()).isEqualTo(200); + assertThat(res.headers().firstValue(Constants.CONTENT_ENCODING)).isEmpty(); + } +} diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/AutoCloseIterator.java b/avaje-jex/src/test/java/io/avaje/jex/core/AutoCloseIterator.java similarity index 68% rename from avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/AutoCloseIterator.java rename to avaje-jex/src/test/java/io/avaje/jex/core/AutoCloseIterator.java index 36ce4986..6df2bd9e 100644 --- a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/AutoCloseIterator.java +++ b/avaje-jex/src/test/java/io/avaje/jex/core/AutoCloseIterator.java @@ -1,11 +1,12 @@ -package io.avaje.jex.jdk; +package io.avaje.jex.core; import java.util.Iterator; +import java.util.concurrent.atomic.AtomicBoolean; public class AutoCloseIterator implements Iterator, AutoCloseable { private final Iterator it; - private boolean closed; + private final AtomicBoolean closed = new AtomicBoolean(false); public AutoCloseIterator(Iterator it) { this.it = it; @@ -23,10 +24,10 @@ public E next() { @Override public void close() { - closed = true; + closed.set(true); } public boolean isClosed() { - return closed; + return closed.get(); } } diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/CharacterEncodingTest.java b/avaje-jex/src/test/java/io/avaje/jex/core/CharacterEncodingTest.java similarity index 98% rename from avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/CharacterEncodingTest.java rename to avaje-jex/src/test/java/io/avaje/jex/core/CharacterEncodingTest.java index 3d0f7dcb..5ed62058 100644 --- a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/CharacterEncodingTest.java +++ b/avaje-jex/src/test/java/io/avaje/jex/core/CharacterEncodingTest.java @@ -1,4 +1,4 @@ -package io.avaje.jex.jdk; +package io.avaje.jex.core; import io.avaje.jex.Jex; import org.junit.jupiter.api.AfterAll; diff --git a/avaje-jex/src/test/java/io/avaje/jex/core/ContextAttributeTest.java b/avaje-jex/src/test/java/io/avaje/jex/core/ContextAttributeTest.java new file mode 100644 index 00000000..899c9f02 --- /dev/null +++ b/avaje-jex/src/test/java/io/avaje/jex/core/ContextAttributeTest.java @@ -0,0 +1,69 @@ +package io.avaje.jex.core; + +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 + .filter( + (ctx, chain) -> { + ctx.attribute("oneUuid", uuid) + .attribute(TestPair.class.getName(), pair); + chain.proceed(); + }) + .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-jetty/src/test/java/io/avaje/jex/base/ContextFormParamTest.java b/avaje-jex/src/test/java/io/avaje/jex/core/ContextFormParamTest.java similarity index 99% rename from avaje-jex-jetty/src/test/java/io/avaje/jex/base/ContextFormParamTest.java rename to avaje-jex/src/test/java/io/avaje/jex/core/ContextFormParamTest.java index ca0677b8..c438ddaa 100644 --- a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/ContextFormParamTest.java +++ b/avaje-jex/src/test/java/io/avaje/jex/core/ContextFormParamTest.java @@ -1,4 +1,4 @@ -package io.avaje.jex.base; +package io.avaje.jex.core; import io.avaje.jex.Jex; import org.junit.jupiter.api.AfterAll; diff --git a/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/ContextLengthTest.java b/avaje-jex/src/test/java/io/avaje/jex/core/ContextLengthTest.java similarity index 73% rename from avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/ContextLengthTest.java rename to avaje-jex/src/test/java/io/avaje/jex/core/ContextLengthTest.java index 19097ddf..abe4bc1d 100644 --- a/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/ContextLengthTest.java +++ b/avaje-jex/src/test/java/io/avaje/jex/core/ContextLengthTest.java @@ -1,12 +1,13 @@ -package io.avaje.jex.grizzly; +package io.avaje.jex.core; -import io.avaje.jex.Jex; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; import java.net.http.HttpResponse; -import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +import io.avaje.jex.Jex; class ContextLengthTest { @@ -16,8 +17,9 @@ 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("/uri/{param}", ctx -> ctx.text("uri:" + ctx.uri())) + .get("/matchedPath/{param}", ctx -> ctx.text("matchedPath:" + ctx.matchedPath())) + .get("/fullUrl/{param}", ctx -> ctx.text("fullUrl:" + ctx.fullUrl())) .get("/contextPath", ctx -> ctx.text("contextPath:" + ctx.contextPath())) .get("/userAgent", ctx -> ctx.text("userAgent:" + ctx.userAgent())) ); @@ -50,36 +52,52 @@ void requestContentLengthAndType_notReqContentType() { } @Test - void url() { + void uri() { HttpResponse res = pair.request() - .path("url") + .path("uri") + .path("uriTest") .queryParam("a", "av") .GET().asString(); assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("url:http://localhost:" + pair.port() + "/url"); + assertThat(res.body()).contains("/uri/uriTest?a=av"); } @Test void fullUrl_no_queryString() { HttpResponse res = pair.request() .path("fullUrl") + .path("noQuery") .GET().asString(); assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("fullUrl:http://localhost:" + pair.port() + "/fullUrl"); + assertThat(res.body()).isEqualTo("fullUrl:http://localhost:" + pair.port() + "/fullUrl/noQuery"); } @Test void fullUrl_queryString() { HttpResponse res = pair.request() .path("fullUrl") + .path("query") + .queryParam("a", "av") + .queryParam("b", "bv") + .GET().asString(); + + assertThat(res.statusCode()).isEqualTo(200); + assertThat(res.body()).isEqualTo("fullUrl:http://localhost:" + pair.port() + "/fullUrl/query?a=av&b=bv"); + } + + @Test + void matchedPath() { + HttpResponse res = pair.request() + .path("matchedPath") + .path("query") .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"); + assertThat(res.body()).isEqualTo("matchedPath:/matchedPath/{param}"); } @Test @@ -90,7 +108,7 @@ void contextPath() { .GET().asString(); assertThat(res.statusCode()).isEqualTo(200); - assertThat(res.body()).isEqualTo("contextPath:"); + assertThat(res.body()).isEqualTo("contextPath:/"); } @Test diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/ContextTest.java b/avaje-jex/src/test/java/io/avaje/jex/core/ContextTest.java similarity index 75% rename from avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/ContextTest.java rename to avaje-jex/src/test/java/io/avaje/jex/core/ContextTest.java index 478c347b..476fb66f 100644 --- a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/ContextTest.java +++ b/avaje-jex/src/test/java/io/avaje/jex/core/ContextTest.java @@ -1,14 +1,16 @@ -package io.avaje.jex.jdk; +package io.avaje.jex.core; -import io.avaje.jex.Jex; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.Test; +import static java.util.Objects.requireNonNull; +import static org.assertj.core.api.Assertions.assertThat; import java.net.http.HttpResponse; +import java.util.Map; import java.util.Optional; -import static java.util.Objects.requireNonNull; -import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +import io.avaje.jex.Jex; class ContextTest { @@ -34,11 +36,19 @@ static TestPair init() { 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())) + .post("/doubleJsonStream", ctx -> { + ctx.bodyAsInputStream().readAllBytes(); + ctx.text(ctx.bodyAsClass(Map.class)+""); + }) + .post("/doubleJsonStreamBytes", ctx -> { + ctx.body(); + ctx.text(ctx.bodyAsClass(Map.class)+""); + }) + .post("/doubleString", ctx -> ctx.text(ctx.body() + ctx.body())) .get("/status", ctx -> { ctx.status(201); ctx.text("status:" + ctx.status()); @@ -110,57 +120,37 @@ void ctx_ip() { 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(); + void ctx_methodPathPortProtocol() { + HttpResponse res = pair.request().path("method") + .GET().asString(); - assertThat(res.body()).isEqualTo("isMultipart:false isMultipartFormData:false"); + assertThat(res.body()).isEqualTo("method:GET path:/method protocol:HTTP/1.1 port:" + pair.port()); } - @Test - void ctx_isMultiPart_when_nothing() { - HttpResponse res = pair.request().path("multipart") - .body("junk") - .POST().asString(); - - assertThat(res.body()).isEqualTo("isMultipart:false isMultipartFormData:false"); + void post_double_string() { + HttpResponse res = pair.request().path("echo").body("simple").POST().asString(); + assertThat(res.body()).isEqualTo("req-body[simple]"); } -// @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(); + void post_double_json_fail() { + HttpResponse res = pair.request().path("doubleJsonStream").body("{}").POST().asString(); + assertThat(res.body()).isEqualTo("Internal Server Error"); + } - assertThat(res.body()).isEqualTo("method:GET path:/method protocol:HTTP/1.1 port:" + pair.port()); + @Test + void post_double_json_bytes() { + HttpResponse res = pair.request().path("doubleJsonStreamBytes").body("{}").POST().asString(); + assertThat(res.body()).isEqualTo("{}"); } @Test void post_body() { - HttpResponse res = pair.request().path("echo").body("simple").POST().asString(); - assertThat(res.body()).isEqualTo("req-body[simple]"); + HttpResponse res = pair.request().path("doubleString").body("simple").POST().asString(); + assertThat(res.body()).isEqualTo("simplesimple"); } @Test 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 index 944bcc8b..d65d06cb 100644 --- a/avaje-jex/src/test/java/io/avaje/jex/core/ContextUtilTest.java +++ b/avaje-jex/src/test/java/io/avaje/jex/core/ContextUtilTest.java @@ -1,23 +1,25 @@ package io.avaje.jex.core; -import org.junit.jupiter.api.Test; - import static org.assertj.core.api.Assertions.assertThat; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + class ContextUtilTest { @Test void parseCharset_defaults() { - assertThat(CoreServiceManager.parseCharset("")).isEqualTo(CoreServiceManager.UTF_8); - assertThat(CoreServiceManager.parseCharset("junk")).isEqualTo(CoreServiceManager.UTF_8); + assertThat(ServiceManager.parseCharset("")).isEqualTo(StandardCharsets.UTF_8); + assertThat(ServiceManager.parseCharset("junk")).isEqualTo(StandardCharsets.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"); + assertThat(ServiceManager.parseCharset("app/foo; charset=Us-AsCiI")).isEqualTo(StandardCharsets.US_ASCII); + assertThat(ServiceManager.parseCharset("app/foo;charset=Us-AsCiI")).isEqualTo(StandardCharsets.US_ASCII); + assertThat(ServiceManager.parseCharset("app/foo;charset = Us-AsCiI ")).isEqualTo(StandardCharsets.US_ASCII); + assertThat(ServiceManager.parseCharset("app/foo;charset = Us-AsCiI;")).isEqualTo(StandardCharsets.US_ASCII); + assertThat(ServiceManager.parseCharset("app/foo;charset = Us-AsCiI;other=junk")).isEqualTo(StandardCharsets.US_ASCII); } } diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/CookieParserTest.java b/avaje-jex/src/test/java/io/avaje/jex/core/CookieParserTest.java similarity index 98% rename from avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/CookieParserTest.java rename to avaje-jex/src/test/java/io/avaje/jex/core/CookieParserTest.java index 46649180..a63c57ef 100644 --- a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/CookieParserTest.java +++ b/avaje-jex/src/test/java/io/avaje/jex/core/CookieParserTest.java @@ -1,4 +1,4 @@ -package io.avaje.jex.jdk; +package io.avaje.jex.core; import org.junit.jupiter.api.Test; diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/CookieServerTest.java b/avaje-jex/src/test/java/io/avaje/jex/core/CookieServerTest.java similarity index 96% rename from avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/CookieServerTest.java rename to avaje-jex/src/test/java/io/avaje/jex/core/CookieServerTest.java index 20a4610f..1061e37c 100644 --- a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/CookieServerTest.java +++ b/avaje-jex/src/test/java/io/avaje/jex/core/CookieServerTest.java @@ -1,14 +1,15 @@ -package io.avaje.jex.jdk; +package io.avaje.jex.core; -import io.avaje.jex.Context; -import io.avaje.jex.Jex; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; import java.net.http.HttpResponse; import java.time.Duration; -import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +import io.avaje.jex.Jex; +import io.avaje.jex.http.Context; class CookieServerTest { @@ -27,7 +28,7 @@ static TestPair init() { ctx.cookie(httpCookie).text("ok"); }) ); - return TestPair.create(app, 9001); + return TestPair.create(app); } @AfterAll diff --git a/avaje-jex/src/test/java/io/avaje/jex/DefaultErrorHandlingTest.java b/avaje-jex/src/test/java/io/avaje/jex/core/DefaultErrorHandlingTest.java similarity index 61% rename from avaje-jex/src/test/java/io/avaje/jex/DefaultErrorHandlingTest.java rename to avaje-jex/src/test/java/io/avaje/jex/core/DefaultErrorHandlingTest.java index bed06272..e32650b7 100644 --- a/avaje-jex/src/test/java/io/avaje/jex/DefaultErrorHandlingTest.java +++ b/avaje-jex/src/test/java/io/avaje/jex/core/DefaultErrorHandlingTest.java @@ -1,10 +1,15 @@ -package io.avaje.jex; +package io.avaje.jex.core; -import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; import java.nio.file.DirectoryIteratorException; -import static org.assertj.core.api.Assertions.assertThat; +import io.avaje.jex.Jex; +import io.avaje.jex.Routing; +import io.avaje.jex.http.Context; +import io.avaje.jex.http.ExceptionHandler; + +import org.junit.jupiter.api.Test; class DefaultErrorHandlingTest { @@ -13,9 +18,10 @@ class DefaultErrorHandlingTest { @Test void exception() { + Routing router = Jex.create().routing(); + router.error(RuntimeException.class, rt); - DefaultErrorHandling handling = new DefaultErrorHandling(); - handling.exception(RuntimeException.class, rt); + var handling = new ExceptionManager(router.errorHandlers()); assertThat(handling.find(RuntimeException.class)).isSameAs(rt); assertThat(handling.find(IllegalStateException.class)).isSameAs(rt); @@ -24,10 +30,11 @@ void exception() { @Test void exception_expect_highestMatch() { + Routing router = Jex.create().routing(); + router.error(RuntimeException.class, rt); + router.error(IllegalStateException.class, ise); - DefaultErrorHandling handling = new DefaultErrorHandling(); - handling.exception(RuntimeException.class, rt); - handling.exception(IllegalStateException.class, ise); + var handling = new ExceptionManager(router.errorHandlers()); assertThat(handling.find(IllegalStateException.class)).isSameAs(ise); assertThat(handling.find(RuntimeException.class)).isSameAs(rt); @@ -37,14 +44,12 @@ void exception_expect_highestMatch() { private static class RT implements ExceptionHandler { @Override - public void handle(RuntimeException exception, Context ctx) { - } + public void handle(Context ctx, RuntimeException exception) {} } private static class ISE implements ExceptionHandler { @Override - public void handle(IllegalStateException exception, Context ctx) { - } + public void handle(Context ctx, IllegalStateException exception) {} } } diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/ExceptionManagerTest.java b/avaje-jex/src/test/java/io/avaje/jex/core/ExceptionManagerTest.java similarity index 54% rename from avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/ExceptionManagerTest.java rename to avaje-jex/src/test/java/io/avaje/jex/core/ExceptionManagerTest.java index 2e6f60ca..a63d89ef 100644 --- a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/ExceptionManagerTest.java +++ b/avaje-jex/src/test/java/io/avaje/jex/core/ExceptionManagerTest.java @@ -1,14 +1,18 @@ -package io.avaje.jex.jdk; +package io.avaje.jex.core; -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 static org.assertj.core.api.Assertions.assertThat; import java.net.http.HttpResponse; +import java.util.Map; -import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +import io.avaje.jex.Jex; +import io.avaje.jex.http.BadRequestException; +import io.avaje.jex.http.HttpStatus; +import io.avaje.jex.http.HttpResponseException; +import io.avaje.json.JsonException; class ExceptionManagerTest { @@ -18,20 +22,26 @@ static TestPair init() { final Jex app = Jex.create() .routing(routing -> routing .get("/", ctx -> { - throw new ForbiddenResponse(); + throw new HttpResponseException(HttpStatus.FORBIDDEN_403.status(), "Forbidden"); }) .post("/", ctx -> { throw new IllegalStateException("foo"); }) .get("/conflict", ctx -> { - throw new ConflictResponse("Baz"); + throw new HttpResponseException(409, "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())); + }) + .put("/nested", ctx -> { + throw new JsonException("hmm"); + }) + .patch("/patch", ctx -> { + throw new BadRequestException(Map.of("error","bad request")); + }) + .error(NullPointerException.class, (ctx, exception) -> ctx.text("npe")) + .error(IllegalStateException.class, (ctx, exception) -> ctx.status(222).text("Handled IllegalStateException|" + exception.getMessage())) + .error(JsonException.class, (ctx, exception) -> {throw new IllegalStateException();})); return TestPair.create(app); } @@ -44,8 +54,8 @@ static void end() { @Test void get() { HttpResponse res = pair.request().GET().asString(); - assertThat(res.statusCode()).isEqualTo(223); - assertThat(res.body()).isEqualTo("Handled ForbiddenResponse|Forbidden"); + assertThat(res.statusCode()).isEqualTo(403); + assertThat(res.body()).isEqualTo("Forbidden"); } @Test @@ -55,6 +65,21 @@ void post() { assertThat(res.body()).isEqualTo("Handled IllegalStateException|foo"); } + @Test + void patch() { + HttpResponse res = pair.request().path("patch").PATCH().asString(); + assertThat(res.statusCode()).isEqualTo(400); + assertThat(res.body()).isEqualTo("{\"error\":\"bad request\"}"); + assertThat(res.headers().firstValue("Content-Type").get()).contains("application/json"); + } + + @Test + void expect_fallback_to_fallback() { + HttpResponse res = pair.request().path("nested").PUT().asString(); + assertThat(res.statusCode()).isEqualTo(500); + assertThat(res.body()).isEqualTo("Internal Server Error"); + } + @Test void expect_fallback_to_default_asPlainText() { HttpResponse res = pair.request().path("conflict").GET().asString(); @@ -75,6 +100,6 @@ void expect_fallback_to_default_asJson() { 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"); + assertThat(res.body()).isEqualTo("Internal Server Error"); } } diff --git a/avaje-jex/src/test/java/io/avaje/jex/core/FilterTest.java b/avaje-jex/src/test/java/io/avaje/jex/core/FilterTest.java new file mode 100644 index 00000000..0945e695 --- /dev/null +++ b/avaje-jex/src/test/java/io/avaje/jex/core/FilterTest.java @@ -0,0 +1,118 @@ +package io.avaje.jex.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.http.HttpHeaders; +import java.net.http.HttpResponse; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.LockSupport; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +import io.avaje.jex.Jex; + +class FilterTest { + + static final TestPair pair = init(); + static final AtomicReference afterAll = new AtomicReference<>(); + static final AtomicReference afterTwo = new AtomicReference<>(); + + static TestPair init() { + final Jex app = + Jex.create() + .routing( + routing -> + routing + .get("/", ctx -> ctx.text("roo")) + .get( + "/noResponse", + ctx -> { + ctx.header("Content-Type", ""); + }) + .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")) + .filter( + (ctx, chain) -> { + if (ctx.path().contains("/two/")) { + ctx.header("before-two", "set"); + } + chain.proceed(); + }) + .after(ctx -> afterAll.set("set")) + .filter( + (ctx, chain) -> { + chain.proceed(); + if (ctx.path().contains("/two/")) { + 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 getNoResponse() { + clearAfter(); + HttpResponse res = pair.request().path("noResponse").GET().asString(); + assertThat(res.statusCode()).isEqualTo(204); + 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.statusCode()).isLessThan(300); + assertThat(res.headers().firstValue("before-two")).isEmpty(); + assertThat(afterTwo.get()).isNull(); + } + + private void assertHasBeforeAfterAll(HttpResponse res) { + assertThat(res.statusCode()).isLessThan(300); + assertThat(res.headers().firstValue("before-all")).get().isEqualTo("set"); + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(2)); + assertThat(afterAll.get()).isEqualTo("set"); + } +} diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/HeadersTest.java b/avaje-jex/src/test/java/io/avaje/jex/core/HeadersTest.java similarity index 88% rename from avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/HeadersTest.java rename to avaje-jex/src/test/java/io/avaje/jex/core/HeadersTest.java index 83e9738c..2fb8e75d 100644 --- a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/HeadersTest.java +++ b/avaje-jex/src/test/java/io/avaje/jex/core/HeadersTest.java @@ -1,6 +1,6 @@ -package io.avaje.jex.jdk; +package io.avaje.jex.core; -import io.avaje.http.client.HttpClientContext; +import io.avaje.http.client.HttpClient; import io.avaje.http.client.JacksonBodyAdapter; import io.avaje.jex.Jex; import org.junit.jupiter.api.BeforeAll; @@ -17,7 +17,7 @@ class HeadersTest { static final int port = new Random().nextInt(1000) + 10_000; static Jex.Server server; - static HttpClientContext client; + static HttpClient client; @BeforeAll static void setup() { @@ -33,7 +33,7 @@ static void setup() { .port(port) .start(); - client = HttpClientContext.builder() + client = HttpClient.builder() .baseUrl("http://localhost:"+port) .bodyAdapter(new JacksonBodyAdapter()) .build(); diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/HealthPluginOffTest.java b/avaje-jex/src/test/java/io/avaje/jex/core/HealthPluginOffTest.java similarity index 93% rename from avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/HealthPluginOffTest.java rename to avaje-jex/src/test/java/io/avaje/jex/core/HealthPluginOffTest.java index 2b1612aa..790b9a5c 100644 --- a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/HealthPluginOffTest.java +++ b/avaje-jex/src/test/java/io/avaje/jex/core/HealthPluginOffTest.java @@ -1,4 +1,4 @@ -package io.avaje.jex.jdk; +package io.avaje.jex.core; import io.avaje.jex.Jex; import org.junit.jupiter.api.AfterAll; @@ -14,7 +14,7 @@ class HealthPluginOffTest { static TestPair init() { final Jex app = Jex.create() - .configure(config -> config.health(false)) + .config(config -> config.health(false)) .routing(routing -> routing .get("/", ctx -> ctx.text("hello")) ); diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/HealthPluginTest.java b/avaje-jex/src/test/java/io/avaje/jex/core/HealthPluginTest.java similarity index 93% rename from avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/HealthPluginTest.java rename to avaje-jex/src/test/java/io/avaje/jex/core/HealthPluginTest.java index ad47a08f..fb0438e4 100644 --- a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/HealthPluginTest.java +++ b/avaje-jex/src/test/java/io/avaje/jex/core/HealthPluginTest.java @@ -1,10 +1,9 @@ -package io.avaje.jex.jdk; +package io.avaje.jex.core; -import io.avaje.http.client.HttpClientContext; +import io.avaje.http.client.HttpClient; 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; @@ -21,7 +20,7 @@ class HealthPluginTest { static final int port = new Random().nextInt(1000) + 10_000; static Jex jex; static Jex.Server server; - static HttpClientContext client; + static HttpClient client; @BeforeAll static void setup() { @@ -37,7 +36,7 @@ static void setup() { .port(port); server = jex.start(); - client = HttpClientContext.builder() + client = HttpClient.builder() .baseUrl("http://localhost:"+port) .bodyAdapter(new JacksonBodyAdapter()) .build(); diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/HelloBean.java b/avaje-jex/src/test/java/io/avaje/jex/core/HelloBean.java similarity index 74% rename from avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/HelloBean.java rename to avaje-jex/src/test/java/io/avaje/jex/core/HelloBean.java index a89d5f57..1b1a640c 100644 --- a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/HelloBean.java +++ b/avaje-jex/src/test/java/io/avaje/jex/core/HelloBean.java @@ -1,5 +1,8 @@ -package io.avaje.jex.jdk; +package io.avaje.jex.core; +import io.avaje.jsonb.Json; + +@Json public class HelloBean { public int id; diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/HelloDto.java b/avaje-jex/src/test/java/io/avaje/jex/core/HelloDto.java similarity index 87% rename from avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/HelloDto.java rename to avaje-jex/src/test/java/io/avaje/jex/core/HelloDto.java index e8861f3f..5cc0ea98 100644 --- a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/HelloDto.java +++ b/avaje-jex/src/test/java/io/avaje/jex/core/HelloDto.java @@ -1,5 +1,8 @@ -package io.avaje.jex.jdk; +package io.avaje.jex.core; +import io.avaje.jsonb.Json; + +@Json public class HelloDto { public long id; diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/JdkJexServerTest.java b/avaje-jex/src/test/java/io/avaje/jex/core/JdkJexServerTest.java similarity index 90% rename from avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/JdkJexServerTest.java rename to avaje-jex/src/test/java/io/avaje/jex/core/JdkJexServerTest.java index 085e5f69..9910e196 100644 --- a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/JdkJexServerTest.java +++ b/avaje-jex/src/test/java/io/avaje/jex/core/JdkJexServerTest.java @@ -1,13 +1,14 @@ -package io.avaje.jex.jdk; +package io.avaje.jex.core; -import io.avaje.http.client.HttpClientContext; -import io.avaje.http.client.JacksonBodyAdapter; -import io.avaje.jex.Jex; -import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; import java.net.http.HttpResponse; -import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.Test; + +import io.avaje.http.client.HttpClient; +import io.avaje.http.client.JacksonBodyAdapter; +import io.avaje.jex.Jex; class JdkJexServerTest { @@ -28,7 +29,7 @@ void init() { .port(8093) .start(); - final HttpClientContext client = HttpClientContext.builder() + final HttpClient client = HttpClient.builder() .baseUrl("http://localhost:8093") .bodyAdapter(new JacksonBodyAdapter()) .build(); diff --git a/avaje-jex/src/test/java/io/avaje/jex/core/JsonTest.java b/avaje-jex/src/test/java/io/avaje/jex/core/JsonTest.java new file mode 100644 index 00000000..66fc947c --- /dev/null +++ b/avaje-jex/src/test/java/io/avaje/jex/core/JsonTest.java @@ -0,0 +1,206 @@ +package io.avaje.jex.core; + +import static java.util.Arrays.asList; +import static java.util.stream.Collectors.toList; +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.http.HttpHeaders; +import java.net.http.HttpResponse; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.LockSupport; +import java.util.stream.Stream; + +import io.avaje.jex.core.json.JsonbOutput; +import io.avaje.jsonb.Json; +import io.avaje.jsonb.JsonType; +import io.avaje.jsonb.Jsonb; +import io.avaje.jsonb.Types; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +import io.avaje.jex.Jex; +import io.avaje.jex.core.json.JacksonJsonService; + +public 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 final TestPair pair = init(); + static final Jsonb jsonb = Jsonb.builder().build(); + static final JsonType jsonTypeHelloDto = jsonb.type(HelloDto.class); + + @Json + public record Generic(T value){} + + static TestPair init() { + Jex app = + Jex.create() + .jsonService(new JacksonJsonService()) + .get("/", ctx -> ctx.status(200).json(HelloDto.rob())) + .post( + "/generic", + ctx -> { + var type = Types.newParameterizedType(Generic.class, String.class); + String value = ctx.>bodyAsType(type).value; + ctx.text(value); + }) + .get( + "/usingOutputStream", + ctx -> { + ctx.status(200).contentType("application/json"); + var result = HelloDto.rob(); + jsonTypeHelloDto.toJson(result, ctx.outputStream()); + }) + .get( + "/usingJsonOutput", + ctx -> { + ctx.status(200).contentType("application/json"); + var result = HelloDto.fi(); + jsonTypeHelloDto.toJson(result, JsonbOutput.of(ctx)); + }) + .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").orElseThrow()).isEqualTo("application/json"); + } + + @Test + void generic() { + var generic = new Generic<>("stringy"); + var bean = pair.request().path("generic").body(generic).POST().asString().body(); + + assertThat(bean).isEqualTo(generic.value); + } + + @Test + void usingOutputStream() { + + var bean = pair.request().path("usingOutputStream") + .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").orElseThrow()).isEqualTo("application/json"); + + bean = pair.request().path("usingOutputStream") + .GET() + .bean(HelloDto.class); + assertThat(bean.id).isEqualTo(42); + assertThat(bean.name).isEqualTo("rob"); + } + + @Test + void usingJsonOutput() { + var hres = pair.request().path("usingJsonOutput") + .GET() + .as(HelloDto.class); + + assertThat(hres.statusCode()).isEqualTo(200); + final HttpHeaders headers = hres.headers(); + assertThat(headers.firstValue("Content-Type").orElseThrow()).isEqualTo("application/json"); + + var bean = hres.body(); + assertThat(bean.id).isEqualTo(45); + assertThat(bean.name).isEqualTo("fi"); + } + + + @Test + void stream_viaIterator() { + final Stream beanStream = pair.request() + .path("iterate") + .GET() + .stream(HelloDto.class); + + // expect client gets the expected stream of beans + assertCollectedStream(beanStream); + // assert AutoCloseable iterator on the server-side was closed + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(10)); + assertThat(ITERATOR.isClosed()).isTrue(); + } + + @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/src/test/java/io/avaje/jex/core/Main.java similarity index 78% rename from avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/Main.java rename to avaje-jex/src/test/java/io/avaje/jex/core/Main.java index dbdb29b3..62c8da9a 100644 --- a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/Main.java +++ b/avaje-jex/src/test/java/io/avaje/jex/core/Main.java @@ -1,7 +1,6 @@ -package io.avaje.jex.jdk; +package io.avaje.jex.core; import io.avaje.jex.Jex; -import io.avaje.jex.core.HealthPlugin; public class Main { diff --git a/avaje-jex/src/test/java/io/avaje/jex/core/MultiHandlerTest.java b/avaje-jex/src/test/java/io/avaje/jex/core/MultiHandlerTest.java new file mode 100644 index 00000000..a5d627bd --- /dev/null +++ b/avaje-jex/src/test/java/io/avaje/jex/core/MultiHandlerTest.java @@ -0,0 +1,78 @@ +package io.avaje.jex.core; + +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 MultiHandlerTest { + + static TestPair pair = init(); + + static TestPair init() { + Jex app = Jex.create() + .routing(routing -> routing + .get("/hi", ctx4 -> { + if (ctx4.header("Hx-Request") != null) { + ctx4.text("HxResponse"); + } + }) + .get("/hi", ctx -> ctx.text("NormalResponse")) + .get("/hi/{id}", ctx3 -> { + if (ctx3.header("Hx-Request") != null) { + ctx3.text("HxResponse|" + ctx3.pathParam("id")); + } + }) + .get("/hi/{id}", ctx2 -> { + if (ctx2.header("H2-Request") != null) { + ctx2.text("H2Response|" + ctx2.pathParam("id")); + } + }) + .get("/hi/{id}", ctx1 -> ctx1.text("NormalResponse|" + ctx1.pathParam("id"))) + ); + return TestPair.create(app); + } + + @AfterAll + static void end() { + pair.shutdown(); + } + + @Test + void test() { + HttpResponse hres = pair.request().path("hi").GET().asString(); + assertThat(hres.statusCode()).isEqualTo(200); + assertThat(hres.body()).isEqualTo("NormalResponse"); + + HttpResponse hxRes = pair.request() + .header("Hx-Request", "true") + .path("hi") + .GET().asString(); + assertThat(hxRes.statusCode()).isEqualTo(200); + assertThat(hxRes.body()).isEqualTo("HxResponse"); + } + + @Test + void testWithPathParam() { + HttpResponse hres = pair.request().path("hi/42").GET().asString(); + assertThat(hres.statusCode()).isEqualTo(200); + assertThat(hres.body()).isEqualTo("NormalResponse|42"); + + HttpResponse hxRes = pair.request() + .header("Hx-Request", "true") + .path("hi/42") + .GET().asString(); + assertThat(hxRes.statusCode()).isEqualTo(200); + assertThat(hxRes.body()).isEqualTo("HxResponse|42"); + + HttpResponse h2Res = pair.request() + .header("H2-Request", "true") + .path("hi/42") + .GET().asString(); + assertThat(h2Res.statusCode()).isEqualTo(200); + assertThat(h2Res.body()).isEqualTo("H2Response|42"); + } +} diff --git a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/NestedRoutesTest.java b/avaje-jex/src/test/java/io/avaje/jex/core/NestedRoutesTest.java similarity index 76% rename from avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/NestedRoutesTest.java rename to avaje-jex/src/test/java/io/avaje/jex/core/NestedRoutesTest.java index f12a1469..ef183a8c 100644 --- a/avaje-jex-jdk/src/test/java/io/avaje/jex/jdk/NestedRoutesTest.java +++ b/avaje-jex/src/test/java/io/avaje/jex/core/NestedRoutesTest.java @@ -1,4 +1,4 @@ -package io.avaje.jex.jdk; +package io.avaje.jex.core; import io.avaje.jex.Jex; import org.junit.jupiter.api.AfterAll; @@ -16,14 +16,14 @@ 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"))); + .group("api", g -> { + g.get("/", ctx -> ctx.text("apiRoot")); + g.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"))); + .group("extra", g -> { + g.get("/", ctx -> ctx.text("extraRoot")); + g.get("{id}", ctx -> ctx.text("extra-id-" + ctx.pathParam("id"))); + g.get("more/{id}", ctx -> ctx.text("extraMore-" + ctx.pathParam("id"))); })); return TestPair.create(app); } diff --git a/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/QueryParamTest.java b/avaje-jex/src/test/java/io/avaje/jex/core/QueryParamTest.java similarity index 92% rename from avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/QueryParamTest.java rename to avaje-jex/src/test/java/io/avaje/jex/core/QueryParamTest.java index 3ebd8b96..7230ef37 100644 --- a/avaje-jex-grizzly/src/test/java/io/avaje/jex/grizzly/QueryParamTest.java +++ b/avaje-jex/src/test/java/io/avaje/jex/core/QueryParamTest.java @@ -1,4 +1,4 @@ -package io.avaje.jex.grizzly; +package io.avaje.jex.core; import io.avaje.jex.Jex; import org.junit.jupiter.api.AfterAll; @@ -21,6 +21,7 @@ static TestPair init() { .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("/plus/{plus}", ctx -> ctx.text(ctx.pathParam("plus")+ctx.queryParam("plus"))) .get("/scheme", ctx -> ctx.text("scheme: "+ctx.scheme())) ); return TestPair.create(app); @@ -136,6 +137,15 @@ void queryString_when_set() { assertThat(res.body()).isEqualTo("qs: foo=f1&bar=b1&bar=b2"); } + @Test + void plus() { + HttpResponse res = pair.request().path("plus/+") + .queryParam("plus","+") + .GET().asString(); + assertThat(res.statusCode()).isEqualTo(200); + assertThat(res.body()).isEqualTo("++"); + } + @Test void scheme() { HttpResponse res = pair.request().path("scheme") diff --git a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/RedirectTest.java b/avaje-jex/src/test/java/io/avaje/jex/core/RedirectTest.java similarity index 54% rename from avaje-jex-jetty/src/test/java/io/avaje/jex/base/RedirectTest.java rename to avaje-jex/src/test/java/io/avaje/jex/core/RedirectTest.java index 96383246..9680812b 100644 --- a/avaje-jex-jetty/src/test/java/io/avaje/jex/base/RedirectTest.java +++ b/avaje-jex/src/test/java/io/avaje/jex/core/RedirectTest.java @@ -1,4 +1,4 @@ -package io.avaje.jex.base; +package io.avaje.jex.core; import io.avaje.jex.Jex; import org.junit.jupiter.api.AfterAll; @@ -10,18 +10,23 @@ 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")) - ); + var app = + Jex.create() + .routing( + routing -> + routing + .filter( + (ctx, chain) -> { + if (ctx.path().contains("/other/")) ctx.redirect("/two?from=filter"); + chain.proceed(); + }) + .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); } diff --git a/avaje-jex/src/test/java/io/avaje/jex/core/TestPair.java b/avaje-jex/src/test/java/io/avaje/jex/core/TestPair.java new file mode 100644 index 00000000..2bab7988 --- /dev/null +++ b/avaje-jex/src/test/java/io/avaje/jex/core/TestPair.java @@ -0,0 +1,56 @@ +package io.avaje.jex.core; + +import java.net.http.HttpClient.Version; +import java.time.Duration; + +import io.avaje.http.client.HttpClient; +import io.avaje.http.client.HttpClientRequest; +import io.avaje.jex.Jex; + +/** Server and Client pair for a test. */ +public class TestPair { + + private final int port; + + private final Jex.Server server; + + private final HttpClient client; + + public TestPair(int port, Jex.Server server, HttpClient 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) { + + var jexServer = app.port(0).start(); + var port = jexServer.port(); + var url = "http://localhost:" + port; + var client = + HttpClient.builder() + .version(Version.HTTP_1_1) + .requestTimeout(Duration.ofDays(1)) + .baseUrl(url) + .build(); + + return new TestPair(port, jexServer, client); + } +} diff --git a/avaje-jex/src/test/java/io/avaje/jex/http/JdkFilterTest.java b/avaje-jex/src/test/java/io/avaje/jex/http/JdkFilterTest.java new file mode 100644 index 00000000..3d33724b --- /dev/null +++ b/avaje-jex/src/test/java/io/avaje/jex/http/JdkFilterTest.java @@ -0,0 +1,119 @@ +package io.avaje.jex.http; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.http.HttpHeaders; +import java.net.http.HttpResponse; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.LockSupport; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +import com.sun.net.httpserver.Filter; + +import io.avaje.jex.Jex; +import io.avaje.jex.core.TestPair; + +class JdkFilterTest { + + static final TestPair pair = init(); + static final AtomicReference afterAll = new AtomicReference<>(); + static final AtomicReference afterTwo = new AtomicReference<>(); + + static TestPair init() { + final Jex app = + Jex.create() + .routing( + routing -> + routing + .get("/", ctx -> ctx.text("roo")) + .get("/noResponse", ctx -> {}) + .get("/one", ctx -> ctx.text("one")) + .get("/two", ctx -> ctx.text("two")) + .get("/two/{id}", ctx -> ctx.text("two-id")) + .filter( + Filter.beforeHandler( + "before", ex -> ex.getResponseHeaders().set("before-all", "set"))) + .filter( + (ctx, chain) -> { + if (ctx.path().contains("/two/")) { + ctx.header("before-two", "set"); + } + chain.proceed(); + }) + .filter(Filter.afterHandler("after", ex -> afterAll.set("set"))) + .filter( + (ctx, chain) -> { + chain.proceed(); + if (ctx.path().contains("/two/")) { + 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 getNoResponse() { + clearAfter(); + HttpResponse res = pair.request().path("noResponse").GET().asString(); + assertThat(res.statusCode()).isEqualTo(204); + 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.statusCode()).isLessThan(300); + assertThat(res.headers().firstValue("before-two")).isEmpty(); + assertThat(afterTwo.get()).isNull(); + } + + private void assertHasBeforeAfterAll(HttpResponse res) { + assertThat(res.statusCode()).isLessThan(300); + assertThat(res.headers().firstValue("before-all")).get().isEqualTo("set"); + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(2)); + assertThat(afterAll.get()).isEqualTo("set"); + } +} diff --git a/avaje-jex/src/test/java/io/avaje/jex/http/TrailingSlashTest.java b/avaje-jex/src/test/java/io/avaje/jex/http/TrailingSlashTest.java new file mode 100644 index 00000000..260d495f --- /dev/null +++ b/avaje-jex/src/test/java/io/avaje/jex/http/TrailingSlashTest.java @@ -0,0 +1,42 @@ +package io.avaje.jex.http; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.http.HttpResponse; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +import io.avaje.jex.Jex; +import io.avaje.jex.core.TestPair; + +class TrailingSlashTest { + + static TestPair pair = init(); + + static TestPair init() { + final Jex app = + Jex.create() + .config(c -> c.socketBacklog(0).ignoreTrailingSlashes(false)) + .get("/slash", ctx -> {}); + + return TestPair.create(app); + } + + @AfterAll + static void end() { + pair.shutdown(); + } + + @Test + void get() { + HttpResponse res = pair.request().path("slash/").GET().asString(); + assertThat(res.statusCode()).isEqualTo(404); + } + + @Test + void getNoTrailing() { + HttpResponse res = pair.request().path("slash").GET().asString(); + assertThat(res.statusCode()).isEqualTo(204); + } +} diff --git a/avaje-jex/src/test/java/io/avaje/jex/http/sse/SseClientTest.java b/avaje-jex/src/test/java/io/avaje/jex/http/sse/SseClientTest.java new file mode 100644 index 00000000..7c085c48 --- /dev/null +++ b/avaje-jex/src/test/java/io/avaje/jex/http/sse/SseClientTest.java @@ -0,0 +1,195 @@ +package io.avaje.jex.http.sse; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +import io.avaje.jex.Jex; +import io.avaje.jex.core.Constants; +import io.avaje.jex.core.TestPair; +import io.avaje.jex.core.json.JacksonJsonService; + +class SseClientTest { + + static final TestPair pair = init(); + static final AtomicReference afterAll = new AtomicReference<>(); + static final AtomicReference afterTwo = new AtomicReference<>(); + + static TestPair init() { + final var app = + Jex.create() + .jsonService(new JacksonJsonService()) + .sse( + "/sse", + sse -> { + for (var i = 0; i < 4; i++) { + sse.sendEvent("count", "hi", i + ""); + } + }) + .sse( + "/is", + sse -> { + for (var i = 0; i < 2; i++) { + sse.sendEvent( + "count", + new ByteArrayInputStream(("IS val " + 1).getBytes(StandardCharsets.UTF_8)), + i + ""); + } + }) + .sse( + "/json", + sse -> { + for (var i = 0; i < 2; i++) { + sse.sendEvent("count", new JsonContent(i), i + ""); + } + }) + .sse( + "/keepAlive", + sse -> { + Thread.startVirtualThread( + () -> { + for (var i = 0; i < 2; i++) { + sse.sendComment("Sent And Closed"); + sse.close(); + } + }); + sse.keepAlive(); + }) + .sse( + "/multi", + sse -> { + sse.sendEvent("multi\nline"); + sse.sendComment("multi\nline"); + }); + + return TestPair.create(app); + } + + public record JsonContent(int value) {} + + @AfterAll + static void end() { + pair.shutdown(); + } + + @Test + void testSse() { + final var response = + pair.request() + .path("sse") + .header(Constants.ACCEPT, "text/event-stream") + .GET() + .asLines() + .body() + .toList(); + assertThat(response).hasSize(16); + + final var expected = + """ + id: 0 + event: count + data: hi + + id: 1 + event: count + data: hi + + id: 2 + event: count + data: hi + + id: 3 + event: count + data: hi + """; + assertThat(String.join("\n", response)).isEqualTo(expected); + } + + @Test + void testSseInputStream() { + final var response = + pair.request() + .path("is") + .header(Constants.ACCEPT, "text/event-stream") + .GET() + .asLines() + .body() + .toList(); + assertThat(response).hasSize(8); + + final var expected = + """ + id: 0 + event: count + data: IS val 1 + + id: 1 + event: count + data: IS val 1 + """; + assertThat(String.join("\n", response)).isEqualTo(expected); + } + + @Test + void testSseJson() { + final var response = + pair.request() + .path("json") + .header(Constants.ACCEPT, "text/event-stream") + .GET() + .asLines() + .body() + .toList(); + assertThat(response).hasSize(8); + + final var expected = + """ + id: 0 + event: count + data: {"value":0} + + id: 1 + event: count + data: {"value":1} + """; + assertThat(String.join("\n", response)).isEqualTo(expected); + } + + @Test + void testKeepAlive() { + final var response = + pair.request() + .path("keepAlive") + .header(Constants.ACCEPT, "text/event-stream") + .GET() + .asString() + .body(); + assertThat(response).isEqualTo(": Sent And Closed\n"); + } + + @Test + void testMultiLineData() { + final var response = + pair.request() + .path("multi") + .header(Constants.ACCEPT, "text/event-stream") + .GET() + .asString() + .body(); + final var expected = + """ + event: message + data: multi + data: line + + : multi + : line + """; + assertThat(response).isEqualTo(expected); + } +} diff --git a/avaje-jex/src/test/java/io/avaje/jex/routes/PathParserTest.java b/avaje-jex/src/test/java/io/avaje/jex/routes/PathParserTest.java index dcfaf889..b5ec772f 100644 --- a/avaje-jex/src/test/java/io/avaje/jex/routes/PathParserTest.java +++ b/avaje-jex/src/test/java/io/avaje/jex/routes/PathParserTest.java @@ -21,7 +21,7 @@ void matches_trailingSlash_honor() { assertTrue(pathParser.matches("/one/1/")); assertTrue(pathParser.matches("/one/2/")); - assertTrue(pathParser.matches("/one/3//")); // accepts trailing double slash? + assertFalse(pathParser.matches("/one/3//")); // accepts trailing double slash? assertFalse(pathParser.matches("/one/3///")); // but not triple slash? assertFalse(pathParser.matches("/one/1")); assertFalse(pathParser.matches("/one/2")); diff --git a/avaje-jex/src/test/java/io/avaje/jex/routes/RouteIndexTest.java b/avaje-jex/src/test/java/io/avaje/jex/routes/RouteIndexTest.java index 5ef62346..674bc734 100644 --- a/avaje-jex/src/test/java/io/avaje/jex/routes/RouteIndexTest.java +++ b/avaje-jex/src/test/java/io/avaje/jex/routes/RouteIndexTest.java @@ -1,47 +1,61 @@ package io.avaje.jex.routes; -import io.avaje.jex.Routing; -import io.avaje.jex.spi.SpiRoutes; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - import static org.assertj.core.api.Assertions.assertThat; -class RouteIndexTest { +import java.util.Set; - private static final Routing.Entry routingEntry = Mockito.mock(Routing.Entry.class); +import org.junit.jupiter.api.Test; + +class RouteIndexTest { @Test void match() { - RouteIndex index = new RouteIndex(); - index.add(entry("/")); - index.add(entry("/a/b/c")); - index.add(entry("/a/b/d")); - index.add(entry("/a/b/d/e")); - index.add(entry("/a/b/d/e/f")); - index.add(entry("/a/b/d/e/f/g")); - index.add(entry("/a/b/d/e/f/g/h")); - index.add(entry("/a/b/d/e/f/g2/h")); + var indexBuild = new RouteIndexBuild(); + indexBuild.add(entry("/")); + indexBuild.add(entry("/a/b/c")); + indexBuild.add(entry("/a/b/d")); + indexBuild.add(entry("/a/b/d/e")); + indexBuild.add(entry("/a/b/d/e/f")); + indexBuild.add(entry("/a/b/d/e/f/g")); + indexBuild.add(entry("/a/b/d/e/f/g/h")); + indexBuild.add(entry("/a/b/d/e/f/g2/h")); + + RouteIndex index = indexBuild.build(); assertThat(index.match("/").matchPath()).isEqualTo("/"); assertThat(index.match("/a/b/d/e/f/g2/h").matchPath()).isEqualTo("/a/b/d/e/f/g2/h"); } + @Test + void matchMulti() { + var indexBuild = new RouteIndexBuild(); + indexBuild.add(entry("/hi/{id}")); + indexBuild.add(entry("/a/b/c")); + indexBuild.add(entry("/hi/{id}")); + indexBuild.add(entry("/b")); + + RouteIndex index = indexBuild.build(); + + SpiRoutes.Entry entry = index.match("/hi/42"); + assertThat(entry).isNotNull(); + } + @Test void match_args() { - RouteIndex index = new RouteIndex(); - index.add(entry("/")); - index.add(entry("/{id}")); - index.add(entry("/{id}/a")); - index.add(entry("/{id}/b")); - index.add(entry("/a/{id}/c")); - index.add(entry("/a/{name}/d")); - index.add(entry("/a/b/d/e")); - index.add(entry("/a/b/d/e/f")); - index.add(entry("/a/b/d/e/f/g")); - index.add(entry("/a/b/d/e/f/g/h")); - index.add(entry("/a/b/d/e/f/g2/h")); + var indexBuild = new RouteIndexBuild(); + indexBuild.add(entry("/")); + indexBuild.add(entry("/{id}")); + indexBuild.add(entry("/{id}/a")); + indexBuild.add(entry("/{id}/b")); + indexBuild.add(entry("/a/{id}/c")); + indexBuild.add(entry("/a/{name}/d")); + indexBuild.add(entry("/a/b/d/e")); + indexBuild.add(entry("/a/b/d/e/f")); + indexBuild.add(entry("/a/b/d/e/f/g")); + indexBuild.add(entry("/a/b/d/e/f/g/h")); + indexBuild.add(entry("/a/b/d/e/f/g2/h")); + var index = indexBuild.build(); assertThat(index.match("/").matchPath()).isEqualTo("/"); assertThat(index.match("/42").matchPath()).isEqualTo("/{id}"); assertThat(index.match("/99").matchPath()).isEqualTo("/{id}"); @@ -52,12 +66,13 @@ void match_args() { @Test void match_splat() { - RouteIndex index = new RouteIndex(); - index.add(entry("/")); - index.add(entry("/{id}")); - index.add(entry("/{id}/a")); - index.add(entry("/{id}/*")); + var indexBuild = new RouteIndexBuild(); + indexBuild.add(entry("/")); + indexBuild.add(entry("/{id}")); + indexBuild.add(entry("/{id}/a")); + indexBuild.add(entry("/{id}/*")); + var index = indexBuild.build(); assertThat(index.match("/").matchPath()).isEqualTo("/"); assertThat(index.match("/42").matchPath()).isEqualTo("/{id}"); assertThat(index.match("/42/a").matchPath()).isEqualTo("/{id}/a"); @@ -66,6 +81,7 @@ void match_splat() { } private SpiRoutes.Entry entry(String path) { - return new RouteEntry(new PathParser(path, true), routingEntry.getHandler()); + return new RouteEntry(new PathParser(path, true), null, Set.of()); } + } diff --git a/avaje-jex/src/test/java/io/avaje/jex/security/AppRole.java b/avaje-jex/src/test/java/io/avaje/jex/security/AppRole.java new file mode 100644 index 00000000..63506445 --- /dev/null +++ b/avaje-jex/src/test/java/io/avaje/jex/security/AppRole.java @@ -0,0 +1,38 @@ +package io.avaje.jex.security; + +import java.util.HashMap; +import java.util.Map; + +import io.avaje.config.Config; +import io.avaje.jex.http.Context; + +public enum AppRole implements Role { + ANYONE("", ""), + USER(Config.get("roles.user.username", "test"), Config.get("roles.user.password", "test")); + + private final String username; + private final String password; + + AppRole(String username, String password) { + this.username = username; + this.password = password; + } + + private static final Map ROLE_MAP = createRoleMap(); + + private static Map createRoleMap() { + Map map = new HashMap<>(); + for (AppRole role : AppRole.values()) { + if (!ANYONE.equals(role)) { + map.put(role.username + ":" + role.password, role); + } + } + return map; + } + + public static AppRole getRole(Context ctx) { + + final var auth = ctx.basicAuthCredentials(); + return ROLE_MAP.getOrDefault(auth.userName() + ":" + auth.password(), ANYONE); + } +} diff --git a/avaje-jex/src/test/java/io/avaje/jex/security/BasicAuthCredentialsTest.java b/avaje-jex/src/test/java/io/avaje/jex/security/BasicAuthCredentialsTest.java new file mode 100644 index 00000000..d07ada2f --- /dev/null +++ b/avaje-jex/src/test/java/io/avaje/jex/security/BasicAuthCredentialsTest.java @@ -0,0 +1,57 @@ +package io.avaje.jex.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +import io.avaje.http.client.BasicAuthIntercept; +import io.avaje.jex.Jex; +import io.avaje.jex.core.TestPair; + +class BasicAuthCredentialsTest { + + static TestPair pair = init(); + + static TestPair init() { + + final Jex app = + Jex.create() + .get("/", ctx -> {}, AppRole.USER) + .filter( + (ctx, chain) -> { + if (!ctx.routeRoles().contains(AppRole.getRole(ctx))) { + + ctx.status(401).text(""); + } else { + chain.proceed(); + } + }); + + return TestPair.create(app); + } + + @AfterAll + static void end() { + pair.shutdown(); + } + + @Test + void testSuccess() { + var intercept = new BasicAuthIntercept("test", "test"); + var req = pair.request(); + intercept.beforeRequest(req); + var res = req.GET().asDiscarding(); + assertThat(res.statusCode()).isEqualTo(204); + } + + @Test + void testIncorrect() { + var intercept = new BasicAuthIntercept("test1", "test1"); + var req = pair.request(); + intercept.beforeRequest(req); + var res = req.GET().asDiscarding(); + assertThat(res.statusCode()).isEqualTo(401); + } + +} diff --git a/avaje-jex/src/test/resources/64KB.json b/avaje-jex/src/test/resources/64KB.json new file mode 100644 index 00000000..c2a33dc0 --- /dev/null +++ b/avaje-jex/src/test/resources/64KB.json @@ -0,0 +1,1381 @@ +[ + { + "name": "Adeel Solangi", + "language": "Sindhi", + "id": "V59OF92YF627HFY0", + "bio": "Donec lobortis eleifend condimentum. Cras dictum dolor lacinia lectus vehicula rutrum. Maecenas quis nisi nunc. Nam tristique feugiat est vitae mollis. Maecenas quis nisi nunc.", + "version": 6.1 + }, + { + "name": "Afzal Ghaffar", + "language": "Sindhi", + "id": "ENTOCR13RSCLZ6KU", + "bio": "Aliquam sollicitudin ante ligula, eget malesuada nibh efficitur et. Pellentesque massa sem, scelerisque sit amet odio id, cursus tempor urna. Etiam congue dignissim volutpat. Vestibulum pharetra libero et velit gravida euismod.", + "version": 1.88 + }, + { + "name": "Aamir Solangi", + "language": "Sindhi", + "id": "IAKPO3R4761JDRVG", + "bio": "Vestibulum pharetra libero et velit gravida euismod. Quisque mauris ligula, efficitur porttitor sodales ac, lacinia non ex. Fusce eu ultrices elit, vel posuere neque.", + "version": 7.27 + }, + { + "name": "Abla Dilmurat", + "language": "Uyghur", + "id": "5ZVOEPMJUI4MB4EN", + "bio": "Donec lobortis eleifend condimentum. Morbi ac tellus erat.", + "version": 2.53 + }, + { + "name": "Adil Eli", + "language": "Uyghur", + "id": "6VTI8X6LL0MMPJCC", + "bio": "Vivamus id faucibus velit, id posuere leo. Morbi vitae nisi lacinia, laoreet lorem nec, egestas orci. Suspendisse potenti.", + "version": 6.49 + }, + { + "name": "Adile Qadir", + "language": "Uyghur", + "id": "F2KEU5L7EHYSYFTT", + "bio": "Duis commodo orci ut dolor iaculis facilisis. Morbi ultricies consequat ligula posuere eleifend. Aenean finibus in tortor vel aliquet. Fusce eu ultrices elit, vel posuere neque.", + "version": 1.9 + }, + { + "name": "Abdukerim Ibrahim", + "language": "Uyghur", + "id": "LO6DVTZLRK68528I", + "bio": "Vivamus id faucibus velit, id posuere leo. Nunc aliquet sodales nunc a pulvinar. Nunc aliquet sodales nunc a pulvinar. Ut viverra quis eros eu tincidunt.", + "version": 5.9 + }, + { + "name": "Adil Abro", + "language": "Sindhi", + "id": "LJRIULRNJFCNZJAJ", + "bio": "Etiam malesuada blandit erat, nec ultricies leo maximus sed. Fusce congue aliquam elit ut luctus. Etiam malesuada blandit erat, nec ultricies leo maximus sed. Cras dictum dolor lacinia lectus vehicula rutrum. Integer vehicula, arcu sit amet egestas efficitur, orci justo interdum massa, eget ullamcorper risus ligula tristique libero.", + "version": 9.32 + }, + { + "name": "Afonso Vilarchán", + "language": "Galician", + "id": "JMCL0CXNXHPL1GBC", + "bio": "Fusce eu ultrices elit, vel posuere neque. Morbi ac tellus erat. Nunc tincidunt laoreet laoreet.", + "version": 5.21 + }, + { + "name": "Mark Schembri", + "language": "Maltese", + "id": "KU4T500C830697CW", + "bio": "Nam laoreet, nunc non suscipit interdum, justo turpis vestibulum massa, non vulputate ex urna at purus. Morbi ultricies consequat ligula posuere eleifend. Vivamus id faucibus velit, id posuere leo. Sed laoreet posuere sapien, ut feugiat nibh gravida at. Ut maximus, libero nec facilisis fringilla, ex sem sollicitudin leo, non congue tortor ligula in eros.", + "version": 3.17 + }, + { + "name": "Antía Sixirei", + "language": "Galician", + "id": "XOF91ZR7MHV1TXRS", + "bio": "Pellentesque massa sem, scelerisque sit amet odio id, cursus tempor urna. Phasellus massa ligula, hendrerit eget efficitur eget, tincidunt in ligula. Morbi finibus dui sed est fringilla ornare. Duis pellentesque ultrices convallis. Morbi ultricies consequat ligula posuere eleifend.", + "version": 6.44 + }, + { + "name": "Aygul Mutellip", + "language": "Uyghur", + "id": "FTSNV411G5MKLPDT", + "bio": "Duis commodo orci ut dolor iaculis facilisis. Nam semper gravida nunc, sit amet elementum ipsum. Donec pellentesque ultrices mi, non consectetur eros luctus non. Pellentesque massa sem, scelerisque sit amet odio id, cursus tempor urna.", + "version": 9.1 + }, + { + "name": "Awais Shaikh", + "language": "Sindhi", + "id": "OJMWMEEQWMLDU29P", + "bio": "Nunc aliquet sodales nunc a pulvinar. Ut dictum, ligula eget sagittis maximus, tellus mi varius ex, a accumsan justo tellus vitae leo. Donec pellentesque ultrices mi, non consectetur eros luctus non. Nulla finibus massa at viverra facilisis. Nunc tincidunt laoreet laoreet.", + "version": 1.59 + }, + { + "name": "Ambreen Ahmed", + "language": "Sindhi", + "id": "5G646V7E6TJW8X2M", + "bio": "Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Etiam consequat enim lorem, at tincidunt velit ultricies et. Ut maximus, libero nec facilisis fringilla, ex sem sollicitudin leo, non congue tortor ligula in eros.", + "version": 2.35 + }, + { + "name": "Celtia Anes", + "language": "Galician", + "id": "Z53AJY7WUYPLAWC9", + "bio": "Nullam ac sodales dolor, eu facilisis dui. Maecenas non arcu nulla. Ut viverra quis eros eu tincidunt. Curabitur quis commodo quam.", + "version": 8.34 + }, + { + "name": "George Mifsud", + "language": "Maltese", + "id": "N1AS6UFULO6WGTLB", + "bio": "Phasellus tincidunt sollicitudin posuere. Ut accumsan, est vel fringilla varius, purus augue blandit nisl, eu rhoncus ligula purus vel dolor. Donec congue sapien vel euismod interdum. Cras dictum dolor lacinia lectus vehicula rutrum. Phasellus massa ligula, hendrerit eget efficitur eget, tincidunt in ligula.", + "version": 7.47 + }, + { + "name": "Aytürk Qasim", + "language": "Uyghur", + "id": "70RODUVRD95CLOJL", + "bio": "Curabitur ultricies id urna nec ultrices. Aliquam scelerisque pretium tellus, sed accumsan est ultrices id. Duis commodo orci ut dolor iaculis facilisis.", + "version": 1.32 + }, + { + "name": "Dialè Meso", + "language": "Sesotho sa Leboa", + "id": "VBLI24FKF7VV6BWE", + "bio": "Maecenas non arcu nulla. Vivamus id faucibus velit, id posuere leo. Nullam sodales convallis mauris, sit amet lobortis magna auctor sit amet.", + "version": 6.29 + }, + { + "name": "Breixo Galáns", + "language": "Galician", + "id": "4VRLON0GPEZYFCVL", + "bio": "Integer vehicula, arcu sit amet egestas efficitur, orci justo interdum massa, eget ullamcorper risus ligula tristique libero. Morbi ac tellus erat. In id elit malesuada, pulvinar mi eu, imperdiet nulla. Vestibulum pharetra libero et velit gravida euismod. Cras dictum dolor lacinia lectus vehicula rutrum.", + "version": 1.62 + }, + { + "name": "Bieito Lorme", + "language": "Galician", + "id": "5DRDI1QLRGLP29RC", + "bio": "Ut viverra quis eros eu tincidunt. Morbi vitae nisi lacinia, laoreet lorem nec, egestas orci. Curabitur quis commodo quam. Morbi ac tellus erat.", + "version": 4.45 + }, + { + "name": "Azrugul Osman", + "language": "Uyghur", + "id": "5RCTVD3C5QGVAKTQ", + "bio": "Maecenas tempus neque ut porttitor malesuada. Donec lobortis eleifend condimentum.", + "version": 3.18 + }, + { + "name": "Brais Verdiñas", + "language": "Galician", + "id": "BT407GHCC0IHXCD3", + "bio": "Quisque maximus sodales mauris ut elementum. Ut viverra quis eros eu tincidunt. Sed eu libero maximus nunc lacinia lobortis et sit amet nisi. In id elit malesuada, pulvinar mi eu, imperdiet nulla. Curabitur quis commodo quam.", + "version": 5.01 + }, + { + "name": "Ekber Sadir", + "language": "Uyghur", + "id": "AGZDAP8D8OVRRLTY", + "bio": "Quisque efficitur vel sapien ut imperdiet. Phasellus massa ligula, hendrerit eget efficitur eget, tincidunt in ligula. In id elit malesuada, pulvinar mi eu, imperdiet nulla. Sed nec suscipit ligula. Integer vehicula, arcu sit amet egestas efficitur, orci justo interdum massa, eget ullamcorper risus ligula tristique libero.", + "version": 2.04 + }, + { + "name": "Doreen Bartolo", + "language": "Maltese", + "id": "59QSX02O2XOZGRLH", + "bio": "Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Etiam consequat enim lorem, at tincidunt velit ultricies et. Nam semper gravida nunc, sit amet elementum ipsum. Ut viverra quis eros eu tincidunt. Curabitur sed condimentum felis, ut luctus eros.", + "version": 9.31 + }, + { + "name": "Ali Ayaz", + "language": "Sindhi", + "id": "3WNLUZ5LT2F7MYVU", + "bio": "Cras dictum dolor lacinia lectus vehicula rutrum. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Etiam consequat enim lorem, at tincidunt velit ultricies et. Etiam malesuada blandit erat, nec ultricies leo maximus sed.", + "version": 7.8 + }, + { + "name": "Guzelnur Polat", + "language": "Uyghur", + "id": "I6QQHAEGV4CYDXLP", + "bio": "Nam laoreet, nunc non suscipit interdum, justo turpis vestibulum massa, non vulputate ex urna at purus. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Etiam consequat enim lorem, at tincidunt velit ultricies et. Nulla finibus massa at viverra facilisis.", + "version": 8.56 + }, + { + "name": "John Falzon", + "language": "Maltese", + "id": "U3AWXHDTSU0H82SL", + "bio": "Sed nec suscipit ligula. Nullam sodales convallis mauris, sit amet lobortis magna auctor sit amet.", + "version": 9.96 + }, + { + "name": "Erkin Qadir", + "language": "Uyghur", + "id": "GV6TA1AATZYBJ3VR", + "bio": "Phasellus massa ligula, hendrerit eget efficitur eget, tincidunt in ligula. .", + "version": 3.53 + }, + { + "name": "Anita Rajput", + "language": "Sindhi", + "id": "XLLVD0NO2ZFEP4AK", + "bio": "Nam semper gravida nunc, sit amet elementum ipsum. Etiam congue dignissim volutpat.", + "version": 5.16 + }, + { + "name": "Ayesha Khalique", + "language": "Sindhi", + "id": "Q9A5QNGA0OSU8P6Y", + "bio": "Morbi vitae nisi lacinia, laoreet lorem nec, egestas orci. Etiam mauris magna, fermentum vitae aliquet eu, cursus vitae sapien.", + "version": 3.9 + }, + { + "name": "Pheladi Rammala", + "language": "Sesotho sa Leboa", + "id": "EELSIRT2T4Q0M3M4", + "bio": "Quisque efficitur vel sapien ut imperdiet. Morbi ac tellus erat. Aliquam scelerisque pretium tellus, sed accumsan est ultrices id. Ut maximus, libero nec facilisis fringilla, ex sem sollicitudin leo, non congue tortor ligula in eros.", + "version": 1.88 + }, + { + "name": "Antón Caneiro", + "language": "Galician", + "id": "ENTAPNU3MMFUGM1W", + "bio": "Integer vehicula, arcu sit amet egestas efficitur, orci justo interdum massa, eget ullamcorper risus ligula tristique libero. Vestibulum pharetra libero et velit gravida euismod.", + "version": 4.84 + }, + { + "name": "Qahar Abdulla", + "language": "Uyghur", + "id": "OGLODUPEHKEW0K83", + "bio": "Duis commodo orci ut dolor iaculis facilisis. Aliquam sollicitudin ante ligula, eget malesuada nibh efficitur et. Fusce congue aliquam elit ut luctus. Integer vehicula, arcu sit amet egestas efficitur, orci justo interdum massa, eget ullamcorper risus ligula tristique libero. Quisque maximus sodales mauris ut elementum.", + "version": 3.65 + }, + { + "name": "Reyhan Murat", + "language": "Uyghur", + "id": "Y91F4D54794E9ANT", + "bio": "Suspendisse sit amet ullamcorper sem. Curabitur sed condimentum felis, ut luctus eros.", + "version": 2.69 + }, + { + "name": "Tatapi Phogole", + "language": "Sesotho sa Leboa", + "id": "7JA42P5CMCWDVPNR", + "bio": "Duis luctus, lacus eu aliquet convallis, purus elit malesuada ex, vitae rutrum ipsum dui ut magna. Nullam ac sodales dolor, eu facilisis dui. Ut viverra quis eros eu tincidunt.", + "version": 3.78 + }, + { + "name": "Marcos Amboade", + "language": "Galician", + "id": "WPX7H97C7D70CZJR", + "bio": "Nulla finibus massa at viverra facilisis. Pellentesque massa sem, scelerisque sit amet odio id, cursus tempor urna. Curabitur ultricies id urna nec ultrices. Ut maximus, libero nec facilisis fringilla, ex sem sollicitudin leo, non congue tortor ligula in eros. Nunc aliquet sodales nunc a pulvinar.", + "version": 7.37 + }, + { + "name": "Grace Tabone", + "language": "Maltese", + "id": "K4XO8G8DMRNSHF2B", + "bio": "Curabitur sed condimentum felis, ut luctus eros. Duis luctus, lacus eu aliquet convallis, purus elit malesuada ex, vitae rutrum ipsum dui ut magna.", + "version": 5.36 + }, + { + "name": "Shafqat Memon", + "language": "Sindhi", + "id": "D8VFLVRXBXMVBRVI", + "bio": "Aliquam scelerisque pretium tellus, sed accumsan est ultrices id. . Curabitur quis commodo quam. Quisque maximus sodales mauris ut elementum. Quisque mauris ligula, efficitur porttitor sodales ac, lacinia non ex.", + "version": 8.95 + }, + { + "name": "Zeynep Semet", + "language": "Uyghur", + "id": "Z324TZV8S0FGDSAO", + "bio": "Quisque mauris ligula, efficitur porttitor sodales ac, lacinia non ex. Fusce eu ultrices elit, vel posuere neque. Nulla finibus massa at viverra facilisis.", + "version": 1.03 + }, + { + "name": "Meladi Papo", + "language": "Sesotho sa Leboa", + "id": "RJAZQ6BBLRT72CD9", + "bio": "Quisque efficitur vel sapien ut imperdiet. Pellentesque massa sem, scelerisque sit amet odio id, cursus tempor urna. Ut accumsan, est vel fringilla varius, purus augue blandit nisl, eu rhoncus ligula purus vel dolor. Etiam congue dignissim volutpat. Donec congue sapien vel euismod interdum.", + "version": 7.22 + }, + { + "name": "Semet Alim", + "language": "Uyghur", + "id": "HI7L2SR4RCS8C8CS", + "bio": "Duis commodo orci ut dolor iaculis facilisis. Ut viverra quis eros eu tincidunt. Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + "version": 1.01 + }, + { + "name": "Sabela Veloso", + "language": "Galician", + "id": "QA55WXDLC7SRH97X", + "bio": "Duis commodo orci ut dolor iaculis facilisis. Suspendisse potenti. Cras dictum dolor lacinia lectus vehicula rutrum.", + "version": 7.32 + }, + { + "name": "Madule Ledimo", + "language": "Sesotho sa Leboa", + "id": "IHJN2DGJB5O1Y00D", + "bio": "Maecenas non arcu nulla. Aliquam scelerisque pretium tellus, sed accumsan est ultrices id.", + "version": 7.47 + }, + { + "name": "Michelle Caruana", + "language": "Maltese", + "id": "EG1I21R75IV9Q0Q8", + "bio": "Nam tristique feugiat est vitae mollis. Morbi ultricies consequat ligula posuere eleifend. Pellentesque massa sem, scelerisque sit amet odio id, cursus tempor urna.", + "version": 4.95 + }, + { + "name": "Philip Camilleri", + "language": "Maltese", + "id": "FCO0URUHARX5FDFW", + "bio": "Quisque efficitur vel sapien ut imperdiet. Suspendisse sit amet ullamcorper sem. Aliquam scelerisque pretium tellus, sed accumsan est ultrices id. . Aenean finibus in tortor vel aliquet.", + "version": 9.97 + }, + { + "name": "Olalla Romeu", + "language": "Galician", + "id": "WOCMVO6CYPG01ZHY", + "bio": "Maecenas tempus neque ut porttitor malesuada. Sed nec suscipit ligula. Morbi vitae nisi lacinia, laoreet lorem nec, egestas orci. Nullam sodales convallis mauris, sit amet lobortis magna auctor sit amet.", + "version": 1.98 + }, + { + "name": "Gulnur Perhat", + "language": "Uyghur", + "id": "VO3M22TTQMBA2XEM", + "bio": "Nullam ac sodales dolor, eu facilisis dui. Etiam mauris magna, fermentum vitae aliquet eu, cursus vitae sapien. Ut accumsan, est vel fringilla varius, purus augue blandit nisl, eu rhoncus ligula purus vel dolor. Maecenas quis nisi nunc. Duis pellentesque ultrices convallis.", + "version": 5.03 + }, + { + "name": "Hunadi Makgatho", + "language": "Sesotho sa Leboa", + "id": "MRJDOV2MU7PTCDXE", + "bio": "Phasellus tincidunt sollicitudin posuere. Maecenas quis nisi nunc. Duis luctus, lacus eu aliquet convallis, purus elit malesuada ex, vitae rutrum ipsum dui ut magna.", + "version": 8.18 + }, + { + "name": "Charmaine Abela", + "language": "Maltese", + "id": "F6FJP1QDJL944X4Z", + "bio": "Nam rutrum sollicitudin ante tempus consequat. Suspendisse sit amet ullamcorper sem. Morbi ac tellus erat. Sed nec suscipit ligula.", + "version": 6.95 + }, + { + "name": "Tumelò Letamo", + "language": "Sesotho sa Leboa", + "id": "F8BL9NPIKV0OWO1X", + "bio": "Aliquam sollicitudin ante ligula, eget malesuada nibh efficitur et. Etiam congue dignissim volutpat. Sed nec suscipit ligula. Nullam sodales convallis mauris, sit amet lobortis magna auctor sit amet.", + "version": 7.17 + }, + { + "name": "Aneela Mohan", + "language": "Sindhi", + "id": "CRYN52CXKNJU0YXU", + "bio": "Sed nec suscipit ligula. Phasellus massa ligula, hendrerit eget efficitur eget, tincidunt in ligula. Maecenas tempus neque ut porttitor malesuada.", + "version": 4.45 + }, + { + "name": "Koketšo Montjane", + "language": "Sesotho sa Leboa", + "id": "0TTAMXC9TENQCA2O", + "bio": "Curabitur sed condimentum felis, ut luctus eros. Aliquam sollicitudin ante ligula, eget malesuada nibh efficitur et. Etiam mauris magna, fermentum vitae aliquet eu, cursus vitae sapien. Ut maximus, libero nec facilisis fringilla, ex sem sollicitudin leo, non congue tortor ligula in eros.", + "version": 3.61 + }, + { + "name": "Tegra Núnez", + "language": "Galician", + "id": "NC1ZUV6B853BZZCW", + "bio": "Maecenas tempus neque ut porttitor malesuada. Pellentesque massa sem, scelerisque sit amet odio id, cursus tempor urna.", + "version": 6.68 + }, + { + "name": "Dilnur Qeyser", + "language": "Uyghur", + "id": "JVQ8RQ4YRPGLFMR8", + "bio": "Maecenas non arcu nulla. Nulla finibus massa at viverra facilisis. Integer vehicula, arcu sit amet egestas efficitur, orci justo interdum massa, eget ullamcorper risus ligula tristique libero. Ut maximus, libero nec facilisis fringilla, ex sem sollicitudin leo, non congue tortor ligula in eros.", + "version": 7.93 + }, + { + "name": "Tania Agius", + "language": "Maltese", + "id": "WTDGKLDWJLR1BJKR", + "bio": "Etiam congue dignissim volutpat. Pellentesque massa sem, scelerisque sit amet odio id, cursus tempor urna.", + "version": 4.78 + }, + { + "name": "Iago Peirallo", + "language": "Galician", + "id": "D51G7XQTX2SPHR52", + "bio": "Aliquam sollicitudin ante ligula, eget malesuada nibh efficitur et. Donec congue sapien vel euismod interdum. Suspendisse potenti. Quisque maximus sodales mauris ut elementum. Quisque maximus sodales mauris ut elementum.", + "version": 6.3 + }, + { + "name": "Mpho Lamola", + "language": "Sesotho sa Leboa", + "id": "UGL8EOTXYBW1ILLW", + "bio": "In id elit malesuada, pulvinar mi eu, imperdiet nulla. Curabitur ultricies id urna nec ultrices. Maecenas tempus neque ut porttitor malesuada. In sed ultricies lorem. Nullam sodales convallis mauris, sit amet lobortis magna auctor sit amet.", + "version": 2.05 + }, + { + "name": "Josephine Balzan", + "language": "Maltese", + "id": "4OLTG6QD0A2VB432", + "bio": "Maecenas tempus neque ut porttitor malesuada. Sed eu libero maximus nunc lacinia lobortis et sit amet nisi. Maecenas non arcu nulla. Ut accumsan, est vel fringilla varius, purus augue blandit nisl, eu rhoncus ligula purus vel dolor. Curabitur quis commodo quam.", + "version": 7.64 + }, + { + "name": "Thabò Motongwane", + "language": "Sesotho sa Leboa", + "id": "NROE4ZZVGKZGDFNO", + "bio": "Donec pellentesque ultrices mi, non consectetur eros luctus non. Suspendisse potenti. Suspendisse potenti.", + "version": 2.07 + }, + { + "name": "Mmathabò Mojapelo", + "language": "Sesotho sa Leboa", + "id": "VXJDXYPV5L300IFW", + "bio": "Sed laoreet posuere sapien, ut feugiat nibh gravida at. Duis luctus, lacus eu aliquet convallis, purus elit malesuada ex, vitae rutrum ipsum dui ut magna. Nunc tincidunt laoreet laoreet. .", + "version": 9.36 + }, + { + "name": "Kgabo Lerumo", + "language": "Sesotho sa Leboa", + "id": "D63WWKQE2R4TFDIL", + "bio": "Vestibulum pharetra libero et velit gravida euismod. Maecenas tempus neque ut porttitor malesuada. Morbi ultricies consequat ligula posuere eleifend. Quisque efficitur vel sapien ut imperdiet. Nam rutrum sollicitudin ante tempus consequat.", + "version": 6.69 + }, + { + "name": "Lawrence Scicluna", + "language": "Maltese", + "id": "0KDA7XKZNNZWL2SR", + "bio": "Donec pellentesque ultrices mi, non consectetur eros luctus non. In id elit malesuada, pulvinar mi eu, imperdiet nulla. Aliquam sollicitudin ante ligula, eget malesuada nibh efficitur et.", + "version": 6.53 + }, + { + "name": "Iria Xamardo", + "language": "Galician", + "id": "ULUDKBP9PHBGHX2J", + "bio": "Vivamus id faucibus velit, id posuere leo. Sed eu libero maximus nunc lacinia lobortis et sit amet nisi. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam malesuada blandit erat, nec ultricies leo maximus sed. Ut viverra quis eros eu tincidunt.", + "version": 3.42 + }, + { + "name": "Joseph Grech", + "language": "Maltese", + "id": "T4P1164RJBJ8S6XD", + "bio": "Aliquam scelerisque pretium tellus, sed accumsan est ultrices id. Donec lobortis eleifend condimentum.", + "version": 7.68 + }, + { + "name": "Napogadi Selepe", + "language": "Sesotho sa Leboa", + "id": "AJK91MKRFIHAQHHG", + "bio": "Quisque maximus sodales mauris ut elementum. Maecenas quis nisi nunc.", + "version": 4.95 + }, + { + "name": "Lesetja Theko", + "language": "Sesotho sa Leboa", + "id": "AATM20BURO1DHDAE", + "bio": "Phasellus massa ligula, hendrerit eget efficitur eget, tincidunt in ligula. Etiam mauris magna, fermentum vitae aliquet eu, cursus vitae sapien. Nulla finibus massa at viverra facilisis. Morbi finibus dui sed est fringilla ornare.", + "version": 6.81 + }, + { + "name": "Martiño Arxíz", + "language": "Galician", + "id": "CQ56N9MH3WK7H5YQ", + "bio": "Proin tempus eu risus nec mattis. Morbi vitae nisi lacinia, laoreet lorem nec, egestas orci. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Etiam consequat enim lorem, at tincidunt velit ultricies et. Nam rutrum sollicitudin ante tempus consequat. .", + "version": 7.13 + }, + { + "name": "Malehumò Ledwaba", + "language": "Sesotho sa Leboa", + "id": "E4F3HGRTKQKCT1SE", + "bio": "Ut accumsan, est vel fringilla varius, purus augue blandit nisl, eu rhoncus ligula purus vel dolor. Curabitur quis commodo quam. Quisque maximus sodales mauris ut elementum. Curabitur sed condimentum felis, ut luctus eros. Curabitur ultricies id urna nec ultrices.", + "version": 6.52 + }, + { + "name": "Musa Yasin", + "language": "Uyghur", + "id": "1AF8GIQZ1LF8QW0U", + "bio": "Phasellus tincidunt sollicitudin posuere. Phasellus massa ligula, hendrerit eget efficitur eget, tincidunt in ligula. Ut maximus, libero nec facilisis fringilla, ex sem sollicitudin leo, non congue tortor ligula in eros. Ut accumsan, est vel fringilla varius, purus augue blandit nisl, eu rhoncus ligula purus vel dolor.", + "version": 1.54 + }, + { + "name": "Lajwanti Kumari", + "language": "Sindhi", + "id": "INRW3R54RAY7J9IS", + "bio": "In sed ultricies lorem. Sed eu libero maximus nunc lacinia lobortis et sit amet nisi. Quisque mauris ligula, efficitur porttitor sodales ac, lacinia non ex. Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + "version": 9.34 + }, + { + "name": "Maria Sammut", + "language": "Maltese", + "id": "BJRF0BWIHJ0Q12A1", + "bio": "Maecenas tempus neque ut porttitor malesuada. Curabitur ultricies id urna nec ultrices.", + "version": 6.83 + }, + { + "name": "Rita Busuttil", + "language": "Maltese", + "id": "1QLMU6QZ7EYUNNZV", + "bio": "Phasellus tincidunt sollicitudin posuere. Quisque efficitur vel sapien ut imperdiet. Vestibulum pharetra libero et velit gravida euismod. Maecenas tempus neque ut porttitor malesuada.", + "version": 2.09 + }, + { + "name": "Roi Fraguela", + "language": "Galician", + "id": "UAT0M2O42E9M4SFT", + "bio": "Donec congue sapien vel euismod interdum. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce congue aliquam elit ut luctus. Morbi ac tellus erat. Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + "version": 1.08 + }, + { + "name": "Matome Molamo", + "language": "Sesotho sa Leboa", + "id": "7HI0UZZLRB9N5CBI", + "bio": "Vestibulum pharetra libero et velit gravida euismod. Fusce eu ultrices elit, vel posuere neque. Duis pellentesque ultrices convallis.", + "version": 9.55 + }, + { + "name": "Mapula Selokela", + "language": "Sesotho sa Leboa", + "id": "6ZQTOKQI6K82EE9Q", + "bio": "Duis pellentesque ultrices convallis. Nam laoreet, nunc non suscipit interdum, justo turpis vestibulum massa, non vulputate ex urna at purus. Ut viverra quis eros eu tincidunt. Proin tempus eu risus nec mattis.", + "version": 5.27 + }, + { + "name": "Noa Ervello", + "language": "Galician", + "id": "W9FR842CI16V8NU3", + "bio": "Aliquam sollicitudin ante ligula, eget malesuada nibh efficitur et. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Etiam consequat enim lorem, at tincidunt velit ultricies et. Suspendisse sit amet ullamcorper sem. Quisque mauris ligula, efficitur porttitor sodales ac, lacinia non ex.", + "version": 9.33 + }, + { + "name": "Naseem Kakepoto", + "language": "Sindhi", + "id": "6C7HZV4WPV9C9KS6", + "bio": "Morbi ultricies consequat ligula posuere eleifend. Fusce congue aliquam elit ut luctus. . Phasellus massa ligula, hendrerit eget efficitur eget, tincidunt in ligula.", + "version": 1.4 + }, + { + "name": "sayama Amir", + "language": "Sindhi", + "id": "7K4IJT1X7G0EK9WC", + "bio": "Morbi ac tellus erat. Aliquam scelerisque pretium tellus, sed accumsan est ultrices id. Maecenas quis nisi nunc. Etiam congue dignissim volutpat. Sed nec suscipit ligula.", + "version": 9.48 + }, + { + "name": "Mariña Quintá", + "language": "Galician", + "id": "7GXC4OQYXX5JJY9F", + "bio": "Phasellus tincidunt sollicitudin posuere. Morbi ac tellus erat. Nullam ac sodales dolor, eu facilisis dui.", + "version": 8.81 + }, + { + "name": "Memet Tursun", + "language": "Uyghur", + "id": "KSFMV2JK2D553083", + "bio": "Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Etiam consequat enim lorem, at tincidunt velit ultricies et. Morbi finibus dui sed est fringilla ornare. Suspendisse sit amet ullamcorper sem.", + "version": 7.56 + }, + { + "name": "Carmen Vella", + "language": "Maltese", + "id": "WUALBIMS4E8JS4L2", + "bio": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc aliquet sodales nunc a pulvinar. Morbi vitae nisi lacinia, laoreet lorem nec, egestas orci. Vestibulum pharetra libero et velit gravida euismod.", + "version": 4.55 + }, + { + "name": "Sobia Khanam", + "language": "Sindhi", + "id": "YG1ERFWBJ7TIW35D", + "bio": "Phasellus tincidunt sollicitudin posuere. Aliquam scelerisque pretium tellus, sed accumsan est ultrices id. Morbi ultricies consequat ligula posuere eleifend. Curabitur sed condimentum felis, ut luctus eros.", + "version": 4.59 + }, + { + "name": "Raheela Ali", + "language": "Sindhi", + "id": "7JGX9SMLD5DE2IMG", + "bio": "Morbi finibus dui sed est fringilla ornare. Maecenas quis nisi nunc. Maecenas tempus neque ut porttitor malesuada. Curabitur ultricies id urna nec ultrices.", + "version": 4.75 + }, + { + "name": "Rashid Rajput", + "language": "Sindhi", + "id": "UNBGUGDUATATCLS4", + "bio": "Donec congue sapien vel euismod interdum. Maecenas quis nisi nunc.", + "version": 8.51 + }, + { + "name": "Uxía Feal", + "language": "Galician", + "id": "35ZPXUNH1M6W3ZJP", + "bio": "Vestibulum pharetra libero et velit gravida euismod. Vivamus id faucibus velit, id posuere leo.", + "version": 1.31 + }, + { + "name": "Andrew Fenech", + "language": "Maltese", + "id": "VEYKDKL8L0R0C7GQ", + "bio": "In sed ultricies lorem. Etiam mauris magna, fermentum vitae aliquet eu, cursus vitae sapien. Sed laoreet posuere sapien, ut feugiat nibh gravida at.", + "version": 2.5 + }, + { + "name": "Nicholas Micallef", + "language": "Maltese", + "id": "ZYCAI905154LSICR", + "bio": "Nam tristique feugiat est vitae mollis. Curabitur ultricies id urna nec ultrices. Morbi finibus dui sed est fringilla ornare.", + "version": 6.47 + }, + { + "name": "Paul Borg", + "language": "Maltese", + "id": "8AD5MMJ0TD0NJ6H2", + "bio": "Phasellus massa ligula, hendrerit eget efficitur eget, tincidunt in ligula. Etiam mauris magna, fermentum vitae aliquet eu, cursus vitae sapien.", + "version": 3.77 + }, + { + "name": "Sara Saleem", + "language": "Sindhi", + "id": "5LPKMTZI7OPSJRBA", + "bio": "Maecenas tempus neque ut porttitor malesuada. Etiam congue dignissim volutpat. Proin tempus eu risus nec mattis. Morbi vitae nisi lacinia, laoreet lorem nec, egestas orci. Duis commodo orci ut dolor iaculis facilisis.", + "version": 5.31 + }, + { + "name": "Xurxo Golán", + "language": "Galician", + "id": "526ZUSGXEETODHJK", + "bio": "Ut viverra quis eros eu tincidunt. Morbi finibus dui sed est fringilla ornare. Sed laoreet posuere sapien, ut feugiat nibh gravida at. Duis commodo orci ut dolor iaculis facilisis. In sed ultricies lorem.", + "version": 1.75 + }, + { + "name": "Peter Zammit", + "language": "Maltese", + "id": "NNRT5QWNWO2WLS5V", + "bio": "Duis commodo orci ut dolor iaculis facilisis. Maecenas quis nisi nunc.", + "version": 8.23 + }, + { + "name": "Maname Mohlare", + "language": "Sesotho sa Leboa", + "id": "KZJZ9SD0DIWTIBUC", + "bio": "Quisque mauris ligula, efficitur porttitor sodales ac, lacinia non ex. Vestibulum pharetra libero et velit gravida euismod. Ut accumsan, est vel fringilla varius, purus augue blandit nisl, eu rhoncus ligula purus vel dolor. Sed eu libero maximus nunc lacinia lobortis et sit amet nisi.", + "version": 8.95 + }, + { + "name": "Tshepè Mobu", + "language": "Sesotho sa Leboa", + "id": "8CH586LQR7ZCP73P", + "bio": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus massa at viverra facilisis.", + "version": 7.82 + }, + { + "name": "Monica Lohana", + "language": "Sindhi", + "id": "KP1C2WN3DN1R3Y52", + "bio": "Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Etiam consequat enim lorem, at tincidunt velit ultricies et. Aenean finibus in tortor vel aliquet. Nam laoreet, nunc non suscipit interdum, justo turpis vestibulum massa, non vulputate ex urna at purus. Morbi vitae nisi lacinia, laoreet lorem nec, egestas orci.", + "version": 7.95 + }, + { + "name": "Patigul Rahman", + "language": "Uyghur", + "id": "NXMNLB0SOYET1VMN", + "bio": "In sed ultricies lorem. Proin tempus eu risus nec mattis. Nam rutrum sollicitudin ante tempus consequat. Aliquam scelerisque pretium tellus, sed accumsan est ultrices id.", + "version": 2.98 + }, + { + "name": "Joanne Scerri", + "language": "Maltese", + "id": "H8FJ2WKLGGF3K26U", + "bio": "Fusce eu ultrices elit, vel posuere neque. Nulla finibus massa at viverra facilisis. Duis commodo orci ut dolor iaculis facilisis. Nam laoreet, nunc non suscipit interdum, justo turpis vestibulum massa, non vulputate ex urna at purus. Phasellus massa ligula, hendrerit eget efficitur eget, tincidunt in ligula.", + "version": 8.4 + }, + { + "name": "Ratanang Maphutha", + "language": "Sesotho sa Leboa", + "id": "EZXJTQQ2JWPB5DI3", + "bio": "Vivamus id faucibus velit, id posuere leo. Phasellus tincidunt sollicitudin posuere. Duis pellentesque ultrices convallis.", + "version": 9.17 + }, + { + "name": "Kamil Mehmud", + "language": "Uyghur", + "id": "M24A9OMYPSX7FD16", + "bio": "Donec congue sapien vel euismod interdum. Suspendisse potenti. In id elit malesuada, pulvinar mi eu, imperdiet nulla. Nunc aliquet sodales nunc a pulvinar. Ut viverra quis eros eu tincidunt.", + "version": 4.66 + }, + { + "name": "Thobile Mbele", + "language": "isiZulu", + "id": "631M00M8YFFBC5NC", + "bio": "Nunc aliquet sodales nunc a pulvinar. Proin tempus eu risus nec mattis. Proin tempus eu risus nec mattis. Aliquam scelerisque pretium tellus, sed accumsan est ultrices id. Nam laoreet, nunc non suscipit interdum, justo turpis vestibulum massa, non vulputate ex urna at purus.", + "version": 8.96 + }, + { + "name": "Kristján Kristjánsson", + "language": "Icelandic", + "id": "0WT0ZW50DNSTCHKW", + "bio": "Quisque maximus sodales mauris ut elementum. Pellentesque massa sem, scelerisque sit amet odio id, cursus tempor urna. Donec congue sapien vel euismod interdum. Phasellus massa ligula, hendrerit eget efficitur eget, tincidunt in ligula. Donec lobortis eleifend condimentum.", + "version": 8.82 + }, + { + "name": "Stefán Stefánsson", + "language": "Icelandic", + "id": "1UOL8UK8BWAOSYTC", + "bio": "Suspendisse potenti. Duis luctus, lacus eu aliquet convallis, purus elit malesuada ex, vitae rutrum ipsum dui ut magna. Morbi ultricies consequat ligula posuere eleifend.", + "version": 7.87 + }, + { + "name": "Preeti Rajdan", + "language": "Hindi", + "id": "3UN0X88Y4WYH3X8X", + "bio": "In sed ultricies lorem. Vivamus id faucibus velit, id posuere leo. Duis commodo orci ut dolor iaculis facilisis. Nam rutrum sollicitudin ante tempus consequat.", + "version": 9.17 + }, + { + "name": "Sanjay Trivedi", + "language": "Hindi", + "id": "CPHR246457BD01KY", + "bio": "Quisque maximus sodales mauris ut elementum. Morbi ac tellus erat. Maecenas tempus neque ut porttitor malesuada. Cras dictum dolor lacinia lectus vehicula rutrum.", + "version": 8.3 + }, + { + "name": "Smiriti Sisodiya", + "language": "Hindi", + "id": "X3KWIL5KEHTMCKOM", + "bio": "Integer vehicula, arcu sit amet egestas efficitur, orci justo interdum massa, eget ullamcorper risus ligula tristique libero. Morbi finibus dui sed est fringilla ornare.", + "version": 3.27 + }, + { + "name": "Sandeep Benarjee", + "language": "Hindi", + "id": "9TS6CIE3UAIFG2IB", + "bio": "Aliquam sollicitudin ante ligula, eget malesuada nibh efficitur et. Sed nec suscipit ligula. Quisque efficitur vel sapien ut imperdiet. Suspendisse sit amet ullamcorper sem.", + "version": 3.86 + }, + { + "name": "Damir Benic", + "language": "Bosnian", + "id": "QUNL9VBRHUGNOFMJ", + "bio": ". Ut dictum, ligula eget sagittis maximus, tellus mi varius ex, a accumsan justo tellus vitae leo.", + "version": 9.56 + }, + { + "name": "Sigrún Kristjánsdóttir", + "language": "Icelandic", + "id": "BT1Q0NUPKHDVCFLE", + "bio": "Ut dictum, ligula eget sagittis maximus, tellus mi varius ex, a accumsan justo tellus vitae leo. Nulla finibus massa at viverra facilisis.", + "version": 6.78 + }, + { + "name": "Basetsana Thage", + "language": "Setswana", + "id": "R9P3P2IAN7NY2X2Y", + "bio": "Pellentesque massa sem, scelerisque sit amet odio id, cursus tempor urna. Nulla finibus massa at viverra facilisis. Phasellus massa ligula, hendrerit eget efficitur eget, tincidunt in ligula.", + "version": 3.97 + }, + { + "name": "Rajesh Santoshi", + "language": "Hindi", + "id": "OXQTFZHZW8SVE3SY", + "bio": "Donec lobortis eleifend condimentum. Nam rutrum sollicitudin ante tempus consequat. Nullam sodales convallis mauris, sit amet lobortis magna auctor sit amet.", + "version": 8.35 + }, + { + "name": "Margrét Magnúsdóttir", + "language": "Icelandic", + "id": "1P6VZEDGK2XUU97L", + "bio": "Sed eu libero maximus nunc lacinia lobortis et sit amet nisi. Duis pellentesque ultrices convallis. Donec lobortis eleifend condimentum.", + "version": 3.76 + }, + { + "name": "Makhosi Ngiba", + "language": "isiZulu", + "id": "CTM3Y3TZOLC7TPDU", + "bio": "Ut dictum, ligula eget sagittis maximus, tellus mi varius ex, a accumsan justo tellus vitae leo. Suspendisse sit amet ullamcorper sem. Donec lobortis eleifend condimentum. Aenean finibus in tortor vel aliquet. Proin tempus eu risus nec mattis.", + "version": 1.18 + }, + { + "name": "Lorato Bogosi", + "language": "Setswana", + "id": "EEZ0KS5E0RXACAIA", + "bio": "Morbi ultricies consequat ligula posuere eleifend. Nam rutrum sollicitudin ante tempus consequat. Ut dictum, ligula eget sagittis maximus, tellus mi varius ex, a accumsan justo tellus vitae leo. Curabitur ultricies id urna nec ultrices.", + "version": 5.48 + }, + { + "name": "Modisaotsile Bolokwe", + "language": "Setswana", + "id": "DN068KNEOAQ8LM19", + "bio": "Nullam ac sodales dolor, eu facilisis dui. Duis commodo orci ut dolor iaculis facilisis. In id elit malesuada, pulvinar mi eu, imperdiet nulla. Donec congue sapien vel euismod interdum. Sed nec suscipit ligula.", + "version": 4.23 + }, + { + "name": "Mxolisi Mhlongo", + "language": "isiZulu", + "id": "Q2HFB19RPLHIZXKH", + "bio": "Aliquam scelerisque pretium tellus, sed accumsan est ultrices id. Maecenas tempus neque ut porttitor malesuada. . Duis commodo orci ut dolor iaculis facilisis.", + "version": 7.49 + }, + { + "name": "Moni Sisodiya", + "language": "Hindi", + "id": "3CR7CN74GCKXWUQF", + "bio": "Vestibulum pharetra libero et velit gravida euismod. Donec congue sapien vel euismod interdum. Fusce congue aliquam elit ut luctus. Ut viverra quis eros eu tincidunt. Phasellus tincidunt sollicitudin posuere.", + "version": 4.58 + }, + { + "name": "Anna Jónsdóttir", + "language": "Icelandic", + "id": "CKJW1XVW90VWO4Y1", + "bio": "Etiam mauris magna, fermentum vitae aliquet eu, cursus vitae sapien. Donec lobortis eleifend condimentum. Etiam mauris magna, fermentum vitae aliquet eu, cursus vitae sapien.", + "version": 5.78 + }, + { + "name": "Darko Basic", + "language": "Bosnian", + "id": "FWT1CZQOIVRJTXRD", + "bio": "Donec congue sapien vel euismod interdum. Fusce eu ultrices elit, vel posuere neque. Duis luctus, lacus eu aliquet convallis, purus elit malesuada ex, vitae rutrum ipsum dui ut magna.", + "version": 2.27 + }, + { + "name": "Kedibonye Magogwe", + "language": "Setswana", + "id": "PCT0HLRPZLDSSDU1", + "bio": "Aliquam sollicitudin ante ligula, eget malesuada nibh efficitur et. Aliquam sollicitudin ante ligula, eget malesuada nibh efficitur et. Quisque maximus sodales mauris ut elementum.", + "version": 5.57 + }, + { + "name": "Nobuhle Xaba", + "language": "isiZulu", + "id": "5K1K8V1OUUFKQ2UV", + "bio": "Maecenas non arcu nulla. Morbi ac tellus erat.", + "version": 1.18 + }, + { + "name": "Monty Dubey", + "language": "Hindi", + "id": "B7SF955NFGAEBRXU", + "bio": "Maecenas quis nisi nunc. Maecenas tempus neque ut porttitor malesuada. Morbi ultricies consequat ligula posuere eleifend. Ut accumsan, est vel fringilla varius, purus augue blandit nisl, eu rhoncus ligula purus vel dolor.", + "version": 6.69 + }, + { + "name": "Richa Choukse", + "language": "Hindi", + "id": "BADWLBP8CNJNBEC8", + "bio": "Nunc tincidunt laoreet laoreet. Ut dictum, ligula eget sagittis maximus, tellus mi varius ex, a accumsan justo tellus vitae leo. Curabitur quis commodo quam. Morbi vitae nisi lacinia, laoreet lorem nec, egestas orci.", + "version": 7.8 + }, + { + "name": "Dzenan Imamovic", + "language": "Bosnian", + "id": "FVAHD0OY99X9DIRW", + "bio": "Nam tristique feugiat est vitae mollis. Quisque mauris ligula, efficitur porttitor sodales ac, lacinia non ex. Nullam ac sodales dolor, eu facilisis dui. Morbi finibus dui sed est fringilla ornare. Quisque efficitur vel sapien ut imperdiet.", + "version": 1.64 + }, + { + "name": "Amol Bhatnagar", + "language": "Hindi", + "id": "3HPSETKL9VOW2WTL", + "bio": "Vestibulum pharetra libero et velit gravida euismod. Nam semper gravida nunc, sit amet elementum ipsum.", + "version": 3.28 + }, + { + "name": "Ingibjörg Ólafsdóttir", + "language": "Icelandic", + "id": "9BXLMMM1PQOZRHCR", + "bio": "Maecenas non arcu nulla. Sed nec suscipit ligula. Fusce congue aliquam elit ut luctus.", + "version": 9.59 + }, + { + "name": "Shweta Chourasia", + "language": "Hindi", + "id": "9GAO62FXPQMUTTLJ", + "bio": "Duis luctus, lacus eu aliquet convallis, purus elit malesuada ex, vitae rutrum ipsum dui ut magna. Integer vehicula, arcu sit amet egestas efficitur, orci justo interdum massa, eget ullamcorper risus ligula tristique libero. Quisque maximus sodales mauris ut elementum. Sed eu libero maximus nunc lacinia lobortis et sit amet nisi.", + "version": 5.84 + }, + { + "name": "Ayanda Ndimande", + "language": "isiZulu", + "id": "VPK9MQRKX2L847HQ", + "bio": "Duis commodo orci ut dolor iaculis facilisis. Nam laoreet, nunc non suscipit interdum, justo turpis vestibulum massa, non vulputate ex urna at purus.", + "version": 2.89 + }, + { + "name": "Sigurjón Guðmundsson", + "language": "Icelandic", + "id": "IAYT285H2U8JU94F", + "bio": "Sed eu libero maximus nunc lacinia lobortis et sit amet nisi. Ut viverra quis eros eu tincidunt. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam sollicitudin ante ligula, eget malesuada nibh efficitur et. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Etiam consequat enim lorem, at tincidunt velit ultricies et.", + "version": 4.85 + }, + { + "name": "Jóhannes Jóhannsson", + "language": "Icelandic", + "id": "J2RAROEJGKMR72I8", + "bio": "Duis pellentesque ultrices convallis. Nulla finibus massa at viverra facilisis. Ut dictum, ligula eget sagittis maximus, tellus mi varius ex, a accumsan justo tellus vitae leo.", + "version": 4.83 + }, + { + "name": "Neo Dikgaka", + "language": "Setswana", + "id": "OQRF6Y37N20JILOC", + "bio": "Nam tristique feugiat est vitae mollis. Sed nec suscipit ligula. Nam laoreet, nunc non suscipit interdum, justo turpis vestibulum massa, non vulputate ex urna at purus. Duis pellentesque ultrices convallis. Maecenas quis nisi nunc.", + "version": 1.07 + }, + { + "name": "Sanja Jankovic", + "language": "Bosnian", + "id": "HD94EKIPA6WAL05C", + "bio": "Phasellus tincidunt sollicitudin posuere. Nam laoreet, nunc non suscipit interdum, justo turpis vestibulum massa, non vulputate ex urna at purus. In id elit malesuada, pulvinar mi eu, imperdiet nulla. Donec congue sapien vel euismod interdum. Nullam ac sodales dolor, eu facilisis dui.", + "version": 1.06 + }, + { + "name": "Mogorosi Bakwena", + "language": "Setswana", + "id": "FTZM8YDJJUH1OEM7", + "bio": "Vestibulum pharetra libero et velit gravida euismod. Suspendisse sit amet ullamcorper sem.", + "version": 6.03 + }, + { + "name": "Ronak Gupta", + "language": "Hindi", + "id": "ZYPDGK8UDYJPTRKN", + "bio": "Sed laoreet posuere sapien, ut feugiat nibh gravida at. Quisque mauris ligula, efficitur porttitor sodales ac, lacinia non ex. In sed ultricies lorem. Pellentesque massa sem, scelerisque sit amet odio id, cursus tempor urna.", + "version": 7.18 + }, + { + "name": "Ditiro Kgosi", + "language": "Setswana", + "id": "67C5ET66U59WYJ6K", + "bio": "Fusce congue aliquam elit ut luctus. Ut dictum, ligula eget sagittis maximus, tellus mi varius ex, a accumsan justo tellus vitae leo. Cras dictum dolor lacinia lectus vehicula rutrum. Etiam congue dignissim volutpat.", + "version": 4.56 + }, + { + "name": "Jelena Maric", + "language": "Bosnian", + "id": "JTW9DH3B9QGB39JY", + "bio": "Vestibulum pharetra libero et velit gravida euismod. Etiam malesuada blandit erat, nec ultricies leo maximus sed.", + "version": 3.39 + }, + { + "name": "Esha Sastry", + "language": "Hindi", + "id": "4OJULHY03Z6XTRMW", + "bio": "Morbi vitae nisi lacinia, laoreet lorem nec, egestas orci. Nullam ac sodales dolor, eu facilisis dui.", + "version": 5.1 + }, + { + "name": "Chetana Hegde", + "language": "Hindi", + "id": "J9GS1RODDZL325LK", + "bio": "Aliquam sollicitudin ante ligula, eget malesuada nibh efficitur et. Nulla finibus massa at viverra facilisis. Nam tristique feugiat est vitae mollis. Phasellus tincidunt sollicitudin posuere.", + "version": 9.99 + }, + { + "name": "Rahul Shukla", + "language": "Hindi", + "id": "2ANVMAVG6YX2VT6N", + "bio": "Phasellus massa ligula, hendrerit eget efficitur eget, tincidunt in ligula. Phasellus massa ligula, hendrerit eget efficitur eget, tincidunt in ligula.", + "version": 1.72 + }, + { + "name": "Samra Delic", + "language": "Bosnian", + "id": "BXJWNTJ2TDID61PJ", + "bio": "Donec pellentesque ultrices mi, non consectetur eros luctus non. Sed nec suscipit ligula.", + "version": 2.5 + }, + { + "name": "Mohan Pandey", + "language": "Hindi", + "id": "XAHKVLM3I1WSPNIW", + "bio": "Maecenas quis nisi nunc. Ut dictum, ligula eget sagittis maximus, tellus mi varius ex, a accumsan justo tellus vitae leo. Ut maximus, libero nec facilisis fringilla, ex sem sollicitudin leo, non congue tortor ligula in eros. Morbi ac tellus erat.", + "version": 8.1 + }, + { + "name": "Haris Osmanovic", + "language": "Bosnian", + "id": "ZDXF5KESMW9XF2TJ", + "bio": "Nam rutrum sollicitudin ante tempus consequat. Etiam mauris magna, fermentum vitae aliquet eu, cursus vitae sapien.", + "version": 9.41 + }, + { + "name": "Kenosi Kwenaemang", + "language": "Setswana", + "id": "DX2IYTQ9IMY75W08", + "bio": "Sed laoreet posuere sapien, ut feugiat nibh gravida at. Donec lobortis eleifend condimentum.", + "version": 9.01 + }, + { + "name": "Nontobeko Nzimande", + "language": "isiZulu", + "id": "Y9C4HQHTOP74DFZT", + "bio": "Nullam sodales convallis mauris, sit amet lobortis magna auctor sit amet. Morbi vitae nisi lacinia, laoreet lorem nec, egestas orci. Integer vehicula, arcu sit amet egestas efficitur, orci justo interdum massa, eget ullamcorper risus ligula tristique libero. Nam laoreet, nunc non suscipit interdum, justo turpis vestibulum massa, non vulputate ex urna at purus.", + "version": 4.77 + }, + { + "name": "Sanjay Puranik", + "language": "Hindi", + "id": "WF2WP6S0HX8GR8GZ", + "bio": "Ut viverra quis eros eu tincidunt. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Etiam consequat enim lorem, at tincidunt velit ultricies et. Nam semper gravida nunc, sit amet elementum ipsum.", + "version": 3.37 + }, + { + "name": "Sethunya Mpšwe", + "language": "Setswana", + "id": "85MVUXVQ5H5HPA4F", + "bio": "Quisque maximus sodales mauris ut elementum. Duis commodo orci ut dolor iaculis facilisis. Sed eu libero maximus nunc lacinia lobortis et sit amet nisi.", + "version": 1.75 + }, + { + "name": "Dileep Chaturvedi", + "language": "Hindi", + "id": "O95BY1KDMCEYQRFH", + "bio": "Phasellus tincidunt sollicitudin posuere. In id elit malesuada, pulvinar mi eu, imperdiet nulla. Vivamus id faucibus velit, id posuere leo. Nullam ac sodales dolor, eu facilisis dui. Ut dictum, ligula eget sagittis maximus, tellus mi varius ex, a accumsan justo tellus vitae leo.", + "version": 4.94 + }, + { + "name": "Adnan Spahic", + "language": "Bosnian", + "id": "97IIDMHAJMBPI4ON", + "bio": "Duis commodo orci ut dolor iaculis facilisis. Vivamus id faucibus velit, id posuere leo.", + "version": 9.1 + }, + { + "name": "Madhur Jain", + "language": "Hindi", + "id": "FM300CZ0VU9LTNTE", + "bio": "Fusce eu ultrices elit, vel posuere neque. Donec congue sapien vel euismod interdum. Vivamus id faucibus velit, id posuere leo. Integer vehicula, arcu sit amet egestas efficitur, orci justo interdum massa, eget ullamcorper risus ligula tristique libero. Aliquam sollicitudin ante ligula, eget malesuada nibh efficitur et.", + "version": 4.99 + }, + { + "name": "Nayan Mittal", + "language": "Hindi", + "id": "S879KFFIHDNK8GSE", + "bio": "Suspendisse sit amet ullamcorper sem. In id elit malesuada, pulvinar mi eu, imperdiet nulla. Duis commodo orci ut dolor iaculis facilisis.", + "version": 3.99 + }, + { + "name": "Kabelo Morwe", + "language": "Setswana", + "id": "JJDPB2983QRVATD3", + "bio": "Nullam ac sodales dolor, eu facilisis dui. Phasellus massa ligula, hendrerit eget efficitur eget, tincidunt in ligula. . Integer vehicula, arcu sit amet egestas efficitur, orci justo interdum massa, eget ullamcorper risus ligula tristique libero. Curabitur ultricies id urna nec ultrices.", + "version": 8.86 + }, + { + "name": "Einar Einarsson", + "language": "Icelandic", + "id": "ZWMFEUEBNYTW2WPB", + "bio": "Etiam mauris magna, fermentum vitae aliquet eu, cursus vitae sapien. Duis pellentesque ultrices convallis. Nullam sodales convallis mauris, sit amet lobortis magna auctor sit amet. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Etiam consequat enim lorem, at tincidunt velit ultricies et. Donec congue sapien vel euismod interdum.", + "version": 9.05 + }, + { + "name": "Luka Lovren", + "language": "Bosnian", + "id": "9S4SGEQWBKMRISYZ", + "bio": "Maecenas tempus neque ut porttitor malesuada. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur quis commodo quam. Nam rutrum sollicitudin ante tempus consequat.", + "version": 5.22 + }, + { + "name": "Sigríður Einarsdóttir", + "language": "Icelandic", + "id": "4IJVD6OE3C7IX3ZG", + "bio": "Aenean finibus in tortor vel aliquet. Nam tristique feugiat est vitae mollis.", + "version": 6.63 + }, + { + "name": "Sonu Jain", + "language": "Hindi", + "id": "0OIB5SU9JB2PBJDV", + "bio": "Phasellus massa ligula, hendrerit eget efficitur eget, tincidunt in ligula. Curabitur ultricies id urna nec ultrices.", + "version": 9.66 + }, + { + "name": "Boitumelo Ngwako", + "language": "Setswana", + "id": "INZITSS95L9V52JE", + "bio": "Ut maximus, libero nec facilisis fringilla, ex sem sollicitudin leo, non congue tortor ligula in eros. Aliquam sollicitudin ante ligula, eget malesuada nibh efficitur et. Nam tristique feugiat est vitae mollis. Quisque mauris ligula, efficitur porttitor sodales ac, lacinia non ex. In sed ultricies lorem.", + "version": 9.07 + }, + { + "name": "Shilpa Bhatia", + "language": "Hindi", + "id": "SU0W3T6TF8G3JY5M", + "bio": "Morbi ultricies consequat ligula posuere eleifend. Donec pellentesque ultrices mi, non consectetur eros luctus non. Quisque efficitur vel sapien ut imperdiet. Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + "version": 4.43 + }, + { + "name": "Modise Tau", + "language": "Setswana", + "id": "U6SF3N4JXJEQSC1P", + "bio": "Vivamus id faucibus velit, id posuere leo. Phasellus massa ligula, hendrerit eget efficitur eget, tincidunt in ligula. Fusce eu ultrices elit, vel posuere neque. Nunc tincidunt laoreet laoreet.", + "version": 6.23 + }, + { + "name": "Reena Shrivastav", + "language": "Hindi", + "id": "Y57EEOVURYX1OA1P", + "bio": "Donec lobortis eleifend condimentum. Curabitur ultricies id urna nec ultrices. Maecenas non arcu nulla.", + "version": 3.07 + }, + { + "name": "Thabani Ngubani", + "language": "isiZulu", + "id": "LR7FI8WEE3SLTW02", + "bio": "Cras dictum dolor lacinia lectus vehicula rutrum. Nulla finibus massa at viverra facilisis.", + "version": 5.99 + }, + { + "name": "Gunnar Gunnarsson", + "language": "Icelandic", + "id": "UVI6EKJNMC3VE3WU", + "bio": "In sed ultricies lorem. Donec congue sapien vel euismod interdum. Duis commodo orci ut dolor iaculis facilisis. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Etiam consequat enim lorem, at tincidunt velit ultricies et.", + "version": 8.7 + }, + { + "name": "Lejla Selimagic", + "language": "Bosnian", + "id": "ESBBT644VZ64SSEN", + "bio": "Vivamus id faucibus velit, id posuere leo. Etiam congue dignissim volutpat. Donec lobortis eleifend condimentum. Fusce eu ultrices elit, vel posuere neque.", + "version": 5.59 + }, + { + "name": "Kgosietsile Bogatsu", + "language": "Setswana", + "id": "0B8IOVL2NSVJVV6T", + "bio": "Curabitur quis commodo quam. In id elit malesuada, pulvinar mi eu, imperdiet nulla. Nullam ac sodales dolor, eu facilisis dui. Duis commodo orci ut dolor iaculis facilisis.", + "version": 6.78 + }, + { + "name": "Sushant Bhargav", + "language": "Hindi", + "id": "PRWA7HE1GJ7OCYQM", + "bio": "Proin tempus eu risus nec mattis. Maecenas tempus neque ut porttitor malesuada. Quisque efficitur vel sapien ut imperdiet. Quisque efficitur vel sapien ut imperdiet.", + "version": 5.36 + }, + { + "name": "Monika Nayak", + "language": "Hindi", + "id": "RO0ZCWFTY6MJ66AZ", + "bio": "Sed eu libero maximus nunc lacinia lobortis et sit amet nisi. Quisque efficitur vel sapien ut imperdiet. Nam rutrum sollicitudin ante tempus consequat. Curabitur ultricies id urna nec ultrices. Phasellus massa ligula, hendrerit eget efficitur eget, tincidunt in ligula.", + "version": 7.58 + }, + { + "name": "Guðrún Guðmundsdóttir", + "language": "Icelandic", + "id": "R1TRJT5TWANYO88D", + "bio": "Maecenas non arcu nulla. In sed ultricies lorem.", + "version": 4.65 + }, + { + "name": "Shakti Menon", + "language": "Hindi", + "id": "J1NSHQXRWA7CY0AZ", + "bio": "Vivamus id faucibus velit, id posuere leo. Etiam malesuada blandit erat, nec ultricies leo maximus sed. Nam semper gravida nunc, sit amet elementum ipsum.", + "version": 5.16 + }, + { + "name": "Ndumiso Hlatshwayo", + "language": "isiZulu", + "id": "533XA8H67VO8CSGQ", + "bio": "Quisque efficitur vel sapien ut imperdiet. Nam semper gravida nunc, sit amet elementum ipsum. Donec pellentesque ultrices mi, non consectetur eros luctus non. Vestibulum pharetra libero et velit gravida euismod. Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + "version": 5.24 + }, + { + "name": "Lucky Shastry", + "language": "Hindi", + "id": "3OBF3U08WI1QF63N", + "bio": "Morbi ultricies consequat ligula posuere eleifend. Suspendisse sit amet ullamcorper sem.", + "version": 7.86 + }, + { + "name": "Pule Matlhaku", + "language": "Setswana", + "id": "UPATVXM44DAFUDI7", + "bio": "Maecenas tempus neque ut porttitor malesuada. Vivamus id faucibus velit, id posuere leo. Morbi finibus dui sed est fringilla ornare.", + "version": 4.12 + }, + { + "name": "Raju Rathore", + "language": "Hindi", + "id": "QQMNYP788DEFG4IS", + "bio": "Nam rutrum sollicitudin ante tempus consequat. Pellentesque massa sem, scelerisque sit amet odio id, cursus tempor urna. Integer vehicula, arcu sit amet egestas efficitur, orci justo interdum massa, eget ullamcorper risus ligula tristique libero.", + "version": 9.86 + }, + { + "name": "Xolani Ngcobo", + "language": "isiZulu", + "id": "SXWZ4IYT5VZA6WEE", + "bio": "Ut dictum, ligula eget sagittis maximus, tellus mi varius ex, a accumsan justo tellus vitae leo. Fusce eu ultrices elit, vel posuere neque. Curabitur quis commodo quam.", + "version": 4.77 + }, + { + "name": "Meenakshi Benjaree", + "language": "Hindi", + "id": "933PPBA946YX1K4X", + "bio": "Maecenas tempus neque ut porttitor malesuada. Duis pellentesque ultrices convallis.", + "version": 7.9 + }, + { + "name": "Ólafur Magnússon", + "language": "Icelandic", + "id": "NWY9HV455M3W8QKY", + "bio": "Morbi ultricies consequat ligula posuere eleifend. Duis pellentesque ultrices convallis. Vestibulum pharetra libero et velit gravida euismod. Ut dictum, ligula eget sagittis maximus, tellus mi varius ex, a accumsan justo tellus vitae leo. Aliquam sollicitudin ante ligula, eget malesuada nibh efficitur et.", + "version": 2.09 + }, + { + "name": "Samir Simic", + "language": "Bosnian", + "id": "6H2IO7A62ZVUXGKZ", + "bio": "Etiam malesuada blandit erat, nec ultricies leo maximus sed. Quisque maximus sodales mauris ut elementum. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + "version": 6.93 + }, + { + "name": "Swarnika Soni", + "language": "Hindi", + "id": "4GJF8C6P1Y5RFPMC", + "bio": "Ut maximus, libero nec facilisis fringilla, ex sem sollicitudin leo, non congue tortor ligula in eros. Nunc tincidunt laoreet laoreet.", + "version": 4.82 + }, + { + "name": "Lavanya Mittal", + "language": "Hindi", + "id": "4Z09CO5IJH7CEUD2", + "bio": "Suspendisse sit amet ullamcorper sem. Ut dictum, ligula eget sagittis maximus, tellus mi varius ex, a accumsan justo tellus vitae leo.", + "version": 1.08 + }, + { + "name": "Bontle Mokgatle", + "language": "Setswana", + "id": "4Y497GAOTAFUJDIC", + "bio": "Maecenas non arcu nulla. Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + "version": 1.92 + }, + { + "name": "Prashant Chourey", + "language": "Hindi", + "id": "J4NMMNAALGOIZY8V", + "bio": "Etiam malesuada blandit erat, nec ultricies leo maximus sed. Suspendisse potenti. Phasellus massa ligula, hendrerit eget efficitur eget, tincidunt in ligula. Ut viverra quis eros eu tincidunt.", + "version": 8.59 + }, + { + "name": "Prakash Malviya", + "language": "Hindi", + "id": "P442H9CEHIU6HAFV", + "bio": "Proin tempus eu risus nec mattis. Integer vehicula, arcu sit amet egestas efficitur, orci justo interdum massa, eget ullamcorper risus ligula tristique libero. Vivamus id faucibus velit, id posuere leo. In id elit malesuada, pulvinar mi eu, imperdiet nulla. Donec pellentesque ultrices mi, non consectetur eros luctus non.", + "version": 8.21 + }, + { + "name": "Ivana Kalic", + "language": "Bosnian", + "id": "31VIE8WWDJWKE5YL", + "bio": "Quisque efficitur vel sapien ut imperdiet. Duis luctus, lacus eu aliquet convallis, purus elit malesuada ex, vitae rutrum ipsum dui ut magna.", + "version": 6.99 + }, + { + "name": "Ajeet Vasav", + "language": "Hindi", + "id": "ODNPTWVSRBPII0BH", + "bio": "Aenean finibus in tortor vel aliquet. Integer vehicula, arcu sit amet egestas efficitur, orci justo interdum massa, eget ullamcorper risus ligula tristique libero. Morbi finibus dui sed est fringilla ornare. Morbi finibus dui sed est fringilla ornare. Ut maximus, libero nec facilisis fringilla, ex sem sollicitudin leo, non congue tortor ligula in eros.", + "version": 3.6 + }, + { + "name": "Jóhanna Jóhannsdóttir", + "language": "Icelandic", + "id": "ZI21GM8B08FVLMF0", + "bio": "In sed ultricies lorem. Etiam malesuada blandit erat, nec ultricies leo maximus sed.", + "version": 4.93 + }, + { + "name": "Seema Thapar", + "language": "Hindi", + "id": "IZSO10C5ZHVYQ5O2", + "bio": "Duis commodo orci ut dolor iaculis facilisis. Etiam mauris magna, fermentum vitae aliquet eu, cursus vitae sapien. Maecenas tempus neque ut porttitor malesuada. Phasellus massa ligula, hendrerit eget efficitur eget, tincidunt in ligula. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Etiam consequat enim lorem, at tincidunt velit ultricies et.", + "version": 1.79 + }, + { + "name": "María Stefánsdóttir", + "language": "Icelandic", + "id": "KWH2RVHSB25MYGL9", + "bio": "In id elit malesuada, pulvinar mi eu, imperdiet nulla. Sed eu libero maximus nunc lacinia lobortis et sit amet nisi. Ut viverra quis eros eu tincidunt. Nam rutrum sollicitudin ante tempus consequat.", + "version": 5.21 + }, + { + "name": "Denis Terzic", + "language": "Bosnian", + "id": "1WQO4VGBS2U7DOSL", + "bio": "Ut accumsan, est vel fringilla varius, purus augue blandit nisl, eu rhoncus ligula purus vel dolor. Curabitur quis commodo quam. Curabitur ultricies id urna nec ultrices. Nam rutrum sollicitudin ante tempus consequat. Morbi finibus dui sed est fringilla ornare.", + "version": 6.32 + }, + { + "name": "Ana Livic", + "language": "Bosnian", + "id": "8JYVK7SM07YQOVQ3", + "bio": "Nam tristique feugiat est vitae mollis. Aliquam sollicitudin ante ligula, eget malesuada nibh efficitur et. Pellentesque massa sem, scelerisque sit amet odio id, cursus tempor urna. Proin tempus eu risus nec mattis. Sed eu libero maximus nunc lacinia lobortis et sit amet nisi.", + "version": 5.93 + }, + { + "name": "Bukhosi Bhengu", + "language": "isiZulu", + "id": "AFYXL0UNGMU0B1H2", + "bio": "Curabitur quis commodo quam. Curabitur sed condimentum felis, ut luctus eros. Aliquam scelerisque pretium tellus, sed accumsan est ultrices id. Aliquam scelerisque pretium tellus, sed accumsan est ultrices id. Sed nec suscipit ligula.", + "version": 9.37 + }, + { + "name": "Siyabonga Sithole", + "language": "isiZulu", + "id": "NJDX77JXV51CNGF5", + "bio": "Quisque mauris ligula, efficitur porttitor sodales ac, lacinia non ex. Sed laoreet posuere sapien, ut feugiat nibh gravida at.", + "version": 8.22 + }, + { + "name": "Meena Dubey", + "language": "Hindi", + "id": "GCJGYXSPDEFF9BTN", + "bio": "Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Etiam consequat enim lorem, at tincidunt velit ultricies et. Donec lobortis eleifend condimentum. Morbi ac tellus erat. Maecenas quis nisi nunc.", + "version": 2.95 + }, + { + "name": "Chandrika Gupta", + "language": "Hindi", + "id": "7KFJHS86WKTL6Q12", + "bio": "Aliquam sollicitudin ante ligula, eget malesuada nibh efficitur et. Suspendisse sit amet ullamcorper sem. Etiam mauris magna, fermentum vitae aliquet eu, cursus vitae sapien.", + "version": 5.35 + }, + { + "name": "Akhilesh Khare", + "language": "Hindi", + "id": "ATINHMT01VNMMDCP", + "bio": "Donec congue sapien vel euismod interdum. Suspendisse potenti. Nullam ac sodales dolor, eu facilisis dui. Nam tristique feugiat est vitae mollis. Curabitur ultricies id urna nec ultrices.", + "version": 3.68 + }, + { + "name": "Motsumi Basiang", + "language": "Setswana", + "id": "MUELSFQENUOHGBZ3", + "bio": "Cras dictum dolor lacinia lectus vehicula rutrum. Ut maximus, libero nec facilisis fringilla, ex sem sollicitudin leo, non congue tortor ligula in eros. Donec congue sapien vel euismod interdum.", + "version": 5.23 + }, + { + "name": "Neha Benjaree", + "language": "Hindi", + "id": "5VTSZUD0SA9JVL40", + "bio": "Morbi ultricies consequat ligula posuere eleifend. Nulla finibus massa at viverra facilisis. Nam tristique feugiat est vitae mollis.", + "version": 5.73 + }, + { + "name": "Kristín Sigurðardóttir", + "language": "Icelandic", + "id": "ZP5TBBYX6RI2UJ31", + "bio": "Cras dictum dolor lacinia lectus vehicula rutrum. Cras dictum dolor lacinia lectus vehicula rutrum. Duis luctus, lacus eu aliquet convallis, purus elit malesuada ex, vitae rutrum ipsum dui ut magna. Fusce congue aliquam elit ut luctus. Duis commodo orci ut dolor iaculis facilisis.", + "version": 2.8 + }, + { + "name": "Rohini Vasav", + "language": "Hindi", + "id": "UEFML43TCGS04KWM", + "bio": "Ut accumsan, est vel fringilla varius, purus augue blandit nisl, eu rhoncus ligula purus vel dolor. Ut maximus, libero nec facilisis fringilla, ex sem sollicitudin leo, non congue tortor ligula in eros. Nam rutrum sollicitudin ante tempus consequat. Aliquam scelerisque pretium tellus, sed accumsan est ultrices id. Suspendisse sit amet ullamcorper sem.", + "version": 9.3 + }, + { + "name": "Sunil Kapoor", + "language": "Hindi", + "id": "VY2A0APGVHK5NAW2", + "bio": "Proin tempus eu risus nec mattis. Ut dictum, ligula eget sagittis maximus, tellus mi varius ex, a accumsan justo tellus vitae leo. In id elit malesuada, pulvinar mi eu, imperdiet nulla.", + "version": 8.04 + }, + { + "name": "Zamokuhle Zulu", + "language": "isiZulu", + "id": "XU7BX2F8M5PVZ1EF", + "bio": "Etiam congue dignissim volutpat. Phasellus tincidunt sollicitudin posuere. Phasellus tincidunt sollicitudin posuere. Nam tristique feugiat est vitae mollis.", + "version": 8.39 + }, + { + "name": "Bhupesh Menon", + "language": "Hindi", + "id": "0CEPNRDV98KT3ORP", + "bio": "Maecenas tempus neque ut porttitor malesuada. Phasellus massa ligula, hendrerit eget efficitur eget, tincidunt in ligula. Quisque mauris ligula, efficitur porttitor sodales ac, lacinia non ex. Maecenas quis nisi nunc.", + "version": 2.69 + } +] \ No newline at end of file diff --git a/avaje-jex/src/test/resources/public/index.html b/avaje-jex/src/test/resources/public/index.html new file mode 100644 index 00000000..41abec16 --- /dev/null +++ b/avaje-jex/src/test/resources/public/index.html @@ -0,0 +1,9 @@ + + + + Index.html + + +

This ia my first page.

+ + \ No newline at end of file diff --git a/avaje-jex/src/test/resources/public/sus.txt b/avaje-jex/src/test/resources/public/sus.txt new file mode 100644 index 00000000..b20ae04e --- /dev/null +++ b/avaje-jex/src/test/resources/public/sus.txt @@ -0,0 +1 @@ +ඞ \ No newline at end of file diff --git a/examples/example-grizzly/pom.xml b/examples/example-grizzly/pom.xml deleted file mode 100644 index bce25c0d..00000000 --- a/examples/example-grizzly/pom.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - 4.0.0 - - org.avaje - java11-oss - 3.9 - - - - org.example - example-grizzly - 1 - - - 11 - - - - - - io.avaje - avaje-jex-grizzly - 2.5-SNAPSHOT - - - - com.fasterxml.jackson.core - jackson-databind - 2.14.0 - - - - io.avaje - avaje-http-client - 1.21 - - - - org.avaje - logback - 1.0 - - - - diff --git a/examples/example-grizzly/src/main/java/org/example/GMain.java b/examples/example-grizzly/src/main/java/org/example/GMain.java deleted file mode 100644 index 5cedc0f5..00000000 --- a/examples/example-grizzly/src/main/java/org/example/GMain.java +++ /dev/null @@ -1,55 +0,0 @@ -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()) - ) - .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 deleted file mode 100644 index de0c8524..00000000 --- a/examples/example-grizzly/src/main/java/org/example/HelloDto.java +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 28ad4ca4..00000000 --- a/examples/example-grizzly/src/test/java/org/example/ClientMain.java +++ /dev/null @@ -1,33 +0,0 @@ -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.builder() - .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()); - - HelloDto bean = ctx.request() - .GET() - .bean(HelloDto.class); - - System.out.println("bean " + bean); - } -} diff --git a/examples/example-grizzly/src/test/resources/logback-test.xml b/examples/example-grizzly/src/test/resources/logback-test.xml deleted file mode 100644 index 2c7f5454..00000000 --- a/examples/example-grizzly/src/test/resources/logback-test.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - TRACE - - - %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - - - - - - - - diff --git a/examples/example-http-generation/README.md b/examples/example-http-generation/README.md new file mode 100644 index 00000000..895c8db7 --- /dev/null +++ b/examples/example-http-generation/README.md @@ -0,0 +1,31 @@ +# example-http-generation + +- Minimal example of Jex web routing with underlying JDK Http server +- Uses avaje-jsonb for json marshalling (via source code generation) +- Uses avaje-http to create JAX-RS style routing (via source code generation) +- Uses avaje-inject for DI (via source code generation) +- Uses avaje-config for configuration + +## Graalvm native-image + +To Build +```sh +mvn clean package -Pnative -DskipTests +``` + +To Run: +```sh +./target/mytest + +# produces +# Oct 21, 2022 1:39:36 PM io.avaje.jex.core.JdkServerStart start +# INFO: started server on port 7003 version 2.5-SNAPSHOT +``` + +To Play: +```sh +curl localhost:7003/foo/44 + +# produces +# {"id":44,"name":"Rob"} +``` diff --git a/examples/example-http-generation/pom.xml b/examples/example-http-generation/pom.xml new file mode 100644 index 00000000..1a3aeacc --- /dev/null +++ b/examples/example-http-generation/pom.xml @@ -0,0 +1,84 @@ + + + + io.avaje + examples + 3.0 + + 4.0.0 + + example-http-generation + + + full + 21 + + + + + + io.avaje + avaje-config + + + + + io.avaje + avaje-inject + + + io.avaje + avaje-http-api + + + io.avaje + avaje-http-client + + + + io.avaje + avaje-jex + + + io.avaje + avaje-jsonb + + + + io.avaje + avaje-inject-test + + + + io.avaje + avaje-jex-test + test + + + + + + io.avaje + avaje-inject-generator + provided + + + io.avaje + avaje-jsonb-generator + provided + + + io.avaje + avaje-http-client-generator + provided + + + io.avaje + avaje-http-jex-generator + provided + + + + diff --git a/examples/example-katie/src/main/java/main/Main.java b/examples/example-http-generation/src/main/java/main/Main.java similarity index 62% rename from examples/example-katie/src/main/java/main/Main.java rename to examples/example-http-generation/src/main/java/main/Main.java index 59953da8..c0d52f5f 100644 --- a/examples/example-katie/src/main/java/main/Main.java +++ b/examples/example-http-generation/src/main/java/main/Main.java @@ -1,12 +1,12 @@ package main; -import io.avaje.jex.BootJex; +import io.avaje.jex.AvajeJex; public class Main { public static void main(String[] args) { - BootJex.start(); + AvajeJex.start(); } } diff --git a/examples/example-http-generation/src/main/java/module-info.java b/examples/example-http-generation/src/main/java/module-info.java new file mode 100644 index 00000000..c7884041 --- /dev/null +++ b/examples/example-http-generation/src/main/java/module-info.java @@ -0,0 +1,15 @@ +open module example.http.generation { + + requires io.avaje.config; + requires io.avaje.jsonb; + requires io.avaje.http.api; + requires io.avaje.jex; + requires io.avaje.inject; + + requires io.avaje.http.client; + + provides io.avaje.inject.spi.InjectExtension with org.foo.myapp.MyappModule; + provides io.avaje.jsonb.spi.JsonbExtension with org.foo.myapp.web.jsonb.GeneratedJsonComponent; + provides io.avaje.http.client.HttpClient.GeneratedComponent with org.foo.myapp.web.httpclient.GeneratedHttpComponent; + +} diff --git a/examples/example-katie/src/main/java/org/foo/myapp/config/JexConfiguration.java b/examples/example-http-generation/src/main/java/org/foo/myapp/config/JexConfiguration.java similarity index 74% rename from examples/example-katie/src/main/java/org/foo/myapp/config/JexConfiguration.java rename to examples/example-http-generation/src/main/java/org/foo/myapp/config/JexConfiguration.java index 8c57ffb9..1c99a12b 100644 --- a/examples/example-katie/src/main/java/org/foo/myapp/config/JexConfiguration.java +++ b/examples/example-http-generation/src/main/java/org/foo/myapp/config/JexConfiguration.java @@ -3,15 +3,14 @@ import io.avaje.inject.Bean; import io.avaje.inject.Factory; import io.avaje.inject.PostConstruct; -import io.avaje.jex.Jex; +import io.avaje.jex.spi.JexPlugin; @Factory public class JexConfiguration { @Bean - Jex buildJex() { - return Jex.create() - .port(8002); + JexPlugin buildJex() { + return j -> j.port(8002); } @PostConstruct diff --git a/examples/example-katie/src/main/java/org/foo/myapp/service/HelloService.java b/examples/example-http-generation/src/main/java/org/foo/myapp/service/HelloService.java similarity index 100% rename from examples/example-katie/src/main/java/org/foo/myapp/service/HelloService.java rename to examples/example-http-generation/src/main/java/org/foo/myapp/service/HelloService.java diff --git a/examples/example-katie/src/main/java/org/foo/myapp/web/HelloApi.java b/examples/example-http-generation/src/main/java/org/foo/myapp/web/HelloApi.java similarity index 84% rename from examples/example-katie/src/main/java/org/foo/myapp/web/HelloApi.java rename to examples/example-http-generation/src/main/java/org/foo/myapp/web/HelloApi.java index 4af1d330..a0e4480d 100644 --- a/examples/example-katie/src/main/java/org/foo/myapp/web/HelloApi.java +++ b/examples/example-http-generation/src/main/java/org/foo/myapp/web/HelloApi.java @@ -2,11 +2,9 @@ import io.avaje.http.api.Client; import io.avaje.http.api.Get; -import io.avaje.http.api.Path; import io.avaje.http.api.Produces; -@Client -@Path("/hello") +@Client("/hello") public interface HelloApi { @Produces("text/plain") diff --git a/examples/example-katie/src/main/java/org/foo/myapp/web/HelloController.java b/examples/example-http-generation/src/main/java/org/foo/myapp/web/HelloController.java similarity index 100% rename from examples/example-katie/src/main/java/org/foo/myapp/web/HelloController.java rename to examples/example-http-generation/src/main/java/org/foo/myapp/web/HelloController.java diff --git a/examples/example-katie/src/main/java/org/foo/myapp/web/HiApi.java b/examples/example-http-generation/src/main/java/org/foo/myapp/web/HiApi.java similarity index 71% rename from examples/example-katie/src/main/java/org/foo/myapp/web/HiApi.java rename to examples/example-http-generation/src/main/java/org/foo/myapp/web/HiApi.java index 6fb87418..47067954 100644 --- a/examples/example-katie/src/main/java/org/foo/myapp/web/HiApi.java +++ b/examples/example-http-generation/src/main/java/org/foo/myapp/web/HiApi.java @@ -14,9 +14,5 @@ public interface HiApi { Hello hello(); @Json - class Hello { - public int id2; - public String msg; - public String other; - } + record Hello(int id2, String msg, String other) {} } diff --git a/examples/example-katie/src/main/java/org/foo/myapp/web/HiController.java b/examples/example-http-generation/src/main/java/org/foo/myapp/web/HiController.java similarity index 65% rename from examples/example-katie/src/main/java/org/foo/myapp/web/HiController.java rename to examples/example-http-generation/src/main/java/org/foo/myapp/web/HiController.java index 85a9a0db..1cde58c9 100644 --- a/examples/example-katie/src/main/java/org/foo/myapp/web/HiController.java +++ b/examples/example-http-generation/src/main/java/org/foo/myapp/web/HiController.java @@ -12,10 +12,6 @@ public String hi() { @Override public Hello hello() { - Hello hello = new Hello(); - hello.id2 = 42; - hello.msg = "hello"; - hello.other = "other"; - return hello; + return new Hello(42, "hello", "other"); } } diff --git a/examples/example-katie/src/main/resources/application.properties b/examples/example-http-generation/src/main/resources/application.properties similarity index 100% rename from examples/example-katie/src/main/resources/application.properties rename to examples/example-http-generation/src/main/resources/application.properties diff --git a/examples/example-katie/src/test/java/org/foo/myapp/web/HelloClientInterfaceInMainTest.java b/examples/example-http-generation/src/test/java/org/foo/myapp/web/HelloClientInterfaceInMainTest.java similarity index 89% rename from examples/example-katie/src/test/java/org/foo/myapp/web/HelloClientInterfaceInMainTest.java rename to examples/example-http-generation/src/test/java/org/foo/myapp/web/HelloClientInterfaceInMainTest.java index 929e622a..1f23225d 100644 --- a/examples/example-katie/src/test/java/org/foo/myapp/web/HelloClientInterfaceInMainTest.java +++ b/examples/example-http-generation/src/test/java/org/foo/myapp/web/HelloClientInterfaceInMainTest.java @@ -1,14 +1,17 @@ package org.foo.myapp.web; -import io.avaje.inject.test.InjectTest; -import jakarta.inject.Inject; +import static org.assertj.core.api.Assertions.assertThat; + import org.junit.jupiter.api.Test; -import static org.assertj.core.api.Assertions.assertThat; +import io.avaje.http.api.Client; +import io.avaje.inject.test.InjectTest; +import jakarta.inject.Inject; /** * A `@Client` interface lives in src/main - not usually expected. */ +@Client.Import(HelloApi.class) @InjectTest class HelloClientInterfaceInMainTest { diff --git a/examples/example-katie/src/test/java/org/foo/myapp/web/HelloClientInterfaceInTestTest.java b/examples/example-http-generation/src/test/java/org/foo/myapp/web/HelloClientInterfaceInTestTest.java similarity index 100% rename from examples/example-katie/src/test/java/org/foo/myapp/web/HelloClientInterfaceInTestTest.java rename to examples/example-http-generation/src/test/java/org/foo/myapp/web/HelloClientInterfaceInTestTest.java diff --git a/examples/example-katie/src/test/java/org/foo/myapp/web/HelloClientInterfaceViaImportTest.java b/examples/example-http-generation/src/test/java/org/foo/myapp/web/HelloClientInterfaceViaImportTest.java similarity index 85% rename from examples/example-katie/src/test/java/org/foo/myapp/web/HelloClientInterfaceViaImportTest.java rename to examples/example-http-generation/src/test/java/org/foo/myapp/web/HelloClientInterfaceViaImportTest.java index a850f1ed..81bcab30 100644 --- a/examples/example-katie/src/test/java/org/foo/myapp/web/HelloClientInterfaceViaImportTest.java +++ b/examples/example-http-generation/src/test/java/org/foo/myapp/web/HelloClientInterfaceViaImportTest.java @@ -1,14 +1,15 @@ package org.foo.myapp.web; -import io.avaje.http.api.Client; -import io.avaje.http.client.HttpClientContext; -import io.avaje.inject.test.InjectTest; -import jakarta.inject.Inject; -import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; import java.net.http.HttpResponse; -import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.Test; + +import io.avaje.http.api.Client; +import io.avaje.http.client.HttpClient; +import io.avaje.inject.test.InjectTest; +import jakarta.inject.Inject; /** * HiApi is a 'server' interface. @@ -16,13 +17,13 @@ * Using Client.Import we get the client code generated in src/test. * Actually in target/generated-test-sources/ ... */ -@Client.Import(types = HiApi.class) +@Client.Import(HiApi.class) @InjectTest class HelloClientInterfaceViaImportTest { @Inject static HiApi client; - @Inject static HttpClientContext rawClient; + @Inject static HttpClient rawClient; @Test void hello() { @@ -37,7 +38,7 @@ void hello() { @Test void hello2() { var hello = client.hello(); - assertThat(hello.msg).isEqualTo("hello"); + assertThat(hello.msg()).isEqualTo("hello"); } @Test diff --git a/examples/example-katie/src/test/java/org/foo/myapp/web/HelloControllerTest.java b/examples/example-http-generation/src/test/java/org/foo/myapp/web/HelloControllerTest.java similarity index 78% rename from examples/example-katie/src/test/java/org/foo/myapp/web/HelloControllerTest.java rename to examples/example-http-generation/src/test/java/org/foo/myapp/web/HelloControllerTest.java index 03f74180..5344d044 100644 --- a/examples/example-katie/src/test/java/org/foo/myapp/web/HelloControllerTest.java +++ b/examples/example-http-generation/src/test/java/org/foo/myapp/web/HelloControllerTest.java @@ -1,6 +1,6 @@ package org.foo.myapp.web; -import io.avaje.http.client.HttpClientContext; +import io.avaje.http.client.HttpClient; import io.avaje.inject.test.InjectTest; import jakarta.inject.Inject; import org.junit.jupiter.api.Test; @@ -10,12 +10,12 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * Using a raw HttpClientContext - not bad. + * Using a raw HttpClient - not bad. */ @InjectTest class HelloControllerTest { - @Inject HttpClientContext client; + @Inject HttpClient client; @Test void hello() { diff --git a/examples/example-katie/src/test/java/org/foo/myapp/web/MyTestHelloApi.java b/examples/example-http-generation/src/test/java/org/foo/myapp/web/MyTestHelloApi.java similarity index 100% rename from examples/example-katie/src/test/java/org/foo/myapp/web/MyTestHelloApi.java rename to examples/example-http-generation/src/test/java/org/foo/myapp/web/MyTestHelloApi.java diff --git a/examples/example-katie/src/test/java/org/foo/myapp/web/TestConfig.java b/examples/example-http-generation/src/test/java/org/foo/myapp/web/TestConfig.java similarity index 90% rename from examples/example-katie/src/test/java/org/foo/myapp/web/TestConfig.java rename to examples/example-http-generation/src/test/java/org/foo/myapp/web/TestConfig.java index ebd8c58b..e7f79971 100644 --- a/examples/example-katie/src/test/java/org/foo/myapp/web/TestConfig.java +++ b/examples/example-http-generation/src/test/java/org/foo/myapp/web/TestConfig.java @@ -1,6 +1,6 @@ package org.foo.myapp.web; -import io.avaje.http.client.HttpClientContext; +import io.avaje.http.client.HttpClient; import io.avaje.http.client.JsonbBodyAdapter; import io.avaje.inject.Bean; import io.avaje.inject.Factory; diff --git a/examples/example-jdk-jsonb/README.md b/examples/example-jdk-jsonb/README.md index fe4f1219..ba42f2ea 100644 --- a/examples/example-jdk-jsonb/README.md +++ b/examples/example-jdk-jsonb/README.md @@ -15,7 +15,7 @@ To Run: ./target/mytest # produces -# Oct 21, 2022 1:39:36 PM io.avaje.jex.jdk.JdkServerStart start +# Oct 21, 2022 1:39:36 PM io.avaje.jex.core.JdkServerStart start # INFO: started server on port 7003 version 2.5-SNAPSHOT ``` diff --git a/examples/example-jdk-jsonb/pom.xml b/examples/example-jdk-jsonb/pom.xml index ecee2dac..369efda3 100644 --- a/examples/example-jdk-jsonb/pom.xml +++ b/examples/example-jdk-jsonb/pom.xml @@ -4,108 +4,56 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - org.avaje - java11-oss - 3.9 - + io.avaje + examples + 3.0 org.example example-jdk-jsonb - 1 - - - - 17 - 0.9.16 + 21 io.avaje - avaje-jex-jdk - 2.5-SNAPSHOT + avaje-config + + + io.avaje + avaje-jex - io.avaje avaje-jsonb - 1.1 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + io.avaje + avaje-inject-generator + provided + + + io.avaje + avaje-jsonb-generator + provided + + + io.avaje + avaje-http-client-generator + provided + + + io.avaje + avaje-http-jex-generator + provided + - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.10.1 - - 17 - - - - - - io.avaje - avaje-jsonb-generator - 1.1 - - - - - - - - - - - - - - - - - - native @@ -114,7 +62,7 @@ org.graalvm.buildtools native-maven-plugin - ${native.maven.plugin.version} + 0.10.6 true @@ -142,9 +90,9 @@ --no-fallback --allow-incomplete-classpath -H:ReflectionConfigurationFiles=graalvm-meta/reflection.json - - - + + + diff --git a/examples/example-jdk-jsonb/src/main/java/module-info.java b/examples/example-jdk-jsonb/src/main/java/module-info.java index 02f09ecc..eaf03ab9 100644 --- a/examples/example-jdk-jsonb/src/main/java/module-info.java +++ b/examples/example-jdk-jsonb/src/main/java/module-info.java @@ -1,10 +1,8 @@ -import io.avaje.jsonb.Jsonb; +import io.avaje.jsonb.spi.JsonbExtension; module example.jdkTwo { - requires io.avaje.jex.jdk; + requires io.avaje.jex; requires io.avaje.jsonb; - - provides Jsonb.GeneratedComponent with org.example.jsonb.GeneratedJsonComponent; - + provides JsonbExtension with org.example.jsonb.GeneratedJsonComponent; } diff --git a/examples/example-jdk-jsonb/src/main/java/org/example/Main.java b/examples/example-jdk-jsonb/src/main/java/org/example/Main.java index a67d1392..724bcad0 100644 --- a/examples/example-jdk-jsonb/src/main/java/org/example/Main.java +++ b/examples/example-jdk-jsonb/src/main/java/org/example/Main.java @@ -1,13 +1,13 @@ package org.example; -import io.avaje.jex.Context; -import io.avaje.jex.Jex; -import io.avaje.jex.core.JsonbJsonService; -import io.avaje.jsonb.Jsonb; - import java.util.Map; import java.util.Set; +import io.avaje.jex.Jex; +import io.avaje.jex.core.json.JsonbJsonService; +import io.avaje.jex.http.Context; +import io.avaje.jsonb.Jsonb; + public class Main { private static final System.Logger log = System.getLogger("org.example"); @@ -17,7 +17,7 @@ public static void main(String[] args) { Jsonb jsonb = Jsonb.builder().build(); Jex.create() - .configure(config -> config.jsonService(new JsonbJsonService(jsonb))) + .config(config -> config.jsonService(new JsonbJsonService(jsonb))) //.attribute(Executor.class, Executors.newVirtualThreadPerTaskExecutor()) .routing(routing -> routing .get("/", ctx -> ctx.text("hello world")) diff --git a/examples/example-jdk/pom.xml b/examples/example-jdk/pom.xml index 72e52097..1af079c4 100644 --- a/examples/example-jdk/pom.xml +++ b/examples/example-jdk/pom.xml @@ -4,10 +4,9 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - org.avaje - java11-oss - 3.9 - + io.avaje + examples + 3.0 org.example @@ -23,26 +22,26 @@ io.avaje - avaje-jex-jdk - 2.5-SNAPSHOT + avaje-jex + 3.0 com.fasterxml.jackson.core jackson-databind - 2.14.0 + 2.18.3 org.slf4j slf4j-api - 1.7.36 + 2.0.17 ch.qos.logback logback-classic - 1.2.11 + 1.5.18 @@ -52,7 +51,7 @@ org.apache.maven.plugins maven-compiler-plugin - 3.8.1 + 3.14.0 ${java.release} @@ -60,7 +59,7 @@ io.repaint.maven tiles-maven-plugin - 2.22 + 2.40 true diff --git a/examples/example-jdk/src/main/java/module-info.java b/examples/example-jdk/src/main/java/module-info.java index c4284407..3f7a240d 100644 --- a/examples/example-jdk/src/main/java/module-info.java +++ b/examples/example-jdk/src/main/java/module-info.java @@ -1,6 +1,6 @@ module example.jdk { - requires transitive io.avaje.jex.jdk; + requires transitive io.avaje.jex; requires transitive org.slf4j; exports org.example to com.fasterxml.jackson.databind; diff --git a/examples/example-jdk/src/main/java/org/example/Main.java b/examples/example-jdk/src/main/java/org/example/Main.java index 4934c1b8..279abede 100644 --- a/examples/example-jdk/src/main/java/org/example/Main.java +++ b/examples/example-jdk/src/main/java/org/example/Main.java @@ -1,7 +1,8 @@ package org.example; -import io.avaje.jex.Context; import io.avaje.jex.Jex; +import io.avaje.jex.http.Context; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/examples/example-jetty/graalvm-meta/reflection.json b/examples/example-jetty/graalvm-meta/reflection.json deleted file mode 100644 index d96ec561..00000000 --- a/examples/example-jetty/graalvm-meta/reflection.json +++ /dev/null @@ -1,6 +0,0 @@ -[ - { - "name": "io.avaje.jex.jetty.JettyStartServer", - "methods": [ { "name": "", "parameterTypes": [ ] } ] - } -] diff --git a/examples/example-jetty/pom.xml b/examples/example-jetty/pom.xml index d34ed17a..c9a8fc88 100644 --- a/examples/example-jetty/pom.xml +++ b/examples/example-jetty/pom.xml @@ -1,166 +1,34 @@ - - + 4.0.0 - org.avaje - java11-oss - 3.9 - + io.avaje + examples + 3.0 - - org.example example-jetty - 1 - - - 17 - 0.9.16 - - - - ch.qos.logback - logback-classic - 1.2.11 + org.eclipse.jetty + jetty-http-spi + 12.0.18 - + io.avaje - avaje-jex-jetty - 2.5-SNAPSHOT + avaje-jex - - - - - - - - - - - - - io.avaje - avaje-jsonb - 1.1 - - - - io.avaje - avaje-jsonb-generator - 1.1 - provided + org.slf4j + slf4j-jdk-platform-logging + 2.0.17 - io.avaje - avaje-inject - 8.11 - - - - io.avaje - avaje-inject-generator - 8.11 - provided - - - - io.avaje - avaje-http-api - 1.21 - - - - io.avaje - avaje-http-jex-generator - 1.21 - provided + ch.qos.logback + logback-classic + 1.5.18 - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.10.1 - - 11 - - - - io.repaint.maven - tiles-maven-plugin - 2.22 - true - - - org.avaje.tile:lib-classpath:1.1 - - - - - - - - - - native - - - - org.graalvm.buildtools - native-maven-plugin - ${native.maven.plugin.version} - true - - - build-native - - build - - package - - - test-native - - test - - test - - - - - true - - mytest - org.example.JMain - - - -J--add-modules - -JALL-SYSTEM - --no-fallback - --allow-incomplete-classpath - -H:IncludeResources=".*/logback\\.xml" - - -H:ReflectionConfigurationFiles=graalvm-meta/reflection.json - - - - - - - - - - - - + \ No newline at end of file diff --git a/examples/example-jetty/src/main/java/io/avaje/Main.java b/examples/example-jetty/src/main/java/io/avaje/Main.java new file mode 100644 index 00000000..a0de5700 --- /dev/null +++ b/examples/example-jetty/src/main/java/io/avaje/Main.java @@ -0,0 +1,24 @@ +package io.avaje; + +import io.avaje.jex.Jex; + +public class Main { + + public static void main(String[] args) { + + Jex.create() + .get("/", ctx -> ctx.text("" + Thread.currentThread().isVirtual())) + .get("/one", ctx -> { + System.out.println("one"); + + ctx.text("one");}) + .get( + "/two/{name}", + ctx -> { + ctx.text("two Yo " + ctx.pathParam("name")); + }) + .post("one", ctx -> ctx.text("posted")) + .port(7002) + .start(); + } +} diff --git a/examples/example-jetty/src/main/java/org/example/HelloDto.java b/examples/example-jetty/src/main/java/org/example/HelloDto.java deleted file mode 100644 index 31e106c4..00000000 --- a/examples/example-jetty/src/main/java/org/example/HelloDto.java +++ /dev/null @@ -1,10 +0,0 @@ -package org.example; - -import io.avaje.jsonb.Json; - -@Json -public class HelloDto { - - public long id; - public String name; -} diff --git a/examples/example-jetty/src/main/java/org/example/JMain.java b/examples/example-jetty/src/main/java/org/example/JMain.java deleted file mode 100644 index e98202a7..00000000 --- a/examples/example-jetty/src/main/java/org/example/JMain.java +++ /dev/null @@ -1,59 +0,0 @@ -package org.example; - -import io.avaje.inject.BeanScope; -import io.avaje.jex.Context; -import io.avaje.jex.Jex; -import io.avaje.jex.jetty.JettyServerConfig; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.concurrent.locks.LockSupport; - -public class JMain { - - private static final Logger log = LoggerFactory.getLogger(JMain.class); - - public static void main(String[] args) { - new JMain().start(BeanScope.builder().build()); - } - - void start(BeanScope beanScope) { - - Jex.create() - //.configure(config -> config.virtualThreads(true)) - .configureWith(beanScope) - .routing(routing -> routing - .get("/", JMain::hello) - .get("/foo/{id}", JMain::helloBean) - .get("/delay", JMain::delay) - ) -// .staticFiles().addClasspath("/static", "content") -// .staticFiles().addExternal("/", "/tmp/junk") - .port(7004) - .start(); - } - - private static void hello(Context context) { - context.text("hello"); - } - - private static void helloBean(Context ctx) { - HelloDto bean = new HelloDto(); - bean.id = Integer.parseInt(ctx.pathParam("id")); - bean.name = "Rob"; - ctx.json(bean); - } - - private static void delay(Context ctx) { - log.info("delay start"); - try { - Thread.sleep(15_000); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - e.printStackTrace(); - } - ctx.text("delay done"); - log.info("delay done"); - } - -} diff --git a/examples/example-jetty/src/main/java/org/example/WiredController.java b/examples/example-jetty/src/main/java/org/example/WiredController.java deleted file mode 100644 index 99f1f9e5..00000000 --- a/examples/example-jetty/src/main/java/org/example/WiredController.java +++ /dev/null @@ -1,35 +0,0 @@ -package org.example; - -import io.avaje.http.api.Controller; -import io.avaje.http.api.Get; -import io.avaje.http.api.Path; -import io.avaje.http.api.Produces; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@Path("/wired") -@Controller -public class WiredController implements AutoCloseable { - - private static final Logger log = LoggerFactory.getLogger(WiredController.class); - - @Produces("text/plain") - @Get - String hello() { - return "Hello from Controller"; - } - - @Override - public void close() { - log.info("close starting ... "); - try { - Thread.sleep(500); - log.info("close done"); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - e.printStackTrace(); - } - } - - -} diff --git a/examples/example-jetty/src/main/resources/content/basic.html b/examples/example-jetty/src/main/resources/content/basic.html deleted file mode 100644 index 8b4e34d7..00000000 --- a/examples/example-jetty/src/main/resources/content/basic.html +++ /dev/null @@ -1 +0,0 @@ -basic diff --git a/examples/example-jetty/src/main/resources/content/index.html b/examples/example-jetty/src/main/resources/content/index.html deleted file mode 100644 index 0ce384c3..00000000 --- a/examples/example-jetty/src/main/resources/content/index.html +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 6be11da0..00000000 --- a/examples/example-jetty/src/main/resources/content/plain-file.txt +++ /dev/null @@ -1 +0,0 @@ -plain-file diff --git a/examples/example-jetty/src/test/resources/logback-test.xml b/examples/example-jetty/src/test/resources/logback-test.xml deleted file mode 100644 index 71d2fe94..00000000 --- a/examples/example-jetty/src/test/resources/logback-test.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - TRACE - - - %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - - - - - - - - diff --git a/examples/example-katie/pom.xml b/examples/example-katie/pom.xml deleted file mode 100644 index b5879db4..00000000 --- a/examples/example-katie/pom.xml +++ /dev/null @@ -1,106 +0,0 @@ - - - - examples - io.avaje - 0.1 - - 4.0.0 - - example-katie - - - 18 - 18 - - - - - - org.avaje - logback - 1.0 - - - - - - - - - - io.avaje - avaje-jex-jetty - 2.5-SNAPSHOT - - - - io.avaje.kate - avaje-kate - 0.9.7 - - - - - io.avaje - avaje-http-client - 1.21 - - - - io.avaje - avaje-jex-test - 2.5-SNAPSHOT - test - - - - io.avaje.kate - avaje-kate-test - 0.9.7 - test - - - - - - - - - - - - - - - - org.apache.maven.plugins - maven-surefire-plugin - ${maven-surefire-plugin.version} - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.10.1 - - - - io.avaje.kate - avaje-kate-apt - 0.9.7 - - - - - - - - - - - - - diff --git a/examples/example-katie/src/main/java/module-info.java b/examples/example-katie/src/main/java/module-info.java deleted file mode 100644 index 586ea628..00000000 --- a/examples/example-katie/src/main/java/module-info.java +++ /dev/null @@ -1,16 +0,0 @@ -import io.avaje.jsonb.Jsonb; - -open module example.katie { - - requires io.avaje.kate; -// requires io.avaje.jsonb; -// requires io.avaje.http.api; -// requires io.avaje.jex; -// requires io.avaje.jex.jetty; -// requires io.avaje.inject; - - requires io.avaje.http.client; - - provides io.avaje.inject.spi.Module with org.foo.myapp.MyappModule; - provides Jsonb.GeneratedComponent with org.foo.myapp.web.jsonb.GeneratedJsonComponent; -} diff --git a/examples/example-katie/src/main/resources/logback.xml b/examples/example-katie/src/main/resources/logback.xml deleted file mode 100644 index 9e1c33b7..00000000 --- a/examples/example-katie/src/main/resources/logback.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - TRACE - - - %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - - - - - - - - diff --git a/examples/example-katie/src/test/resources/logback-test.xml b/examples/example-katie/src/test/resources/logback-test.xml deleted file mode 100644 index ebd125dd..00000000 --- a/examples/example-katie/src/test/resources/logback-test.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - TRACE - - - %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - - - - - - - - diff --git a/examples/example-robaho/pom.xml b/examples/example-robaho/pom.xml new file mode 100644 index 00000000..baad65c0 --- /dev/null +++ b/examples/example-robaho/pom.xml @@ -0,0 +1,40 @@ + + + 4.0.0 + + io.avaje + examples + 3.0 + + + example-robaho + + + + io.github.robaho + httpserver + 1.0.23 + + + + io.avaje + avaje-jex + 3.0 + + + + org.slf4j + slf4j-jdk-platform-logging + 2.0.17 + + + + ch.qos.logback + logback-classic + 1.5.18 + + + + diff --git a/examples/example-robaho/src/main/java/io/avaje/Main.java b/examples/example-robaho/src/main/java/io/avaje/Main.java new file mode 100644 index 00000000..32b594b8 --- /dev/null +++ b/examples/example-robaho/src/main/java/io/avaje/Main.java @@ -0,0 +1,21 @@ +package io.avaje; + +import io.avaje.jex.Jex; + +public class Main { + + public static void main(String[] args) { + + Jex.create() + .routing(routing -> routing + .get("/", ctx -> ctx.text("root")) + .get("/one", ctx -> ctx.text("one")) + .get("/two/{name}", ctx -> { + ctx.text("two Yo " + ctx.pathParam("name")); + }) + .post("one", ctx -> ctx.text("posted"))) + .port(7002) + .start(); + + } +} diff --git a/examples/example-jetty/src/main/resources/logback.xml b/examples/example-robaho/src/main/resources/logback.xml similarity index 84% rename from examples/example-jetty/src/main/resources/logback.xml rename to examples/example-robaho/src/main/resources/logback.xml index 2c7f5454..4bcf2f92 100644 --- a/examples/example-jetty/src/main/resources/logback.xml +++ b/examples/example-robaho/src/main/resources/logback.xml @@ -1,4 +1,4 @@ - + TRACE @@ -14,6 +14,5 @@ - diff --git a/examples/pom.xml b/examples/pom.xml index b8996a94..940110af 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -5,30 +5,18 @@ avaje-jex-parent io.avaje - 2.5-SNAPSHOT + 3.0 examples - 0.1 pom + example-http-generation example-jdk + example-jdk-jsonb + example-robaho example-jetty - example-grizzly - - - - - jdk17plus - - [17,20] - - - example-jdk-jsonb - - - diff --git a/pom.xml b/pom.xml index 69378e9d..5cf03e14 100644 --- a/pom.xml +++ b/pom.xml @@ -1,15 +1,17 @@ - + 4.0.0 org.avaje java11-oss - 3.9 + 4.5 io.avaje avaje-jex-parent - 2.5 + 3.0 pom @@ -19,41 +21,295 @@ true + 2.18.3 + false + 21 + full + 4.0 + 11.4 + 3.2 + 3.1 + 9.4 + 2.9 + 1.2 + 2.11 + 1.42 + 15.10.0 avaje-jex - avaje-jex-test avaje-jex-freemarker + avaje-jex-grizzly-spi + avaje-jex-htmx avaje-jex-mustache - avaje-jex-jetty - avaje-jex-jdk - avaje-jex-grizzly - + avaje-jex-static-content + avaje-jex-test - - - - - io.avaje - junit - 1.1 - test - - - - io.avaje - avaje-http-client - 1.21 - test - + + + + io.ebean + ebean-bom + ${ebean.version} + import + pom + + + io.avaje + avaje-logback-encoder + 0.12 + + + io.avaje + avaje-config + ${avaje.config.version} + + + io.avaje + avaje-jsonb + ${avaje.jsonb.version} + + + io.avaje + avaje-http-api + ${avaje.http.version} + + + io.avaje + avaje-htmx-api + ${avaje.http.version} + + + io.avaje + avaje-http-client + ${avaje.http.version} + + + io.avaje + avaje-inject + ${avaje.inject.version} + + + io.avaje + avaje-inject-test + ${avaje.inject.version} + + + io.avaje + avaje-metrics + ${avaje.metrics.version} + + + io.avaje + avaje-validator-constraints + ${avaje.validator.version} + + + io.avaje + avaje-validator + ${avaje.validator.version} + + + io.avaje + avaje-http-client-generator + ${avaje.http.version} + provided + true + + + io.avaje + avaje-http-jex-generator + ${avaje.http.version} + provided + true + + + io.avaje + avaje-inject-generator + ${avaje.inject.version} + provided + true + + + io.avaje + avaje-jsonb-generator + ${avaje.jsonb.version} + provided + true + + + io.avaje + avaje-validator-generator + ${avaje.validator.version} + provided + true + + + io.avaje + avaje-prisms + ${avaje.prisms.version} + provided + true + + + io.avaje + avaje-record-builder + ${avaje.record.builder.version} + provided + true + + + io.avaje + avaje-spi-service + ${avaje.spi.version} + provided + true + + + io.ebean + ${ebean.version} + querybean-generator + provided + true + + + io.avaje + avaje-jex + 3.0 + + + io.avaje + avaje-jex-test + 3.0 + + + io.avaje + avaje-jex-freemarker + 3.0 + + + io.avaje + avaje-jex-mustache + 3.0 + + + io.avaje + avaje-jex-htmx + 3.0 + + + io.avaje + avaje-jex-static-content + 3.0 + + + io.github.robaho + httpserver + 1.0.23 + + + - + + + + maven-compiler-plugin + + + -AbuildPlugin=false + + + + + io.avaje + avaje-inject-maven-plugin + ${avaje.inject.version} + + + + process-sources + + provides + + + + + + + + + + io.avaje + openapi-maven-plugin + 1.0 + + + main + process-classes + + openapi + + + + + + maven-shade-plugin + + + package + + shade + + + + + + false + + + + + + io.avaje + avaje-provides-maven-plugin + 2.1 + + + + + disable-apt-validation + add-module-spi + + + + + + io.ebean + ebean-maven-plugin + ${ebean.version} + + + + + enhance + testEnhance + + + + + + + central + + 2025-01-27T08:47:34Z + default @@ -64,6 +320,65 @@ examples + + jdk24Plus + + [24,) + + + + + io.avaje + avaje-provides-maven-plugin + + + + + + ebean + + + target/classes/META-INF/ebean-generated-info.mf + + + + + + io.ebean + ebean-maven-plugin + + + + + + create-modules + + + + maven-dependency-plugin + + + copy-modules + package + + copy-dependencies + + + + ${project.build.directory}/modules + runtime + + + + + + maven-jar-plugin + + ${project.build.directory}/modules + + + + + -