preparing for a conceptual refactoring...

This commit is contained in:
Relism
2026-03-28 14:11:12 +01:00
parent 7b996b552b
commit 2edd68b0aa
60 changed files with 3432 additions and 518 deletions
@@ -1,5 +1,7 @@
package dev.relism;
import dev.relism.extension.FlashApp;
import dev.relism.extension.FlashConfiguration;
import dev.relism.http.ContentType;
import dev.relism.models.Response;
import org.junit.jupiter.api.AfterEach;
@@ -23,7 +25,7 @@ import static org.junit.jupiter.api.Assertions.*;
class HttpServerConcurrencyTest {
private HttpServer server;
private FlashApp app;
private int port;
private HttpClient httpClient;
@@ -32,19 +34,19 @@ class HttpServerConcurrencyTest {
try (ServerSocket s = new ServerSocket(0)) {
port = s.getLocalPort();
}
HttpServerConfiguration config = HttpServerConfiguration.builder()
app = FlashApp.create(FlashConfiguration.builder()
.port(port)
.host("127.0.0.1")
.build();
.build());
server = new HttpServer(config);
server.get("/ping", (req, res) -> "pong").with();
server.post("/echo", (req, res) -> {
app.get("/ping", (req, res) -> "pong");
app.post("/echo", (req, res) -> {
byte[] body = req.body().bytes();
return new Response(200, body, ContentType.TEXT_PLAIN);
}).with();
});
server.start().get(5, TimeUnit.SECONDS);
app.start().get(5, TimeUnit.SECONDS);
httpClient = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
@@ -53,8 +55,7 @@ class HttpServerConcurrencyTest {
@AfterEach
void tearDown() {
if (server != null)
server.stop();
if (app != null) app.stop();
}
// --- helpers ---
@@ -91,7 +92,6 @@ class HttpServerConcurrencyTest {
CountDownLatch start = new CountDownLatch(1);
AtomicInteger successes = new AtomicInteger();
List<Throwable> errors = new CopyOnWriteArrayList<>();
List<String> responses = new CopyOnWriteArrayList<>();
for (int i = 0; i < count; i++) {
@@ -100,11 +100,9 @@ class HttpServerConcurrencyTest {
try {
start.await();
HttpResponse<String> res = get("/ping");
String summary = res.statusCode() + "|" + res.body();
responses.add(summary);
if (res.statusCode() == 200 && "pong".equals(res.body())) {
responses.add(res.statusCode() + "|" + res.body());
if (res.statusCode() == 200 && "pong".equals(res.body()))
successes.incrementAndGet();
}
} catch (Exception e) {
errors.add(e);
}
@@ -169,7 +167,7 @@ class HttpServerConcurrencyTest {
}
/**
* Races the router's lazy-compile step: a fresh server with 10 registered routes
* Races the router's lazy-compile step: a fresh app with 10 registered routes
* is hit by threads simultaneously before any request has been processed,
* causing multiple threads to compete on the first compilation.
*/
@@ -180,17 +178,17 @@ class HttpServerConcurrencyTest {
freshPort = s.getLocalPort();
}
HttpServer freshServer = new HttpServer(HttpServerConfiguration.builder()
FlashApp freshApp = FlashApp.create(FlashConfiguration.builder()
.port(freshPort)
.host("127.0.0.1")
.build());
for (int i = 0; i < 10; i++) {
final int idx = i;
freshServer.get("/route" + idx, (req, res) -> "handler" + idx).with();
freshApp.get("/route" + idx, (req, res) -> "handler" + idx);
}
freshServer.start().get(5, TimeUnit.SECONDS);
freshApp.start().get(5, TimeUnit.SECONDS);
int count = 20;
ExecutorService pool = Executors.newFixedThreadPool(count);
@@ -206,9 +204,8 @@ class HttpServerConcurrencyTest {
try {
start.await();
HttpResponse<String> res = get(freshPort, "/route" + routeIdx);
if (res.statusCode() == 200 && ("handler" + routeIdx).equals(res.body())) {
if (res.statusCode() == 200 && ("handler" + routeIdx).equals(res.body()))
successes.incrementAndGet();
}
} catch (Exception e) {
errors.add(e);
}
@@ -224,7 +221,7 @@ class HttpServerConcurrencyTest {
assertTrue(errors.isEmpty(), () -> "Unexpected errors: " + errors);
assertEquals(count, successes.get());
} finally {
freshServer.stop();
freshApp.stop();
}
}
}
@@ -1,5 +1,7 @@
package dev.relism;
import dev.relism.extension.FlashApp;
import dev.relism.extension.FlashConfiguration;
import dev.relism.http.ContentType;
import dev.relism.models.Response;
import org.junit.jupiter.api.AfterEach;
@@ -19,58 +21,52 @@ import static org.junit.jupiter.api.Assertions.*;
class HttpServerTest {
private HttpServer server;
private FlashApp app;
private int port;
@BeforeEach
void setUp() throws Exception {
// Find a free ephemeral port
try (ServerSocket s = new ServerSocket(0)) {
port = s.getLocalPort();
}
HttpServerConfiguration config = HttpServerConfiguration.builder()
app = FlashApp.create(FlashConfiguration.builder()
.port(port)
.host("127.0.0.1")
.build();
.build());
server = new HttpServer(config);
// Setup some routes
server.get("/api/ping", (req, res) -> "pong").with();
app.get("/api/ping", (req, res) -> "pong");
server.post("/api/echo", (req, res) -> {
app.post("/api/echo", (req, res) -> {
byte[] body = req.body().bytes();
return res.status(201).body(body); // echo body & change status
}).with();
return res.status(201).body(body);
});
server.get("/api/crash", (req, res) -> {
app.get("/api/crash", (req, res) -> {
throw new RuntimeException("Simulated Crash");
}).with();
});
byte[] streamData = "streaming response body".getBytes(StandardCharsets.UTF_8);
server.get("/api/stream", (req, res) ->
res.stream(new ByteArrayInputStream(streamData), streamData.length)).with();
app.get("/api/stream", (req, res) ->
res.stream(new ByteArrayInputStream(streamData), streamData.length));
server.get("/api/chunked-out", (req, res) ->
res.chunked(new ByteArrayInputStream(streamData))).with();
app.get("/api/chunked-out", (req, res) ->
res.chunked(new ByteArrayInputStream(streamData)));
server.get("/api/custom-header", (req, res) ->
res.body("ok").header("X-Flash", "works")).with();
app.get("/api/custom-header", (req, res) ->
res.body("ok").header("X-Flash", "works"));
server.post("/api/chunked-in", (req, res) -> {
app.post("/api/chunked-in", (req, res) -> {
byte[] body = req.body().bytes();
return res.body(body);
}).with();
});
server.start().get(5, TimeUnit.SECONDS);
app.start().get(5, TimeUnit.SECONDS);
}
@AfterEach
void tearDown() {
if (server != null) {
server.stop();
}
if (app != null) app.stop();
}
// --- helpers ---
@@ -125,7 +121,7 @@ class HttpServerTest {
int size = Integer.parseInt(sizeLine.toString().trim(), 16);
if (size == 0) break;
result.write(in.readNBytes(size));
in.read(); in.read(); // \r\n after chunk data
in.read(); in.read();
}
return result.toString(StandardCharsets.UTF_8);
}
@@ -134,28 +130,18 @@ class HttpServerTest {
@Test
void testGet_pingRoute_returns200AndStringBody() throws Exception {
String req = "GET /api/ping HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"\r\n";
String req = "GET /api/ping HTTP/1.1\r\nHost: localhost\r\n\r\n";
String res = sendRawRequest(req);
assertTrue(res.startsWith("HTTP/1.1 200 OK"));
assertTrue(res.contains("Content-Length: 4")); // "pong"
assertTrue(res.contains("Content-Length: 4"));
assertTrue(res.endsWith("pong"));
}
@Test
void testPost_echoRoute_returns201AndEchoesBody() throws Exception {
String body = "Hello, Flash!";
String req = "POST /api/echo HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"Content-Length: " + body.length() + "\r\n" +
"\r\n" +
body;
String req = "POST /api/echo HTTP/1.1\r\nHost: localhost\r\nContent-Length: " + body.length() + "\r\n\r\n" + body;
String res = sendRawRequest(req);
assertTrue(res.startsWith("HTTP/1.1 201 Created"));
assertTrue(res.contains("Content-Length: " + body.length()));
assertTrue(res.endsWith(body));
@@ -163,12 +149,8 @@ class HttpServerTest {
@Test
void testNotFound_returns404Html() throws Exception {
String req = "GET /api/unknown HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"\r\n";
String req = "GET /api/unknown HTTP/1.1\r\nHost: localhost\r\n\r\n";
String res = sendRawRequest(req);
assertTrue(res.startsWith("HTTP/1.1 404 Not Found"));
assertTrue(res.contains("404"));
assertTrue(res.contains("No route matched this request"));
@@ -176,12 +158,8 @@ class HttpServerTest {
@Test
void testException_returns500Html() throws Exception {
String req = "GET /api/crash HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"\r\n";
String req = "GET /api/crash HTTP/1.1\r\nHost: localhost\r\n\r\n";
String res = sendRawRequest(req);
assertTrue(res.startsWith("HTTP/1.1 500 Internal Server Error"));
assertTrue(res.contains("500"));
assertTrue(res.contains("Simulated Crash"));
@@ -189,25 +167,18 @@ class HttpServerTest {
@Test
void testRoot_returns404_noExceptionTossed() throws Exception {
String req = "GET / HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"\r\n";
String req = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n";
String res = sendRawRequest(req);
assertTrue(res.startsWith("HTTP/1.1 404 Not Found"));
assertTrue(res.contains("No route matched this request."));
}
// --- streaming response ---
@Test
void testStreamingResponse_writesContentLengthAndBody() throws Exception {
String req = "GET /api/stream HTTP/1.1\r\nHost: localhost\r\n\r\n";
String res = sendRawRequest(req);
assertTrue(res.startsWith("HTTP/1.1 200 OK"));
assertTrue(res.contains("Content-Length: 23")); // "streaming response body"
assertTrue(res.contains("Content-Length: 23"));
assertTrue(res.endsWith("streaming response body"));
}
@@ -215,41 +186,28 @@ class HttpServerTest {
void testChunkedResponse_writesTransferEncodingChunked() throws Exception {
String req = "GET /api/chunked-out HTTP/1.1\r\nHost: localhost\r\n\r\n";
String res = sendRawRequest(req);
assertTrue(res.startsWith("HTTP/1.1 200 OK"));
assertTrue(res.contains("Transfer-Encoding: chunked"));
assertTrue(res.endsWith("streaming response body"));
}
// --- custom response headers ---
@Test
void testCustomHeader_appearsInResponse() throws Exception {
String req = "GET /api/custom-header HTTP/1.1\r\nHost: localhost\r\n\r\n";
String res = sendRawRequest(req);
assertTrue(res.startsWith("HTTP/1.1 200 OK"));
assertTrue(res.contains("X-Flash: works\r\n"));
}
// --- chunked request body ---
@Test
void testChunkedRequestBody_decodedAndEchoed() throws Exception {
String req = "POST /api/chunked-in HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"Transfer-Encoding: chunked\r\n" +
"\r\n" +
"5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n";
String req = "POST /api/chunked-in HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n"
+ "5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n";
String res = sendRawRequest(req);
assertTrue(res.startsWith("HTTP/1.1 200 OK"));
assertTrue(res.endsWith("hello world"));
}
// --- keep-alive ---
@Test
void testKeepAlive_twoRequestsOnSameConnection() throws Exception {
String req = "GET /api/ping HTTP/1.1\r\nHost: localhost\r\n\r\n";
@@ -257,7 +215,6 @@ class HttpServerTest {
try (Socket socket = new Socket("127.0.0.1", port);
OutputStream out = socket.getOutputStream();
InputStream in = socket.getInputStream()) {
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
out.write(req.getBytes(StandardCharsets.UTF_8));
out.write(req.getBytes(StandardCharsets.UTF_8));
@@ -14,84 +14,72 @@ import static org.junit.jupiter.api.Assertions.*;
class AbstractRouterTest {
// A dummy router for testing base functionality
// A minimal concrete router for testing base-class functionality
static class DummyRouter extends AbstractRouter {
RequestHandler lastAddedHandler;
HttpMethod lastAddedMethod;
String lastAddedPath;
HttpMethod lastAddedMethod;
String lastAddedPath;
@Override
public RequestHandler route(Request request) {
return null; // Not testing routing logic here
return null;
}
@Override
protected AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler) {
this.lastAddedMethod = method;
this.lastAddedPath = path;
this.lastAddedMethod = method;
this.lastAddedPath = path;
this.lastAddedHandler = handler;
return this;
}
}
@Route(method = dev.relism.http.HttpMethod.POST, path = "/profile")
static class ProfileHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
static class UnannotatedHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
// --- namespace ---
@Test
void setNamespace_updatesStringAndBytes() {
DummyRouter router = new DummyRouter();
assertEquals("/", router.getNamespace());
router.setNamespace("/api");
assertEquals("/api", router.getNamespace());
assertArrayEquals("/api".getBytes(StandardCharsets.UTF_8), router.getNamespaceBytes());
}
// --- helpers ---
// --- doRegister (infrastructure method used by FlashApp/FlashScope) ---
@Test
void getPostPutDelete_delegatesToAddRouteWithSanitizedPath() {
void doRegister_lambda_sanitizesPathAndWrapsHandler() {
DummyRouter router = new DummyRouter();
SimpleHandler.FunctionalHandler func = (req, res) -> "OK";
router.get("users/", func).with();
router.doRegister(HttpMethod.GET, "users/", func, new Middleware[0]);
assertEquals(HttpMethod.GET, router.lastAddedMethod);
assertEquals("/users", router.lastAddedPath);
assertTrue(router.lastAddedHandler instanceof SimpleHandler);
assertNotNull(router.lastAddedHandler);
router.post("/items", func).with();
assertEquals(HttpMethod.POST, router.lastAddedMethod);
router.put("update", func).with();
assertEquals(HttpMethod.PUT, router.lastAddedMethod);
router.delete("//delete//", func).with();
router.doRegister(HttpMethod.DELETE, "//delete//", func, new Middleware[0]);
assertEquals(HttpMethod.DELETE, router.lastAddedMethod);
assertEquals("/delete", router.lastAddedPath);
}
// --- register ---
@Route(method = HttpMethod.POST, path = "/profile")
static class ProfileHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) { return null; }
}
static class UnannotatedHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) { return null; }
}
@Test
void register_annotatedHandler_addsRoute() {
void doRegister_annotatedHandler_addsRoute() {
DummyRouter router = new DummyRouter();
ProfileHandler handler = new ProfileHandler();
router.register(handler).with();
router.doRegister(handler, new Middleware[0]);
assertEquals(HttpMethod.POST, router.lastAddedMethod);
assertEquals("/profile", router.lastAddedPath);
@@ -99,24 +87,22 @@ class AbstractRouterTest {
}
@Test
void register_unannotatedHandler_doesNothing() {
void doRegister_unannotatedHandler_doesNothing() {
DummyRouter router = new DummyRouter();
router.register(new UnannotatedHandler()).with();
assertNull(router.lastAddedMethod); // Nothing added
router.doRegister(new UnannotatedHandler(), new Middleware[0]);
assertNull(router.lastAddedMethod);
}
// --- default handlers ---
// --- error handlers ---
@Test
void defaultNotFoundHandler_returns404Html() throws Exception {
DummyRouter router = new DummyRouter();
Response res = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
assertNotNull(router.getNotFoundHandler());
SimpleHandler.FunctionalHandler custom = (req, resp) -> "Custom 404";
router.onNotFound(custom);
router.onNotFound((req, resp) -> "Custom 404");
assertEquals("Custom 404", router.getNotFoundHandler().handle(null, res));
}
@@ -125,9 +111,7 @@ class AbstractRouterTest {
DummyRouter router = new DummyRouter();
assertNotNull(router.getExceptionHandler());
AbstractRouter.ExceptionHandler custom = (ex, req, res) -> "Caught";
router.onException(custom);
router.onException((ex, req, res) -> "Caught");
assertEquals("Caught", router.getExceptionHandler().handle(new RuntimeException(), null, null));
}
}
@@ -6,7 +6,6 @@ import dev.relism.models.HeaderMap;
import dev.relism.models.Request;
import dev.relism.models.RequestHandler;
import dev.relism.models.RequestLine;
import dev.relism.models.Response;
import dev.relism.models.SimpleHandler;
import dev.relism.routing.routers.fastpathrouter.FastPathViews;
import org.junit.jupiter.api.Test;
@@ -40,7 +39,6 @@ class GlobalRouterTest {
private Request mockRequest(String path) {
byte[] bytes = path.getBytes(StandardCharsets.UTF_8);
FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(bytes, 0, bytes.length);
RequestLine line = new RequestLine(
HttpMethod.GET, pathView, null,
new FastPathViews.RequestByteView("HTTP/1.1".getBytes(StandardCharsets.UTF_8), 0, 8),
@@ -54,39 +52,30 @@ class GlobalRouterTest {
@Test
void route_delegatesToSubRouterBasedOnLongestPrefix() {
GlobalRouter global = new GlobalRouter();
RequestHandler hApi = new SimpleHandler((req, res) -> "api");
RequestHandler hApi = new SimpleHandler((req, res) -> "api");
RequestHandler hApiV1 = new SimpleHandler((req, res) -> "apiv1");
global.mount("/api", new MockSubRouter(hApi));
global.mount("/api/v1", new MockSubRouter(hApiV1)); // longer prefix
// Path matches /api/v1 -> Should pick hApiV1 because it's longer and sorted first
RequestHandler resolved = global.route(mockRequest("/api/v1/users"));
assertEquals(hApiV1, resolved);
global.mount("/api", new MockSubRouter(hApi));
global.mount("/api/v1", new MockSubRouter(hApiV1));
// Path matches /api but not /api/v1
RequestHandler resolved2 = global.route(mockRequest("/api/v2/users"));
assertEquals(hApi, resolved2);
assertEquals(hApiV1, global.route(mockRequest("/api/v1/users")));
assertEquals(hApi, global.route(mockRequest("/api/v2/users")));
}
@Test
void route_fallsBackToInternalRouter() throws Exception {
GlobalRouter global = new GlobalRouter();
RequestHandler internalHandler = new SimpleHandler((req, res) -> "internal");
global.get("/hello", (req, res) -> "internal").with();
// We know it routes to internal. Let's send a request.
global.doRegister(HttpMethod.GET, "/hello", (req, res) -> "internal", new Middleware[0]);
RequestHandler resolved = global.route(mockRequest("/hello"));
assertNotNull(resolved);
// It's the compiled FastPathRouter handler, let's verify it works
assertEquals("internal", resolved.handle(null, null));
}
@Test
void route_noMatch_returnsNotFoundHandler() {
GlobalRouter global = new GlobalRouter();
// Nothing registered. Should return the global notFoundHandler.
RequestHandler resolved = global.route(mockRequest("/unknown"));
assertEquals(global.getNotFoundHandler(), resolved);
}
@@ -102,10 +91,7 @@ class GlobalRouterTest {
global.mount("/api", sub);
// Under sub-namespace
assertEquals(customSubHandler, global.resolveExceptionHandler(mockRequest("/api/fail")));
// Outside sub-namespace (global)
assertEquals(global.getExceptionHandler(), global.resolveExceptionHandler(mockRequest("/other")));
}
}
@@ -5,7 +5,7 @@ import dev.relism.models.HeaderMap;
import dev.relism.models.Request;
import dev.relism.models.RequestHandler;
import dev.relism.models.RequestLine;
import dev.relism.models.SimpleHandler;
import dev.relism.routing.Middleware;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
@@ -14,12 +14,13 @@ import static org.junit.jupiter.api.Assertions.*;
class FastPathRouterImplTest {
private static final Middleware[] NO_MIDDLEWARE = new Middleware[0];
// --- helpers ---
private Request mockRequest(HttpMethod method, String path) {
byte[] bytes = path.getBytes(StandardCharsets.UTF_8);
FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(bytes, 0, bytes.length);
RequestLine line = new RequestLine(
method, pathView, null,
new FastPathViews.RequestByteView("HTTP/1.1".getBytes(StandardCharsets.UTF_8), 0, 8),
@@ -33,9 +34,9 @@ class FastPathRouterImplTest {
@Test
void route_lazyCompilationAndMatch() throws Exception {
FastPathRouterImpl router = new FastPathRouterImpl();
router.get("/a", (req, res) -> "A").with();
router.post("/b", (req, res) -> "B").with();
router.doRegister(HttpMethod.GET, "/a", (req, res) -> "A", NO_MIDDLEWARE);
router.doRegister(HttpMethod.POST, "/b", (req, res) -> "B", NO_MIDDLEWARE);
RequestHandler res1 = router.route(mockRequest(HttpMethod.GET, "/a"));
assertNotNull(res1);
@@ -49,26 +50,23 @@ class FastPathRouterImplTest {
@Test
void route_noMatch_returnsNull() {
FastPathRouterImpl router = new FastPathRouterImpl();
router.get("/a", (req, res) -> "A").with();
router.doRegister(HttpMethod.GET, "/a", (req, res) -> "A", NO_MIDDLEWARE);
assertNull(router.route(mockRequest(HttpMethod.GET, "/b")));
// Wrong method
assertNull(router.route(mockRequest(HttpMethod.POST, "/a")));
}
@Test
void route_extractsPathParams() throws Exception {
FastPathRouterImpl router = new FastPathRouterImpl();
router.get("/users/{id}/items/{itemId}", (req, res) -> "Extract").with();
router.doRegister(HttpMethod.GET, "/users/{id}/items/{itemId}",
(req, res) -> "Extract", NO_MIDDLEWARE);
Request request = mockRequest(HttpMethod.GET, "/users/123/items/456");
RequestHandler handler = router.route(request);
assertNotNull(handler);
assertEquals("Extract", handler.handle(request, null));
// Verify path params were injected
assertNotNull(request.getPathParams());
assertEquals("123", request.param("id"));
assertEquals("456", request.param("itemId"));