diff --git a/avaje-jex-http3-flupke/pom.xml b/avaje-jex-http3-flupke/pom.xml new file mode 100644 index 00000000..3dd2243b --- /dev/null +++ b/avaje-jex-http3-flupke/pom.xml @@ -0,0 +1,48 @@ + + 4.0.0 + + io.avaje + avaje-jex-parent + 3.3 + + avaje-jex-http3-flupke + 0.1 + + 21 + + + + tech.kwik + flupke + 0.9 + + + tech.kwik + kwik + 0.10.7 + + + io.avaje + avaje-jex-ssl + + + io.avaje + avaje-jex-test + test + + + + io.avaje + avaje-jsonb + test + + + + io.avaje + avaje-jsonb-generator + test + + + diff --git a/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/FlupkeJexPlugin.java b/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/FlupkeJexPlugin.java new file mode 100644 index 00000000..b4b4b41d --- /dev/null +++ b/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/FlupkeJexPlugin.java @@ -0,0 +1,143 @@ +package io.avaje.jex.http3.flupke; + +import java.net.DatagramSocket; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import io.avaje.jex.Jex; +import io.avaje.jex.http3.flupke.core.H3ServerProvider; +import io.avaje.jex.http3.flupke.webtransport.WebTransportEntry; +import io.avaje.jex.http3.flupke.webtransport.WebTransportHandler; +import io.avaje.jex.spi.JexPlugin; +import tech.kwik.core.server.ServerConnectionConfig; +import tech.kwik.core.server.ServerConnector; +import tech.kwik.flupke.server.Http3ServerExtensionFactory; + +/** + * A plugin that configures Jex to use the Flupke library for HTTP/3 and WebTransport functionality. + * + *

This plugin allows customization of the underlying Flupke server components and registers + * WebTransport handlers. + */ +public final class FlupkeJexPlugin implements JexPlugin { + + private DatagramSocket socket; + private Map extensions = Map.of(); + private List wts = new ArrayList<>(); + private Consumer consumer = b -> {}; + private Consumer connection = b -> {}; + private String certAlias; + + private FlupkeJexPlugin() {} + + /** + * Creates a new instance of the {@code FlupkeJexPlugin}. + * + * @return The new plugin instance. + */ + public static FlupkeJexPlugin create() { + return new FlupkeJexPlugin(); + } + + /** + * The alias for the certificate, needed when multiple certificate chains are present + * + * @param certAlias + */ + public void certAlias(String certAlias) { + this.certAlias = certAlias; + } + + /** + * Provides a custom {@code DatagramSocket} for the HTTP/3 server to use. + * + *

This is typically used for specific network configurations or testing. + * + * @param socket The custom DatagramSocket to use. + * @return This plugin instance for chaining. + */ + public FlupkeJexPlugin customSocket(DatagramSocket socket) { + this.socket = socket; + return this; + } + + /** + * Sets a map of named {@code Http3ServerExtensionFactory} instances for advanced server + * customization. + * + * @param extensions A map where the key is the extension name and the value is the factory. + * @return This plugin instance for chaining. + */ + public FlupkeJexPlugin extensions(Map extensions) { + this.extensions = extensions; + return this; + } + + /** + * Provides a {@code Consumer} to configure the underlying Flupke {@code ServerConnector.Builder}. + * + *

This allows for low-level configuration of connection settings. + * + * @param consumer The consumer to apply configurations to the connector builder. + * @return This plugin instance for chaining. + * @see ServerConnector.Builder + */ + public FlupkeJexPlugin connectorConfig(Consumer consumer) { + this.consumer = consumer; + return this; + } + + /** + * Provides a {@code Consumer} to configure the underlying Flupke {@code + * ServerConnectionConfig.Builder}. + * + * @param consumer The consumer to apply configurations to the connection config builder. + * @return This plugin instance for chaining. + * @see ServerConnectionConfig.Builder + */ + public FlupkeJexPlugin connectionConfig(Consumer consumer) { + this.connection = consumer; + return this; + } + + /** + * Registers a new WebTransport handler for the specified path. + * + *

This is the simpler method if you have a pre-built {@code WebTransportHandler} instance. + * + * @param path The URL path (e.g., "/my-webtransport-endpoint"). + * @param handler The fully configured WebTransportHandler. + * @return This plugin instance for chaining. + */ + public FlupkeJexPlugin webTransport(String path, WebTransportHandler handler) { + this.wts.add(new WebTransportEntry(path, handler)); + return this; + } + + /** + * Registers a new WebTransport handler for the specified path using a builder pattern. + * + *

This method accepts a {@code Consumer} that receives a {@code WebTransportHandler.Builder} + * for fluent configuration of the event listeners. + * + * @param path The URL path (e.g., "/my-webtransport-endpoint"). + * @param consumer A consumer to configure the {@code WebTransportHandler.Builder}. + * @return This plugin instance for chaining. + * @see WebTransportHandler.Builder + */ + public FlupkeJexPlugin webTransport(String path, Consumer consumer) { + var b = WebTransportHandler.builder(); + consumer.accept(b); + this.wts.add(new WebTransportEntry(path, b.build())); + return this; + } + + @Override + public void apply(Jex jex) { + jex.config() + .serverProvider( + new H3ServerProvider(consumer, connection, certAlias, wts, extensions, socket)); + } +} diff --git a/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/FlupkeSystemLogger.java b/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/FlupkeSystemLogger.java new file mode 100644 index 00000000..f95bdf80 --- /dev/null +++ b/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/FlupkeSystemLogger.java @@ -0,0 +1,61 @@ +package io.avaje.jex.http3.flupke; + +import java.lang.System.Logger; +import java.lang.System.Logger.Level; +import java.nio.ByteBuffer; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import io.avaje.applog.AppLog; +import tech.kwik.core.log.BaseLogger; + +/** BaseLogger Implementation that uses the JDK System.Logger */ +public class FlupkeSystemLogger extends BaseLogger { + private static final Logger LOG = AppLog.getLogger(FlupkeSystemLogger.class); + private final Lock lock = new ReentrantLock(); + + @Override + protected void log(String text) { + this.lock.lock(); + try { + LOG.log(Level.INFO, text); + } finally { + this.lock.unlock(); + } + } + + @Override + protected void log(String text, Throwable throwable) { + if (throwable == null) { + LOG.log(Level.ERROR, text); + return; + } + + this.lock.lock(); + try { + LOG.log(Level.ERROR, text, throwable); + } finally { + this.lock.unlock(); + } + } + + @Override + protected void logWithHexDump(String text, byte[] data, int length) { + this.lock.lock(); + try { + LOG.log(Level.INFO, text + "\n" + super.byteToHexBlock(data, length)); + } finally { + this.lock.unlock(); + } + } + + @Override + protected void logWithHexDump(String text, ByteBuffer data, int offset, int length) { + this.lock.lock(); + try { + LOG.log(Level.INFO, text + "\n" + super.byteToHexBlock(data, offset, length)); + } finally { + this.lock.unlock(); + } + } +} diff --git a/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/core/FlupkeExchange.java b/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/core/FlupkeExchange.java new file mode 100644 index 00000000..a6cc81cf --- /dev/null +++ b/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/core/FlupkeExchange.java @@ -0,0 +1,194 @@ +package io.avaje.jex.http3.flupke.core; + +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.net.http.HttpHeaders; +import java.util.HashMap; +import java.util.Map; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpContext; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpPrincipal; + +import tech.kwik.flupke.server.HttpServerRequest; +import tech.kwik.flupke.server.HttpServerResponse; + +class FlupkeExchange extends HttpExchange { + + private final HttpServerRequest request; + private final HttpServerResponse response; + private final Map attributes = new HashMap<>(); + private final Headers responseHeaders = new Headers(); + + private Headers requestHeaders; + private final HttpContext ctx; + private int statusCode = 0; + private InputStream is; + private final PlaceholderOutputStream os = new PlaceholderOutputStream(); + + public FlupkeExchange(HttpServerRequest request, HttpServerResponse response, HttpContext ctx) { + this.request = request; + this.response = response; + this.ctx = ctx; + this.is = request.body(); + } + + @Override + public Headers getRequestHeaders() { + if (requestHeaders == null) { + requestHeaders = new Headers(request.headers().map()); + } + return requestHeaders; + } + + @Override + public Headers getResponseHeaders() { + return responseHeaders; + } + + @Override + public URI getRequestURI() { + return URI.create(request.path()); + } + + @Override + public String getRequestMethod() { + return request.method(); + } + + @Override + public HttpContext getHttpContext() { + return ctx; + } + + @Override + public void close() { + try (var __ = is; + var ___ = response.getOutputStream(); ) { + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public InputStream getRequestBody() { + return is; + } + + @Override + public OutputStream getResponseBody() { + return os; + } + + @Override + public void sendResponseHeaders(int status, long responseLength) throws IOException { + statusCode = status; + if (responseLength > 0) { + responseHeaders.add("Content-length", Long.toString(responseLength)); + } + response.setHeaders(HttpHeaders.of(responseHeaders, (a, b) -> true)); + response.setStatus(status); + os.wrapped = response.getOutputStream(); + } + + @Override + public InetSocketAddress getRemoteAddress() { + // TODO use new flupke version to get socket address + return null; + } + + @Override + public int getResponseCode() { + return statusCode; + } + + @Override + public InetSocketAddress getLocalAddress() { + return (InetSocketAddress) ctx.getAttributes().get("local_inet_address"); + } + + @Override + public String getProtocol() { + return "h3"; + } + + @Override + public Object getAttribute(String name) { + return attributes.get(name); + } + + @Override + public void setAttribute(String name, Object value) { + attributes.put(name, value); + } + + @Override + public void setStreams(InputStream i, OutputStream o) { + is = i; + os.wrapped = o; + } + + @Override + public HttpPrincipal getPrincipal() { + throw new UnsupportedOperationException(); + } + + /** + * An OutputStream which wraps another wtStream which is supplied either at creation time, or + * sometime later. If a caller/user tries to write to this wtStream before the wrapped wtStream + * has been provided, then an IOException will be thrown. + */ + class PlaceholderOutputStream extends OutputStream { + + OutputStream wrapped; + + void setWrappedStream(OutputStream os) { + wrapped = os; + } + + boolean isWrapped() { + return wrapped != null; + } + + private void checkWrap() throws IOException { + if (wrapped == null) { + throw new IOException("response headers not sent yet"); + } + } + + @Override + public void write(int b) throws IOException { + checkWrap(); + wrapped.write(b); + } + + @Override + public void write(byte b[]) throws IOException { + checkWrap(); + wrapped.write(b); + } + + @Override + public void write(byte b[], int off, int len) throws IOException { + checkWrap(); + wrapped.write(b, off, len); + } + + @Override + public void flush() throws IOException { + checkWrap(); + wrapped.flush(); + } + + @Override + public void close() throws IOException { + checkWrap(); + wrapped.close(); + } + } +} diff --git a/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/core/FlupkeHttpContext.java b/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/core/FlupkeHttpContext.java new file mode 100644 index 00000000..35b3e866 --- /dev/null +++ b/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/core/FlupkeHttpContext.java @@ -0,0 +1,71 @@ +package io.avaje.jex.http3.flupke.core; + +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.HttpContext; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +class FlupkeHttpContext extends HttpContext { + + private final HttpSpiContextHandler handler; + private final HttpServer server; + private final Map attributes = new HashMap<>(Map.of("protocol", "UDP")); + private final List filters = new ArrayList<>(); + private final HttpHandler httpHandler; + + protected FlupkeHttpContext(HttpServer server, HttpHandler handler) { + httpHandler = handler; + this.server = server; + this.handler = new HttpSpiContextHandler(this, handler); + } + + protected HttpSpiContextHandler flupkeHandler() { + return handler; + } + + @Override + public HttpHandler getHandler() { + return httpHandler; + } + + @Override + public void setHandler(HttpHandler h) { + handler.setHttpHandler(h); + } + + @Override + public String getPath() { + return "/"; + } + + @Override + public HttpServer getServer() { + return server; + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public List getFilters() { + return filters; + } + + @Override + public Authenticator setAuthenticator(Authenticator auth) { + throw new UnsupportedOperationException(); + } + + @Override + public Authenticator getAuthenticator() { + throw new UnsupportedOperationException(); + } +} diff --git a/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/core/FlupkeHttpServer.java b/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/core/FlupkeHttpServer.java new file mode 100644 index 00000000..a561a91f --- /dev/null +++ b/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/core/FlupkeHttpServer.java @@ -0,0 +1,202 @@ +package io.avaje.jex.http3.flupke.core; + +import static java.lang.System.Logger.Level.INFO; + +import java.io.IOException; +import java.net.DatagramSocket; +import java.net.InetSocketAddress; +import java.security.KeyStore; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.function.Consumer; + +import com.sun.net.httpserver.Filter; +import com.sun.net.httpserver.HttpContext; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsServer; + +import io.avaje.applog.AppLog; +import io.avaje.jex.http3.flupke.FlupkeSystemLogger; +import io.avaje.jex.http3.flupke.webtransport.WebTransportEntry; +import io.avaje.jex.ssl.core.SSLConfigurator; +import tech.kwik.core.server.ServerConnectionConfig; +import tech.kwik.core.server.ServerConnector; +import tech.kwik.flupke.server.Http3ApplicationProtocolFactory; +import tech.kwik.flupke.server.Http3ServerExtensionFactory; +import tech.kwik.flupke.webtransport.WebTransportHttp3ApplicationProtocolFactory; + +class FlupkeHttpServer extends HttpsServer { + + private static final System.Logger log = AppLog.getLogger("io.avaje.jex"); + private final List wts; + private final Consumer configuration; + private final Consumer connection; + private final String certAlias; + private final HttpsServer http1; + + private DatagramSocket datagram; + private InetSocketAddress addr; + private Executor executor; + private FlupkeHttpContext context; + private ServerConnector connector; + private KeyStore keystore; + private String password; + + public FlupkeHttpServer( + Consumer configuration, + Consumer connection, + List wts, + String certAlias, + Map extensions, + DatagramSocket socket, + InetSocketAddress addr, + int backlog) + throws IOException { + this.configuration = configuration; + this.connection = connection; + this.certAlias = certAlias; + this.datagram = socket; + this.addr = addr; + this.wts = wts; + http1 = HttpsServer.create(addr, backlog); + } + + @Override + public void bind(InetSocketAddress addr, int backlog) throws IOException { + this.addr = addr; + if (datagram == null) { + datagram = new DatagramSocket(addr); + } + } + + @Override + public InetSocketAddress getAddress() { + return (InetSocketAddress) datagram.getLocalSocketAddress(); + } + + @Override + public void start() { + try { + var builder = ServerConnector.builder(); + var connectionBuilder = + ServerConnectionConfig.builder() + .maxIdleTimeoutInSeconds(30) + .maxUnidirectionalStreamBufferSize(1_000_000) + .maxBidirectionalStreamBufferSize(1_000_000) + .maxConnectionBufferSize(10_000_000) + .maxOpenPeerInitiatedUnidirectionalStreams(10) + .maxOpenPeerInitiatedBidirectionalStreams(100) + .connectionIdLength(8) + .retryRequired(true); + connection.accept(connectionBuilder); + bind(addr, 0); + builder + .withConfiguration(connectionBuilder.build()) + .withLogger(new FlupkeSystemLogger()) + .withPort(1) + .withSocket(datagram) + .withKeyStore( + keystore, + certAlias != null ? certAlias : keystore.aliases().nextElement(), + password.toCharArray()); + + configuration.accept(builder); + this.connector = builder.build(); + Http3ApplicationProtocolFactory factory; + + if (!wts.isEmpty()) { + var wt = new WebTransportHttp3ApplicationProtocolFactory(context.flupkeHandler()); + wt.setExecutor((ExecutorService) executor); + for (var entry : wts) { + wt.registerWebTransportServer(entry.path(), entry); + } + factory = wt; + } else { + // TODO register virtual thread executor on new flupke release + factory = new Http3ApplicationProtocolFactory(context.flupkeHandler()); + } + connector.registerApplicationProtocol("h3", factory); + connector.start(); + InetSocketAddress address = getAddress(); + context.getAttributes().put("local_inet_address", address); + + http1 + .createContext("/", context.getHandler()) + .getFilters() + .add( + Filter.beforeHandler( + "Alt-Svc", + ctx -> { + ctx.getResponseHeaders().add("Alt-Svc", "h3=\":443\""); + ctx.getResponseHeaders() + .add("Alt-Svc", "h3=\":%s\"".formatted(datagram.getLocalPort())); + })); + http1.start(); + log.log( + INFO, + "Avaje Jex started {0} on TCP https://{1}:{2,number,#}", + http1.getClass(), + address.getHostName(), + address.getPort()); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + @Override + public void setExecutor(Executor executor) { + this.executor = executor; + http1.setExecutor(executor); + } + + @Override + public Executor getExecutor() { + return executor; + } + + @Override + public void stop(int delay) { + connector.close(); + http1.stop(delay); + } + + @Override + public HttpContext createContext(String path, HttpHandler httpHandler) { + this.context = new FlupkeHttpContext(this, httpHandler); + return context; + } + + @Override + public void setHttpsConfigurator(HttpsConfigurator config) { + if (config instanceof SSLConfigurator ssl) { + this.keystore = ssl.keyStore(); + this.password = ssl.password(); + } else { + throw new IllegalArgumentException("Only the Jex SSL configurator is supported"); + } + http1.setHttpsConfigurator(config); + } + + @Override + public HttpsConfigurator getHttpsConfigurator() { + throw new UnsupportedOperationException(); + } + + @Override + public HttpContext createContext(String path) { + throw new UnsupportedOperationException("Need a handler"); + } + + @Override + public void removeContext(String path) throws IllegalArgumentException { + throw new UnsupportedOperationException(); + } + + @Override + public void removeContext(HttpContext context) { + throw new UnsupportedOperationException(); + } +} diff --git a/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/core/H3ServerProvider.java b/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/core/H3ServerProvider.java new file mode 100644 index 00000000..14f53bc5 --- /dev/null +++ b/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/core/H3ServerProvider.java @@ -0,0 +1,53 @@ +package io.avaje.jex.http3.flupke.core; + +import java.io.IOException; +import java.net.DatagramSocket; +import java.net.InetSocketAddress; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.HttpsServer; +import com.sun.net.httpserver.spi.HttpServerProvider; + +import io.avaje.jex.http3.flupke.webtransport.WebTransportEntry; +import tech.kwik.core.server.ServerConnectionConfig; +import tech.kwik.core.server.ServerConnector; +import tech.kwik.flupke.server.Http3ServerExtensionFactory; + +public class H3ServerProvider extends HttpServerProvider { + + private final Consumer consumer; + private final Consumer connection; + private final String certAlias; + private final DatagramSocket datagram; + private final List wts; + private final Map extensions; + + public H3ServerProvider( + Consumer consumer, + Consumer connection, + String certAlias, + List wts, + Map extensions, + DatagramSocket datagram) { + this.consumer = consumer; + this.connection = connection; + this.certAlias = certAlias; + this.wts = wts; + this.datagram = datagram; + this.extensions = extensions; + } + + @Override + public HttpServer createHttpServer(InetSocketAddress addr, int backlog) throws IOException { + throw new UnsupportedOperationException("Https is required for HTTP/3"); + } + + @Override + public HttpsServer createHttpsServer(InetSocketAddress addr, int backlog) throws IOException { + return new FlupkeHttpServer( + consumer, connection, wts, certAlias, extensions, datagram, addr, backlog); + } +} diff --git a/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/core/HttpSpiContextHandler.java b/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/core/HttpSpiContextHandler.java new file mode 100644 index 00000000..ff4bac1f --- /dev/null +++ b/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/core/HttpSpiContextHandler.java @@ -0,0 +1,45 @@ +package io.avaje.jex.http3.flupke.core; + +import java.io.IOException; +import java.lang.System.Logger; +import java.lang.System.Logger.Level; + +import com.sun.net.httpserver.Filter.Chain; +import com.sun.net.httpserver.HttpContext; +import com.sun.net.httpserver.HttpHandler; + +import io.avaje.applog.AppLog; +import tech.kwik.flupke.server.HttpRequestHandler; +import tech.kwik.flupke.server.HttpServerRequest; +import tech.kwik.flupke.server.HttpServerResponse; + +class HttpSpiContextHandler implements HttpRequestHandler { + public static final Logger LOG = AppLog.getLogger(HttpSpiContextHandler.class); + + private final HttpContext httpContext; + + private HttpHandler httpHandler; + + public HttpSpiContextHandler(HttpContext httpContext, HttpHandler httpHandler) { + this.httpContext = httpContext; + this.httpHandler = httpHandler; + } + + @Override + public void handleRequest(HttpServerRequest request, HttpServerResponse response) + throws IOException { + final var exchange = new FlupkeExchange(request, response, httpContext); + try { + new Chain(httpContext.getFilters(), httpHandler).doFilter(exchange); + } catch (Exception ex) { + LOG.log(Level.ERROR, "Failed to handle", ex); + response.setStatus(500); + } finally { + exchange.close(); + } + } + + void setHttpHandler(HttpHandler httpHandler) { + this.httpHandler = httpHandler; + } +} diff --git a/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/webtransport/WebTransportEntry.java b/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/webtransport/WebTransportEntry.java new file mode 100644 index 00000000..0faa557d --- /dev/null +++ b/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/webtransport/WebTransportEntry.java @@ -0,0 +1,33 @@ +package io.avaje.jex.http3.flupke.webtransport; + +import java.util.function.Consumer; + +import io.avaje.jex.http3.flupke.webtransport.WebTransportEvent.BiStream; +import io.avaje.jex.http3.flupke.webtransport.WebTransportEvent.Close; +import io.avaje.jex.http3.flupke.webtransport.WebTransportEvent.Open; +import io.avaje.jex.http3.flupke.webtransport.WebTransportEvent.UniStream; +import tech.kwik.flupke.webtransport.Session; + +/** Entry for webtransport */ +public final record WebTransportEntry(String path, WebTransportHandler handler) + implements Consumer { + + @Override + public void accept(Session session) { + + session.registerSessionTerminatedEventListener( + (l, s) -> handler.onClose(new Close(session, l, s))); + session.setUnidirectionalStreamReceiveHandler( + s -> { + var ctx = new UniStream(session, s); + handler.onUniDirectionalStream(ctx); + }); + session.setBidirectionalStreamReceiveHandler( + s -> { + var ctx = new BiStream(session, s); + handler.onBiDirectionalStream(ctx); + }); + session.open(); + handler.onOpen(new Open(session)); + } +} diff --git a/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/webtransport/WebTransportEvent.java b/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/webtransport/WebTransportEvent.java new file mode 100644 index 00000000..c2157079 --- /dev/null +++ b/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/webtransport/WebTransportEvent.java @@ -0,0 +1,232 @@ +package io.avaje.jex.http3.flupke.webtransport; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UncheckedIOException; + +import tech.kwik.flupke.webtransport.Session; +import tech.kwik.flupke.webtransport.WebTransportStream; + +/** + * The abstract sealed base class that provides the context for a specific {@link + * WebTransportEvent}. + * + *

This class and its permitted subclasses represent the various lifecycle events that can occur + * during a WebTransport session, such as opening, closing, and receiving new streams. + */ +public abstract sealed class WebTransportEvent { + + private final Session session; + + protected WebTransportEvent(Session session) { + this.session = session; + } + + /** + * Returns the original path (including query parameters) of the URL that started this + * WebTransport session. + * + * @return The URL path string. + */ + public String path() { + return session.getPath(); + } + + /** + * Gets the unique ID associated with this WebTransport session. + * + * @return The session ID as a long. + */ + public long sessionId() { + return session.getSessionId(); + } + + /** + * Whether the current session is still active. + * + * @return whether the session is active. + */ + public boolean isOpen() { + return session.isOpen(); + } + + /** + * Creates a new unidirectional {@link OutputStream} from the server to the client within this + * session. + * + * @return The newly created unidirectional wtStream. + * @throws UncheckedIOException If an I/O error occurs while creating the wtStream. + */ + public OutputStream createUnidirectionalStream() { + try { + return session.createUnidirectionalStream().getOutputStream(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** + * Creates a new bidirectional {@link WebTransportStream} within this session, allowing data flow + * in both directions. + * + * @return The newly created bidirectional wtStream. + * @throws UncheckedIOException If an I/O error occurs while creating the wtStream. + */ + public WebTransportStream createBiDirectionalStream() { + try { + return session.createBidirectionalStream(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** + * Closes the WebTransport session gracefully with the default close code (0) and an empty reason. + * + *

This is equivalent to calling {@code closeSession(0, "")}. + * + * @throws UncheckedIOException If an I/O error occurs while closing the session. + */ + public void closeSession() { + closeSession(0, ""); + } + + /** + * Closes the WebTransport session with a specified application-defined code and a reason string. + * + * @param code The application-specific close code. + * @param message The human-readable reason for closing. + * @throws UncheckedIOException If an I/O error occurs while closing the session. + */ + public void closeSession(long code, String message) { + + try { + session.close(code, message); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + // --- Nested Event Classes --- + + /** + * Represents the context for an open event. + * + *

This event occurs when a new WebTransport connection is established and the handshake is + * complete. + */ + public static final class Open extends WebTransportEvent { + Open(Session s) { + super(s); + } + } + + /** + * Represents the context for a close event. + * + *

This event is triggered when the WebTransport session is closed, either by the client, the + * server (via {@link WebTransportEvent#closeSession(long, String)}), or due to a network error. + */ + public static final class Close extends WebTransportEvent { + + final long code; + final String message; + + Close(Session session, long code, String message) { + super(session); + this.code = code; + this.message = message; + } + + /** + * Returns the application-defined close code sent by the party initiating the close. + * + * @return The close code. + */ + public long code() { + return code; + } + + /** + * Returns the reason message for the session close. + * + * @return The close reason message. + */ + public String message() { + return message; + } + } + + abstract static sealed class Stream extends WebTransportEvent implements AutoCloseable { + final WebTransportStream wtStream; + + Stream(Session session, WebTransportStream stream) { + super(session); + this.wtStream = stream; + } + } + + /** + * Represents the context for a new bidirectional wtStream event. + * + *

This occurs when the client initiates a new bidirectional wtStream to the server. The + * returned wtStream can be used for both reading (input) and writing (output). + */ + public static final class BiStream extends Stream { + BiStream(Session session, WebTransportStream stream) { + super(session, stream); + } + + /** + * Returns the input stream for reading data sent by the client wtStream. + * + * @return An {@link InputStream} for reading the client's data. + */ + public InputStream requestStream() { + return wtStream.getInputStream(); + } + + /** + * Returns the stream for writing data to the client on this bidirectional wtStream. + * + * @return An {@link OutputStream} for writing to the client. + */ + public OutputStream responseStream() { + return wtStream.getOutputStream(); + } + + @Override + public void close() throws IOException { + try (var in = wtStream.getInputStream(); + var out = wtStream.getOutputStream()) {} + } + } + + /** + * Represents the context for a new unidirectional wtStream event. + * + *

This occurs when the client initiates a new unidirectional wtStream to the server. The + * returned wtStream is only for reading (input) data sent by the client. + */ + public static final class UniStream extends Stream { + + UniStream(Session session, WebTransportStream stream) { + super(session, stream); + } + + /** + * Returns the input stream for reading data sent by the client on this unidirectional wtStream. + * + * @return An {@link InputStream} for reading the client's data. + */ + public InputStream requestStream() { + return wtStream.getInputStream(); + } + + @Override + public void close() throws IOException { + wtStream.getInputStream().close(); + } + } +} diff --git a/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/webtransport/WebTransportHandler.java b/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/webtransport/WebTransportHandler.java new file mode 100644 index 00000000..6bce716d --- /dev/null +++ b/avaje-jex-http3-flupke/src/main/java/io/avaje/jex/http3/flupke/webtransport/WebTransportHandler.java @@ -0,0 +1,176 @@ +package io.avaje.jex.http3.flupke.webtransport; + +import java.util.Objects; +import java.util.function.Consumer; + +import io.avaje.jex.http3.flupke.webtransport.WebTransportEvent.BiStream; +import io.avaje.jex.http3.flupke.webtransport.WebTransportEvent.Close; +import io.avaje.jex.http3.flupke.webtransport.WebTransportEvent.Open; +import io.avaje.jex.http3.flupke.webtransport.WebTransportEvent.UniStream; + +/** + * Defines the contract for handling lifecycle events within a single WebTransport session. + * + *

This interface is typically implemented using the nested {@link Builder} to create a handler + * that delegates each event type to a custom {@code Consumer} function. + */ +public interface WebTransportHandler { + + /** + * Returns a new {@code Builder} instance for fluently creating a {@code WebTransportHandler}. + * + * @return A new Builder. + */ + static Builder builder() { + return new Builder(); + } + + /** + * Handles a new WebTransport session opening event. + * + * @param context The {@code Open} context containing session details. + */ + void onOpen(Open context); + + /** + * Handles a WebTransport session closing event. + * + * @param context The {@code Close} context, which includes the closing code and message. + */ + void onClose(Close context); + + /** + * Handles a new unidirectional stream opened by the client to the server. + * + *

The handler should consume the data from the stream via {@link UniStream#requestStream()}. + * + * @param context The {@code UniStream} context, which provides access to the read-only {@code + * InputStream}. + */ + void onUniDirectionalStream(UniStream context); + + /** + * Handles a new bidirectional stream opened by the client. + * + *

The handler can read data from and write data to the stream via {@link + * BiStream#requestStream()}. + * + * @param context The {@code BiStream} context, which provides access to the request/response + * streams. + */ + void onBiDirectionalStream(BiStream context); + + /** + * A fluent builder for creating a {@link WebTransportHandler} implementation. + * + *

The builder allows setting a {@code Consumer} for each specific WebTransport event. Events + * not configured will use a default implementation: {@code onOpen} and {@code onClose} default to + * a no-op, while stream handlers default to throwing an {@code UnsupportedOperationException}. + */ + final class Builder { + + // Consumers to hold the logic for each event type. + // Defaults to an empty operation (no-op) if not explicitly set. + private Consumer openHandler = ctx -> {}; + private Consumer closeHandler = ctx -> {}; + private Consumer bidirectional = + ctx -> { + throwUOE("bidirectional handler not implemented"); + }; + + private Consumer unidirectional = + ctx -> { + throwUOE("unidirectional handler not implemented"); + }; + + private Builder() {} + + private void throwUOE(String message) { + throw new UnsupportedOperationException(message); + } + + /** + * Factory method to start the building process. + * + * @return A new instance of the Builder. + */ + public static Builder builder() { + return new Builder(); + } + + // --- Fluent Setter Methods --- + + /** + * Sets the consumer function to be executed when a session is opened. + * + * @param handler The consumer to handle the {@code Open} event context. Must not be null. + * @return This builder instance for chaining. + */ + public Builder onOpen(Consumer handler) { + this.openHandler = Objects.requireNonNull(handler); + return this; + } + + /** + * Sets the consumer function to be executed when a session is closed. + * + * @param handler The consumer to handle the {@code Close} event context. Must not be null. + * @return This builder instance for chaining. + */ + public Builder onClose(Consumer handler) { + this.closeHandler = Objects.requireNonNull(handler); + return this; + } + + /** + * Sets the consumer function to be executed when the client opens a new unidirectional stream. + * + * @param handler The consumer to handle the {@code UniStream} event context. Must not be null. + * @return This builder instance for chaining. + */ + public Builder onUniDirectionalStream(Consumer handler) { + this.unidirectional = Objects.requireNonNull(handler); + return this; + } + + /** + * Sets the consumer function to be executed when the client opens a new bidirectional stream. + * + * @param handler The consumer to handle the {@code BiStream} event context. Must not be null. + * @return This builder instance for chaining. + */ + public Builder onBiDirectionalStream(Consumer handler) { + this.bidirectional = Objects.requireNonNull(handler); + return this; + } + + /** + * Finishes the configuration and returns the fully built {@code WebTransportHandler}. + * + * @return A {@code WebTransportHandler} that delegates to the configured Consumer functions. + */ + public WebTransportHandler build() { + return new WebTransportHandler() { + @Override + public void onOpen(Open context) { + openHandler.accept(context); + } + + @Override + public void onClose(Close context) { + closeHandler.accept(context); + } + + @Override + public void onUniDirectionalStream(UniStream context) { + unidirectional.accept(context); + } + + @Override + public void onBiDirectionalStream(BiStream context) { + bidirectional.accept(context); + } + }; + } + } +} diff --git a/avaje-jex-http3-flupke/src/main/java/module-info.java b/avaje-jex-http3-flupke/src/main/java/module-info.java new file mode 100644 index 00000000..fe3cb231 --- /dev/null +++ b/avaje-jex-http3-flupke/src/main/java/module-info.java @@ -0,0 +1,7 @@ +module io.avaje.jex.http3.flupke { + + requires transitive io.avaje.jex.ssl; + requires transitive tech.kwik.core; + requires transitive tech.kwik.flupke; + requires java.base; +} diff --git a/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/AutoCloseIterator.java b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/AutoCloseIterator.java new file mode 100644 index 00000000..3f33c06e --- /dev/null +++ b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/AutoCloseIterator.java @@ -0,0 +1,33 @@ +package io.avaje.jex.http3.flupke; + +import java.util.Iterator; +import java.util.concurrent.atomic.AtomicBoolean; + +public class AutoCloseIterator implements Iterator, AutoCloseable { + + private final Iterator it; + private final AtomicBoolean closed = new AtomicBoolean(false); + + 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.set(true); + } + + public boolean isClosed() { + return closed.get(); + } +} diff --git a/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/CharacterEncodingTest.java b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/CharacterEncodingTest.java new file mode 100644 index 00000000..e6d4e0c3 --- /dev/null +++ b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/CharacterEncodingTest.java @@ -0,0 +1,55 @@ +package io.avaje.jex.http3.flupke; + +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; + +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.close(); + } + + @Test + void get() { + + var textRes = pair.request().path("text").GET().asString(); + var jsonRes = pair.request().path("json").GET().asString(); + var htmlRes = pair.request().path("html").GET().asString(); + + assertThat(contentType(jsonRes)).isEqualTo("application/json"); + assertThat(jsonRes.body()).isEqualTo("\"白菜湯\""); + assertThat(contentType(htmlRes)).isEqualTo("text/html;charset=utf-8"); + assertThat(htmlRes.body()).isEqualTo("kålsuppe"); + assertThat(contentType(textRes)).isEqualTo("text/plain;charset=utf-8"); + assertThat(textRes.body()).isEqualTo("суп из капусты"); + } + + private String contentType(HttpResponse res) { + return res.headers().firstValue("Content-Type").get(); + } +} diff --git a/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/CompressionTest.java b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/CompressionTest.java new file mode 100644 index 00000000..684f8ac9 --- /dev/null +++ b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/CompressionTest.java @@ -0,0 +1,90 @@ +package io.avaje.jex.http3.flupke; + +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.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.close(); + } + + @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(); + } + + @Test + void testCompressionRange() 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); + } +} diff --git a/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/ContextAttributeTest.java b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/ContextAttributeTest.java new file mode 100644 index 00000000..25d7dcd5 --- /dev/null +++ b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/ContextAttributeTest.java @@ -0,0 +1,70 @@ +package io.avaje.jex.http3.flupke; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.http.HttpResponse; +import java.util.UUID; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +import io.avaje.jex.Jex; + +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.close(); + } + + @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-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/ContextFormParamTest.java b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/ContextFormParamTest.java new file mode 100644 index 00000000..48b6bf74 --- /dev/null +++ b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/ContextFormParamTest.java @@ -0,0 +1,137 @@ +package io.avaje.jex.http3.flupke; + +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; + +class ContextFormParamTest { + + static TestPair pair = init(); + + static TestPair init() { + var app = Jex.create() + .config(c -> c.maxRequestSize(-1)) + .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.close(); + } + + @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-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/ContextLengthTest.java b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/ContextLengthTest.java new file mode 100644 index 00000000..e75cc3dd --- /dev/null +++ b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/ContextLengthTest.java @@ -0,0 +1,119 @@ +//TODO Enable on new flupke version +//package io.avaje.jex.flupke.core; +// +//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.http3.flupke.TestPair; +// +//class ContextLengthTest { +// +// static TestPair pair = init(); +// +// static TestPair init() { +// var app = Jex.create() +// .config(c -> c.maxRequestSize(-1)) +// .routing(routing -> routing .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())) +// ); +// return TestPair.create(app); +// } +// +// @AfterAll +// static void end() { +// pair.close(); +// } +// +// @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 uri() { +// HttpResponse res = pair.request() +// .path("uri") +// .path("uriTest") +// .queryParam("a", "av") +// .GET().asString(); +// +// assertThat(res.statusCode()).isEqualTo(200); +// 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/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("matchedPath:/matchedPath/{param}"); +// } +// +// @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"); +// } +//} +package io; + + diff --git a/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/ContextRequestTooBigTest.java b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/ContextRequestTooBigTest.java new file mode 100644 index 00000000..3f63fe23 --- /dev/null +++ b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/ContextRequestTooBigTest.java @@ -0,0 +1,33 @@ +package io.avaje.jex.http3.flupke; + +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; + +class ContextRequestTooBigTest { + + static final TestPair pair = init(); + + static TestPair init() { + final Jex app = + Jex.create().config(c -> c.maxRequestSize(5)).post("/", ctx -> ctx.text(ctx.body())); + + return TestPair.create(app); + } + + @AfterAll + static void end() { + pair.close(); + } + + @Test + void overSized() { + HttpResponse res = pair.request().body("amogus").POST().asString(); + assertThat(res.statusCode()).isEqualTo(413); + } +} diff --git a/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/ContextTest.java b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/ContextTest.java new file mode 100644 index 00000000..8877135b --- /dev/null +++ b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/ContextTest.java @@ -0,0 +1,158 @@ +package io.avaje.jex.http3.flupke; + +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 org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +import io.avaje.jex.Jex; + +class ContextTest { + + static TestPair pair = init(); + + static TestPair init() { + final Jex app = Jex.create() + .config(c -> c.maxRequestSize(-1)) + .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("/ip", ctx -> { + final String ip = ctx.ip(); + requireNonNull(ip); + ctx.text("ip:" + ip); + }) + .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()); + })); + + return TestPair.create(app); + } + + @AfterAll + static void end() { + pair.close(); + } + + @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"); + } +// TODO enable on new flupke +// @Test +// void ctx_ip() { +// HttpResponse res = pair.request().path("ip") +// .GET().asString(); +// +// assertThat(res.body()).isEqualTo("ip:127.0.0.1"); +// } + + + @Test + void ctx_methodPathPortProtocol() { + HttpResponse res = pair.request().path("method") + .GET().asString(); + + assertThat(res.body()).isEqualTo("method:GET path:/method protocol:h3 port:" + pair.port()); + } + + @Test + void post_double_string() { + HttpResponse res = pair.request().path("echo").body("simple").POST().asString(); + assertThat(res.body()).isEqualTo("req-body[simple]"); + } + + @Test + void post_double_json_fail() { + HttpResponse res = pair.request().path("doubleJsonStream").body("{}").POST().asString(); + assertThat(res.body()).isEqualTo("Internal Server Error"); + } + + @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("doubleString").body("simple").POST().asString(); + assertThat(res.body()).isEqualTo("simplesimple"); + } + + @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-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/CtxPathTest.java b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/CtxPathTest.java new file mode 100644 index 00000000..c7ce386b --- /dev/null +++ b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/CtxPathTest.java @@ -0,0 +1,40 @@ +package io.avaje.jex.http3.flupke; + +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; + +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.close(); + } + + @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-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/ExceptionManagerTest.java b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/ExceptionManagerTest.java new file mode 100644 index 00000000..ded562b9 --- /dev/null +++ b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/ExceptionManagerTest.java @@ -0,0 +1,105 @@ +package io.avaje.jex.http3.flupke; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.http.HttpResponse; +import java.util.Map; + +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.HttpResponseException; +import io.avaje.jex.http.HttpStatus; +import io.avaje.json.JsonException; + +class ExceptionManagerTest { + + static TestPair pair = init(); + + static TestPair init() { + final Jex app = Jex.create() + .routing(routing -> routing + .get("/", ctx -> { + throw new HttpResponseException(HttpStatus.FORBIDDEN_403.status(), "Forbidden"); + }) + .post("/", ctx -> { + throw new IllegalStateException("foo"); + }) + .get("/conflict", ctx -> { + throw new HttpResponseException(409, "Baz"); + }) + .get("/fiveHundred", ctx -> { + throw new IllegalArgumentException("Bar"); + }) + .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); + } + + @AfterAll + static void end() { + pair.close(); + } + + @Test + void get() { + HttpResponse res = pair.request().GET().asString(); + assertThat(res.statusCode()).isEqualTo(403); + assertThat(res.body()).isEqualTo("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 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(); + 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-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/FilterTest.java b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/FilterTest.java new file mode 100644 index 00000000..d0be764e --- /dev/null +++ b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/FilterTest.java @@ -0,0 +1,118 @@ +package io.avaje.jex.http3.flupke; + +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.close(); + } + + 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-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/HeadersTest.java b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/HeadersTest.java new file mode 100644 index 00000000..a099313d --- /dev/null +++ b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/HeadersTest.java @@ -0,0 +1,55 @@ +package io.avaje.jex.http3.flupke; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.http.HttpResponse; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Random; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import io.avaje.http.client.HttpClient; +import io.avaje.http.client.JacksonBodyAdapter; +import io.avaje.jex.Jex; + +class HeadersTest { + + static final int port = new Random().nextInt(1000) + 10_000; + static Jex.Server server; + static HttpClient client; + + @BeforeAll + static void setup() { + server = Jex.create() + .routing(routing -> routing + .get("/", ctx -> { + final String one = ctx.header("one"); + Map obj = new LinkedHashMap<>(); + obj.put("one", one); + ctx.json(obj); + }) + ) + .port(port) + .start(); + + client = HttpClient.builder() + .baseUrl("http://localhost:"+port) + .bodyAdapter(new JacksonBodyAdapter()) + .build(); + } + + @Test + void get() { + + final HttpResponse hres = client.request() + .header("one", "hello") + .GET().asString(); + + assertThat(hres.statusCode()).isEqualTo(200); + assertThat(hres.body()).isEqualTo("{\"one\":\"hello\"}"); + + server.shutdown(); + } +} diff --git a/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/HelloBean.java b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/HelloBean.java new file mode 100644 index 00000000..31cfc921 --- /dev/null +++ b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/HelloBean.java @@ -0,0 +1,19 @@ +package io.avaje.jex.http3.flupke; + +import io.avaje.jsonb.Json; + +@Json +public class HelloBean { + + public int id; + public String name; + + public HelloBean(int id, String name) { + this.id = id; + this.name = name; + } + + public HelloBean() { + + } +} diff --git a/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/HelloDto.java b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/HelloDto.java new file mode 100644 index 00000000..3cac372a --- /dev/null +++ b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/HelloDto.java @@ -0,0 +1,30 @@ +package io.avaje.jex.http3.flupke; + +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-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/JsonTest.java b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/JsonTest.java new file mode 100644 index 00000000..47c10bd9 --- /dev/null +++ b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/JsonTest.java @@ -0,0 +1,184 @@ +package io.avaje.jex.http3.flupke; + +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 org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +import io.avaje.jex.Jex; +import io.avaje.jex.core.json.JacksonJsonService; +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; + +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() + .config(c -> c.maxRequestSize(-1)) + .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.close(); + } + + @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-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/Main.java b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/Main.java new file mode 100644 index 00000000..1ad89c0c --- /dev/null +++ b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/Main.java @@ -0,0 +1,16 @@ +package io.avaje.jex.http3.flupke; + +import io.avaje.jex.Jex; + +public class Main { + + public static void main(String[] args) { + + Jex.create() + .routing(routing -> routing + .get("/", ctx -> ctx.text("hello world")) + ) + .port(9009) + .start(); + } +} diff --git a/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/MultiHandlerTest.java b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/MultiHandlerTest.java new file mode 100644 index 00000000..86fc8815 --- /dev/null +++ b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/MultiHandlerTest.java @@ -0,0 +1,79 @@ +package io.avaje.jex.http3.flupke; + +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; + +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.close(); + } + + @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-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/QueryParamTest.java b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/QueryParamTest.java new file mode 100644 index 00000000..d205d068 --- /dev/null +++ b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/QueryParamTest.java @@ -0,0 +1,159 @@ +package io.avaje.jex.http3.flupke; + +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; + +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("/plus/{plus}", ctx -> ctx.text(ctx.pathParam("plus")+ctx.queryParam("plus"))) + .get("/scheme", ctx -> ctx.text("scheme: "+ctx.scheme())) + ); + return TestPair.create(app); + } + + @AfterAll + static void end() { + pair.close(); + } + + @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 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") + .queryParam("foo","f1") + .GET().asString(); + assertThat(res.statusCode()).isEqualTo(200); + assertThat(res.body()).isEqualTo("scheme: https"); + } + +} diff --git a/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/TestPair.java b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/TestPair.java new file mode 100644 index 00000000..77f8ada9 --- /dev/null +++ b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/TestPair.java @@ -0,0 +1,68 @@ +package io.avaje.jex.http3.flupke; + +import io.avaje.http.client.HttpClient; +import io.avaje.http.client.HttpClientRequest; +import io.avaje.jex.Jex; +import io.avaje.jex.ssl.SslPlugin; +import tech.kwik.flupke.Http3Client; + +/** Server and Client pair for a test. */ +public class TestPair implements AutoCloseable { + private static final SslPlugin sslPlugin = + SslPlugin.create( + s -> + s.resourceLoader(TestPair.class) + .keystoreFromClasspath("/my-custom-keystore.p12", "password")); + 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(); + client.close(); + } + + 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.plugin(sslPlugin).plugin(FlupkeJexPlugin.create()).port(0).start(); + var port = jexServer.port(); + var url = "https://localhost:" + port; + var client = + HttpClient.builder() + .baseUrl(url) + .client( + Http3Client.newBuilder() + .disableCertificateCheck() + .sslContext(sslPlugin.sslContext()) + .build()) + .build(); + + return new TestPair(port, jexServer, client); + } + + @Override + public void close() { + shutdown(); + } +} diff --git a/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/webtransport/WebTransportTest.java b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/webtransport/WebTransportTest.java new file mode 100644 index 00000000..5a1033aa --- /dev/null +++ b/avaje-jex-http3-flupke/src/test/java/io/avaje/jex/http3/flupke/webtransport/WebTransportTest.java @@ -0,0 +1,504 @@ +package io.avaje.jex.http3.flupke.webtransport; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse.BodyHandlers; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import io.avaje.jex.Jex; +import io.avaje.jex.Jex.Server; +import io.avaje.jex.http3.flupke.FlupkeJexPlugin; +import io.avaje.jex.http3.flupke.webtransport.WebTransportEvent.BiStream; +import io.avaje.jex.ssl.SslPlugin; +import tech.kwik.flupke.Http3Client; +import tech.kwik.flupke.webtransport.ClientSessionFactory; +import tech.kwik.flupke.webtransport.Session; +import tech.kwik.flupke.webtransport.WebTransportStream; + +class WebTransportTest { + + private URI localhost = URI.create("https://localhost:8080/"); + private Server jex; + private static SslPlugin ssl = + SslPlugin.create( + s -> + s.resourceLoader(WebTransportTest.class) + .keystoreFromClasspath("/my-custom-keystore.p12", "password")); + private static Http3Client client = + (Http3Client) + Http3Client.newBuilder().disableCertificateCheck().sslContext(ssl.sslContext()).build(); + + @AfterEach + void teardown() throws InterruptedException { + if (jex != null) { + jex.shutdown(); + } + } + + @Test + void testBasicBidirectionalEcho() throws Exception { + var webTransport = + FlupkeJexPlugin.create().webTransport("/echo", b -> b.onBiDirectionalStream(this::echo)); + + jex = + Jex.create() + .plugin(ssl) + .plugin(webTransport) + .get( + "/", + ctx -> { + assertEquals("h3", ctx.exchange().getProtocol()); + ctx.text("hello world"); + }) + .port(0) + .start(); + localhost = URI.create("https://localhost:%s/".formatted(jex.port())); + // Test regular HTTP/3 endpoint + assertEquals( + "hello world", + client + .send(HttpRequest.newBuilder().uri(localhost).GET().build(), BodyHandlers.ofString()) + .body()); + + // Test WebTransport echo + var clientSessionFactory = + ClientSessionFactory.newBuilder() + .serverUri(localhost.resolve("/echo")) + .httpClient(client) + .build(); + + Session session = clientSessionFactory.createSession(localhost.resolve("/echo")); + session.open(); + Thread.sleep(Duration.ofMillis(500)); + WebTransportStream bidirectionalStream = session.createBidirectionalStream(); + String message = "Hello, WebTransport!"; + bidirectionalStream.getOutputStream().write(message.getBytes()); + bidirectionalStream.getOutputStream().close(); + + ByteArrayOutputStream response = new ByteArrayOutputStream(); + bidirectionalStream.getInputStream().transferTo(response); + + assertEquals(message, response.toString(StandardCharsets.UTF_8)); + + session.close(); + } + + @Test + void testUnidirectionalStream() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference receivedMessage = new AtomicReference<>(); + + var webTransport = + FlupkeJexPlugin.create() + .webTransport( + "/uni", + b -> + b.onUniDirectionalStream( + stream -> { + try (stream) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + stream.requestStream().transferTo(baos); + receivedMessage.set(baos.toString(StandardCharsets.UTF_8)); + latch.countDown(); + } catch (IOException e) { + fail("Failed to read unidirectional stream: " + e.getMessage()); + } + })); + + startServer(webTransport); + + var clientSessionFactory = + ClientSessionFactory.newBuilder() + .serverUri(localhost.resolve("/uni")) + .httpClient(client) + .build(); + + Session session = clientSessionFactory.createSession(localhost.resolve("/uni")); + session.open(); + + Thread.sleep(Duration.ofMillis(500)); + OutputStream uniStream = session.createUnidirectionalStream().getOutputStream(); + String message = "Unidirectional message"; + uniStream.write(message.getBytes()); + uniStream.close(); + + assertTrue(latch.await(5, TimeUnit.SECONDS)); + assertEquals(message, receivedMessage.get()); + + session.close(); + } + + private final void startServer(FlupkeJexPlugin webTransport) { + jex = Jex.create().plugin(ssl).plugin(webTransport).port(0).start(); + localhost = URI.create("https://localhost:%s/".formatted(jex.port())); + } + + @Test + void testServerInitiatedUnidirectionalStream() throws Exception { + CountDownLatch clientLatch = new CountDownLatch(1); + AtomicReference clientReceived = new AtomicReference<>(); + + var webTransport = + FlupkeJexPlugin.create() + .webTransport( + "/server-uni", + b -> + b.onBiDirectionalStream( + stream -> { + try { + // Read client request + ByteArrayOutputStream request = new ByteArrayOutputStream(); + stream.requestStream().transferTo(request); + + // Send response via server-initiated unidirectional stream + OutputStream uniStream = stream.createUnidirectionalStream(); + String response = + "Server says: " + request.toString(StandardCharsets.UTF_8); + uniStream.write(response.getBytes()); + uniStream.close(); + + stream.close(); + } catch (IOException e) { + fail("Server failed: " + e.getMessage()); + } + })); + + startServer(webTransport); + + var clientSessionFactory = + ClientSessionFactory.newBuilder() + .serverUri(localhost.resolve("/server-uni")) + .httpClient(client) + .build(); + + Session session = clientSessionFactory.createSession(localhost.resolve("/server-uni")); + + // Set up handler for server-initiated unidirectional streams + session.setUnidirectionalStreamReceiveHandler( + stream -> { + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + stream.getInputStream().transferTo(baos); + clientReceived.set(baos.toString(StandardCharsets.UTF_8)); + clientLatch.countDown(); + } catch (IOException e) { + fail("Client failed to read: " + e.getMessage()); + } + }); + + session.open(); + + Thread.sleep(Duration.ofMillis(500)); + // Send request + WebTransportStream biStream = session.createBidirectionalStream(); + biStream.getOutputStream().write("Hello".getBytes()); + biStream.getOutputStream().close(); + + assertTrue(clientLatch.await(5, TimeUnit.SECONDS)); + assertEquals("Server says: Hello", clientReceived.get()); + + session.close(); + } + + @Test + void testSessionCloseHandling() throws Exception { + CountDownLatch openLatch = new CountDownLatch(1); + CountDownLatch closeLatch = new CountDownLatch(1); + AtomicLong closeCode = new AtomicLong(-1); + AtomicReference closeMessage = new AtomicReference<>(); + + var webTransport = + FlupkeJexPlugin.create() + .webTransport( + "/close", + b -> + b.onOpen(ctx -> openLatch.countDown()) + .onClose( + ctx -> { + closeCode.set(ctx.code()); + closeMessage.set(ctx.message()); + closeLatch.countDown(); + })); + + startServer(webTransport); + + var clientSessionFactory = + ClientSessionFactory.newBuilder() + .serverUri(localhost.resolve("/close")) + .httpClient(client) + .build(); + + Session session = clientSessionFactory.createSession(localhost.resolve("/close")); + session.open(); + + Thread.sleep(Duration.ofMillis(500)); + assertTrue(openLatch.await(5, TimeUnit.SECONDS)); + + // Close with custom code and message + session.close(42, "Test close"); + + assertTrue(closeLatch.await(5, TimeUnit.SECONDS)); + assertEquals(42, closeCode.get()); + assertEquals("Test close", closeMessage.get()); + } + + @Test + void testServerInitiatedClose() throws Exception { + CountDownLatch clientCloseLatch = new CountDownLatch(1); + AtomicLong clientCloseCode = new AtomicLong(-1); + + var webTransport = + FlupkeJexPlugin.create() + .webTransport( + "/server-close", + b -> + b.onBiDirectionalStream( + stream -> { + try (stream) { + // Read request + stream.requestStream().transferTo(OutputStream.nullOutputStream()); + // Close the session from server side + stream.closeSession(100, "Server initiated close"); + } catch (IOException e) { + // Expected when closing + } + })); + + startServer(webTransport); + + var clientSessionFactory = + ClientSessionFactory.newBuilder() + .serverUri(localhost.resolve("/server-close")) + .httpClient(client) + .build(); + + Session session = clientSessionFactory.createSession(localhost.resolve("/server-close")); + session.registerSessionTerminatedEventListener( + (code, msg) -> { + clientCloseCode.set(code); + clientCloseLatch.countDown(); + }); + + session.open(); + Thread.sleep(Duration.ofMillis(500)); + + WebTransportStream stream = session.createBidirectionalStream(); + stream.getOutputStream().write("trigger close".getBytes()); + stream.getOutputStream().close(); + + assertTrue(clientCloseLatch.await(5, TimeUnit.SECONDS)); + assertEquals(100, clientCloseCode.get()); + } + + @Test + void testMultipleStreamsPerSession() throws Exception { + AtomicInteger streamCount = new AtomicInteger(0); + + var webTransport = + FlupkeJexPlugin.create() + .connectionConfig(b -> b.maxOpenPeerInitiatedBidirectionalStreams(10)) + .webTransport( + "/multi-stream", + b -> + b.onBiDirectionalStream( + stream -> { + streamCount.incrementAndGet(); + this.echo(stream); + })); + + startServer(webTransport); + + var clientSessionFactory = + ClientSessionFactory.newBuilder() + .serverUri(localhost.resolve("/multi-stream")) + .httpClient(client) + .build(); + + Session session = clientSessionFactory.createSession(localhost.resolve("/multi-stream")); + session.open(); + Thread.sleep(Duration.ofMillis(500)); + + int numStreams = 5; + for (int i = 0; i < numStreams; i++) { + WebTransportStream stream = session.createBidirectionalStream(); + String msg = "Stream " + i; + stream.getOutputStream().write(msg.getBytes()); + stream.getOutputStream().close(); + + ByteArrayOutputStream response = new ByteArrayOutputStream(); + stream.getInputStream().transferTo(response); + assertEquals(msg, response.toString(StandardCharsets.UTF_8)); + } + + session.close(); + assertEquals(numStreams, streamCount.get()); + } + + @Test + void testLargeDataTransfer() throws Exception { + var webTransport = + FlupkeJexPlugin.create().webTransport("/large", b -> b.onBiDirectionalStream(this::echo)); + + startServer(webTransport); + + var clientSessionFactory = + ClientSessionFactory.newBuilder() + .serverUri(localhost.resolve("/large")) + .httpClient(client) + .build(); + + Session session = clientSessionFactory.createSession(localhost.resolve("/large")); + session.open(); + + Thread.sleep(Duration.ofMillis(500)); + // Send 1MB of data + byte[] largeData = new byte[1024 * 1024]; + for (int i = 0; i < largeData.length; i++) { + largeData[i] = (byte) (i % 256); + } + + WebTransportStream stream = session.createBidirectionalStream(); + stream.getOutputStream().write(largeData); + stream.getOutputStream().close(); + + ByteArrayOutputStream response = new ByteArrayOutputStream(); + stream.getInputStream().transferTo(response); + byte[] received = response.toByteArray(); + + assertArrayEquals(largeData, received); + + session.close(); + } + + @Test + void testPathRetrieval() throws Exception { + AtomicReference receivedPath = new AtomicReference<>(); + + var webTransport = + FlupkeJexPlugin.create() + .webTransport( + "/test-path", + b -> + b.onBiDirectionalStream( + stream -> { + receivedPath.set(stream.path()); + try (stream) { + stream.requestStream().transferTo(stream.responseStream()); + } catch (IOException e) { + // Ignore + } + })); + + startServer(webTransport); + + var clientSessionFactory = + ClientSessionFactory.newBuilder() + .serverUri(localhost.resolve("/test-path?param=value")) + .httpClient(client) + .build(); + + Session session = + clientSessionFactory.createSession(localhost.resolve("/test-path?param=value")); + session.open(); + Thread.sleep(Duration.ofMillis(500)); + + WebTransportStream stream = session.createBidirectionalStream(); + stream.getOutputStream().write("test".getBytes()); + stream.getOutputStream().close(); + stream.getInputStream().transferTo(OutputStream.nullOutputStream()); + + session.close(); + + assertNotNull(receivedPath.get()); + assertTrue(receivedPath.get().contains("/test-path")); + } + + @Test + void testConcurrentBidirectionalStreams() throws Exception { + AtomicInteger processedStreams = new AtomicInteger(0); + + var webTransport = + FlupkeJexPlugin.create() + .connectionConfig(b -> b.maxOpenPeerInitiatedBidirectionalStreams(20)) + .webTransport( + "/concurrent", + b -> + b.onBiDirectionalStream( + stream -> { + processedStreams.incrementAndGet(); + this.echo(stream); + })); + + startServer(webTransport); + + var clientSessionFactory = + ClientSessionFactory.newBuilder() + .serverUri(localhost.resolve("/concurrent")) + .httpClient(client) + .build(); + + Session session = clientSessionFactory.createSession(localhost.resolve("/concurrent")); + session.open(); + Thread.sleep(Duration.ofMillis(500)); + + int numStreams = 10; + List threads = new ArrayList<>(); + + for (int i = 0; i < numStreams; i++) { + final int index = i; + var thread = + Thread.startVirtualThread( + () -> { + try { + WebTransportStream stream = session.createBidirectionalStream(); + String msg = "Concurrent " + index; + stream.getOutputStream().write(msg.getBytes()); + stream.getOutputStream().close(); + + ByteArrayOutputStream response = new ByteArrayOutputStream(); + stream.getInputStream().transferTo(response); + assertEquals(msg, response.toString(StandardCharsets.UTF_8)); + } catch (IOException e) { + fail("Thread " + index + " failed: " + e.getMessage()); + } + }); + threads.add(thread); + } + + // Wait for all threads + for (var thread : threads) { + thread.join(); + } + + session.close(); + assertEquals(numStreams, processedStreams.get()); + } + + private void echo(BiStream stream) { + try (stream) { + stream.requestStream().transferTo(stream.responseStream()); + } catch (IOException e) { + System.err.println("IO error while processing request: " + e.getMessage()); + } + } +} diff --git a/avaje-jex-http3-flupke/src/test/resources/64KB.json b/avaje-jex-http3-flupke/src/test/resources/64KB.json new file mode 100644 index 00000000..c2a33dc0 --- /dev/null +++ b/avaje-jex-http3-flupke/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-http3-flupke/src/test/resources/my-custom-keystore.p12 b/avaje-jex-http3-flupke/src/test/resources/my-custom-keystore.p12 new file mode 100644 index 00000000..0e33d9ef Binary files /dev/null and b/avaje-jex-http3-flupke/src/test/resources/my-custom-keystore.p12 differ diff --git a/avaje-jex-http3-flupke/src/test/resources/public/index.html b/avaje-jex-http3-flupke/src/test/resources/public/index.html new file mode 100644 index 00000000..41abec16 --- /dev/null +++ b/avaje-jex-http3-flupke/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-http3-flupke/src/test/resources/public/sus.txt b/avaje-jex-http3-flupke/src/test/resources/public/sus.txt new file mode 100644 index 00000000..b20ae04e --- /dev/null +++ b/avaje-jex-http3-flupke/src/test/resources/public/sus.txt @@ -0,0 +1 @@ +ඞ \ No newline at end of file diff --git a/avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/SslPlugin.java b/avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/SslPlugin.java index 595c03d3..6452327d 100644 --- a/avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/SslPlugin.java +++ b/avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/SslPlugin.java @@ -2,7 +2,10 @@ import java.util.function.Consumer; +import javax.net.ssl.SSLContext; + import io.avaje.jex.spi.JexPlugin; +import io.avaje.jex.ssl.core.DSslPlugin; /** * Plugin that Configures Jex with SSL and mTLS. @@ -30,4 +33,7 @@ public sealed interface SslPlugin extends JexPlugin permits DSslPlugin { static SslPlugin create(Consumer consumer) { return new DSslPlugin(consumer); } + + /** The configured SSLContext */ + SSLContext sslContext(); } diff --git a/avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/DSslConfig.java b/avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/core/DSslConfig.java similarity index 89% rename from avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/DSslConfig.java rename to avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/core/DSslConfig.java index 088314b2..ae87712d 100644 --- a/avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/DSslConfig.java +++ b/avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/core/DSslConfig.java @@ -1,4 +1,4 @@ -package io.avaje.jex.ssl; +package io.avaje.jex.ssl.core; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -11,9 +11,10 @@ import java.security.Provider; import java.util.function.Consumer; -import javax.net.ssl.X509ExtendedKeyManager; - import io.avaje.jex.spi.ClassResourceLoader; +import io.avaje.jex.ssl.SslConfig; +import io.avaje.jex.ssl.SslConfigException; +import io.avaje.jex.ssl.TrustConfig; final class DSslConfig implements SslConfig { @@ -21,13 +22,11 @@ final class DSslConfig implements SslConfig { "Both the certificate and key must be provided using the same method"; enum LoadedIdentity { - KEY_MANAGER, KEY_STORE, NONE } private String identityPassword; - private X509ExtendedKeyManager keyManager = null; private KeyStore keyStore = null; private LoadedIdentity loadedIdentity = LoadedIdentity.NONE; private Provider securityProvider = null; @@ -38,10 +37,6 @@ String identityPassword() { return identityPassword; } - X509ExtendedKeyManager keyManager() { - return keyManager; - } - KeyStore keyStore() { return keyStore; } @@ -96,11 +91,14 @@ public void pemFromInputStream( InputStream certificateInputStream, InputStream privateKeyInputStream, String password) { try { var keyContent = new String(privateKeyInputStream.readAllBytes()); - setKeyManager( + + setKeyStore( KeyStoreUtil.loadIdentityFromPem( certificateInputStream, keyContent, password != null ? password.toCharArray() : null)); + this.identityPassword = identityPassword != null ? identityPassword : ""; + } catch (IOException e) { throw new SslConfigException("Failed to read PEM content from streams", e); } @@ -113,9 +111,10 @@ public void pemFromPath(String certificatePath, String privateKeyPath, String pa var keyPath = Paths.get(privateKeyPath); var keyContent = Files.readString(keyPath); - setKeyManager( + setKeyStore( KeyStoreUtil.loadIdentityFromPem( certContent, keyContent, password != null ? password.toCharArray() : null)); + this.identityPassword = identityPassword != null ? identityPassword : ""; } catch (IOException e) { throw new SslConfigException("Failed to read PEM files", e); } @@ -123,11 +122,12 @@ public void pemFromPath(String certificatePath, String privateKeyPath, String pa @Override public void pemFromString(String certificateString, String privateKeyString, String password) { - setKeyManager( + setKeyStore( KeyStoreUtil.loadIdentityFromPem( new ByteArrayInputStream(certificateString.getBytes(StandardCharsets.UTF_8)), privateKeyString, password != null ? password.toCharArray() : null)); + this.identityPassword = identityPassword != null ? identityPassword : ""; } @Override @@ -145,16 +145,6 @@ public void securityProvider(Provider securityProvider) { this.securityProvider = securityProvider; } - private void setKeyManager(X509ExtendedKeyManager keyManager) { - if (loadedIdentity != LoadedIdentity.NONE) { - throw new SslConfigException(MULTIPLE_IDENTITY); - } - if (keyManager != null) { - loadedIdentity = LoadedIdentity.KEY_MANAGER; - this.keyManager = keyManager; - } - } - private void setKeyStore(KeyStore keyStore) { if (loadedIdentity != LoadedIdentity.NONE) { throw new SslConfigException(MULTIPLE_IDENTITY); diff --git a/avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/DSslPlugin.java b/avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/core/DSslPlugin.java similarity index 54% rename from avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/DSslPlugin.java rename to avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/core/DSslPlugin.java index 10c8253f..d90f363e 100644 --- a/avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/DSslPlugin.java +++ b/avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/core/DSslPlugin.java @@ -1,16 +1,20 @@ -package io.avaje.jex.ssl; +package io.avaje.jex.ssl.core; import java.util.function.Consumer; +import javax.net.ssl.SSLContext; + import com.sun.net.httpserver.HttpsConfigurator; import io.avaje.jex.Jex; +import io.avaje.jex.ssl.SslConfig; +import io.avaje.jex.ssl.SslPlugin; -final class DSslPlugin implements SslPlugin { +public final class DSslPlugin implements SslPlugin { private final HttpsConfigurator sslConfigurator; - DSslPlugin(Consumer consumer) { + public DSslPlugin(Consumer consumer) { final var config = new DSslConfig(); consumer.accept(config); this.sslConfigurator = SSLConfigurator.create(config); @@ -20,4 +24,9 @@ final class DSslPlugin implements SslPlugin { public void apply(Jex jex) { jex.config().httpsConfig(sslConfigurator); } + + @Override + public SSLContext sslContext() { + return sslConfigurator.getSSLContext(); + } } diff --git a/avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/DTrustConfig.java b/avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/core/DTrustConfig.java similarity index 95% rename from avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/DTrustConfig.java rename to avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/core/DTrustConfig.java index 644a361b..933249de 100644 --- a/avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/DTrustConfig.java +++ b/avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/core/DTrustConfig.java @@ -1,4 +1,4 @@ -package io.avaje.jex.ssl; +package io.avaje.jex.ssl.core; import java.io.InputStream; import java.nio.file.Files; @@ -9,6 +9,9 @@ import java.util.List; import java.util.function.Function; +import io.avaje.jex.ssl.SslConfigException; +import io.avaje.jex.ssl.TrustConfig; + final class DTrustConfig implements TrustConfig { private final List certificates = new ArrayList<>(); diff --git a/avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/KeyStoreUtil.java b/avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/core/KeyStoreUtil.java similarity index 87% rename from avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/KeyStoreUtil.java rename to avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/core/KeyStoreUtil.java index 2fe32d6e..a9210782 100644 --- a/avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/KeyStoreUtil.java +++ b/avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/core/KeyStoreUtil.java @@ -1,4 +1,4 @@ -package io.avaje.jex.ssl; +package io.avaje.jex.ssl.core; import static java.util.Base64.getDecoder; @@ -11,7 +11,6 @@ import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; -import java.security.UnrecoverableKeyException; import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; @@ -22,8 +21,7 @@ import java.util.List; import java.util.regex.Pattern; -import javax.net.ssl.KeyManagerFactory; -import javax.net.ssl.X509ExtendedKeyManager; +import io.avaje.jex.ssl.SslConfigException; final class KeyStoreUtil { private static final Pattern CERT_PATTERN = @@ -59,7 +57,8 @@ static KeyStore loadKeyStore(InputStream inputStream, char[] password) { return keyStore; } - throw new SslConfigException("Unable to load KeyStore - format not recognized or invalid password"); + throw new SslConfigException( + "Unable to load KeyStore - format not recognized or invalid password"); } private static KeyStore tryLoadKeyStore(byte[] data, String type, char[] password) { @@ -73,7 +72,7 @@ private static KeyStore tryLoadKeyStore(byte[] data, String type, char[] passwor } } - static X509ExtendedKeyManager loadIdentityFromPem( + static KeyStore loadIdentityFromPem( InputStream certificateInputStream, String privateKeyContent, char[] password) { try { var certificates = parseCertificates(certificateInputStream); @@ -86,22 +85,8 @@ static X509ExtendedKeyManager loadIdentityFromPem( var keyPassword = password != null ? password : new char[0]; keyStore.setKeyEntry(alias, privateKey, keyPassword, certChain); - var kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); - kmf.init(keyStore, keyPassword); - - for (var km : kmf.getKeyManagers()) { - if (km instanceof X509ExtendedKeyManager m) { - return m; - } - } - - throw new SslConfigException("No X509ExtendedKeyManager found"); - - } catch (KeyStoreException - | NoSuchAlgorithmException - | UnrecoverableKeyException - | CertificateException - | IOException e) { + return keyStore; + } catch (KeyStoreException | NoSuchAlgorithmException | CertificateException | IOException e) { throw new SslConfigException("Failed to create KeyManager from PEM content", e); } } diff --git a/avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/SSLConfigurator.java b/avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/core/SSLConfigurator.java similarity index 85% rename from avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/SSLConfigurator.java rename to avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/core/SSLConfigurator.java index bcb3e921..201da761 100644 --- a/avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/SSLConfigurator.java +++ b/avaje-jex-ssl/src/main/java/io/avaje/jex/ssl/core/SSLConfigurator.java @@ -1,4 +1,4 @@ -package io.avaje.jex.ssl; +package io.avaje.jex.ssl.core; import java.security.KeyStore; import java.security.KeyStoreException; @@ -17,16 +17,22 @@ import com.sun.net.httpserver.HttpsConfigurator; import com.sun.net.httpserver.HttpsParameters; -final class SSLConfigurator extends HttpsConfigurator { +import io.avaje.jex.ssl.SslConfigException; + +public final class SSLConfigurator extends HttpsConfigurator { private static final String SSL_PROTOCOL = "TLSv1.3"; private static final String KEY_MANAGER_ALGORITHM = "SunX509"; private static final String TRUST_MANAGER_ALGORITHM = "PKIX"; private final boolean clientAuth; + private final KeyStore keyStore; + private final String password; - SSLConfigurator(SSLContext context, boolean clientAuth) { + SSLConfigurator(SSLContext context, DSslConfig sslConfig, boolean clientAuth) { super(context); + this.keyStore = sslConfig.keyStore(); + this.password = sslConfig.identityPassword(); this.clientAuth = clientAuth; } @@ -42,10 +48,8 @@ static SSLConfigurator create(DSslConfig sslConfig) throws SslConfigException { var sslContext = createContext(sslConfig); var keyManagers = createKeyManagers(sslConfig); var trustManagers = createTrustManagers(sslConfig); - sslContext.init(keyManagers, trustManagers, new SecureRandom()); - - return new SSLConfigurator(sslContext, trustManagers != null); + return new SSLConfigurator(sslContext, sslConfig, trustManagers !=null); } catch (Exception e) { throw new SslConfigException("Failed to build SSLContext", e); } @@ -61,7 +65,6 @@ private static SSLContext createContext(DSslConfig sslConfig) throws NoSuchAlgor private static KeyManager[] createKeyManagers(DSslConfig sslConfig) throws SslConfigException { try { return switch (sslConfig.loadedIdentity()) { - case KEY_MANAGER -> new KeyManager[] {sslConfig.keyManager()}; case KEY_STORE -> createKeyManagersFromKeyStore(sslConfig); default -> throw new IllegalStateException("No SSL Identity provided"); }; @@ -79,14 +82,16 @@ private static KeyManager[] createKeyManagersFromKeyStore(DSslConfig sslConfig) return keyManagerFactory.getKeyManagers(); } - private static KeyManagerFactory createKeyManagerFactory(DSslConfig sslConfig) throws NoSuchAlgorithmException { + private static KeyManagerFactory createKeyManagerFactory(DSslConfig sslConfig) + throws NoSuchAlgorithmException { if (sslConfig.securityProvider() != null) { return KeyManagerFactory.getInstance(KEY_MANAGER_ALGORITHM, sslConfig.securityProvider()); } return KeyManagerFactory.getInstance(KEY_MANAGER_ALGORITHM); } - private static TrustManager[] createTrustManagers(DSslConfig sslConfig) throws SslConfigException { + private static TrustManager[] createTrustManagers(DSslConfig sslConfig) + throws SslConfigException { var trustConfig = sslConfig.trustConfig(); if (trustConfig == null) { return null; // Use system default trust managers @@ -109,14 +114,16 @@ private static TrustManager[] createTrustManagers(DSslConfig sslConfig) throws S } } - private static TrustManagerFactory createTrustManagerFactory(DSslConfig sslConfig) throws NoSuchAlgorithmException { + private static TrustManagerFactory createTrustManagerFactory(DSslConfig sslConfig) + throws NoSuchAlgorithmException { if (sslConfig.securityProvider() != null) { return TrustManagerFactory.getInstance(TRUST_MANAGER_ALGORITHM, sslConfig.securityProvider()); } return TrustManagerFactory.getInstance(TRUST_MANAGER_ALGORITHM); } - private static KeyStore createCombinedTrustStore(List trustStores, List certificates) throws Exception { + private static KeyStore createCombinedTrustStore( + List trustStores, List certificates) throws Exception { var combinedTrustStore = KeyStore.getInstance(KeyStore.getDefaultType()); combinedTrustStore.load(null, null); @@ -148,4 +155,12 @@ private static int addCertificatesFromKeyStore( } return aliasCounter; } + + public KeyStore keyStore() { + return keyStore; + } + + public String password() { + return password; + } } diff --git a/avaje-jex-ssl/src/main/java/module-info.java b/avaje-jex-ssl/src/main/java/module-info.java index 92287789..5f3cf682 100644 --- a/avaje-jex-ssl/src/main/java/module-info.java +++ b/avaje-jex-ssl/src/main/java/module-info.java @@ -17,6 +17,7 @@ module io.avaje.jex.ssl { exports io.avaje.jex.ssl; + exports io.avaje.jex.ssl.core to io.avaje.jex.http3.flupke; requires transitive io.avaje.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 index 239c6b3c..cf192a41 100644 --- a/avaje-jex/src/main/java/io/avaje/jex/core/BootstrapServer.java +++ b/avaje-jex/src/main/java/io/avaje/jex/core/BootstrapServer.java @@ -61,15 +61,20 @@ public static Jex.Server start(Jex jex) { server.setExecutor(config.executor()); } - server.createContext(contextPath, handler); + var protocol = + server + .createContext(contextPath, handler) + .getAttributes() + .getOrDefault("protocol", "TCP"); server.start(); var actualAddress = server.getAddress(); jex.lifecycle().status(AppLifecycle.Status.STARTED); log.log( INFO, - "Avaje Jex started {0} in {1}ms on {2}://{3}:{4,number,#}", + "Avaje Jex started {0} in {1}ms on {2} {3}://{4}:{5,number,#}", serverClass, System.currentTimeMillis() - startTime, + protocol, scheme, actualAddress.getHostName(), actualAddress.getPort()); diff --git a/avaje-jex/src/main/java/module-info.java b/avaje-jex/src/main/java/module-info.java index 81ed7a2e..af75673a 100644 --- a/avaje-jex/src/main/java/module-info.java +++ b/avaje-jex/src/main/java/module-info.java @@ -23,7 +23,7 @@ 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 to io.avaje.jex.staticcontent, io.avaje.jex.http3.flupke; exports io.avaje.jex.core.json; exports io.avaje.jex.security; exports io.avaje.jex.spi; diff --git a/pom.xml b/pom.xml index 516687d8..7efb4108 100644 --- a/pom.xml +++ b/pom.xml @@ -48,6 +48,7 @@ avaje-jex-static-content avaje-jex-test avaje-jex-ssl + avaje-jex-http3-flupke