enhanced HTTP server configuration and response handling; added acceptorThreads, improved header management, and refined error page titles
This commit is contained in:
@@ -39,8 +39,10 @@ public class HttpServer {
|
||||
private static final byte[] CRLF = "\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONTENT_TYPE = "Content-Type: ".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONTENT_LENGTH = "Content-Length: ".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONNECTION_CLOSE = "Connection: close\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONNECTION_CLOSE = "Connection: close\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONNECTION_KEEPALIVE = "Connection: keep-alive\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] TRANSFER_CHUNKED = "Transfer-Encoding: chunked\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] FINAL_CHUNK = "0\r\n\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
private static final byte[] UNKNOWN_STATUS_SUFFIX = " Unknown".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
@@ -122,30 +124,17 @@ public class HttpServer {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a route on the internal router. The handler return value drives the response:
|
||||
* return a {@link dev.relism.models.Response} to replace it entirely, any other non-null
|
||||
* value to set it as the body, or {@code null} to leave the response object unchanged.
|
||||
*/
|
||||
public HttpServer get(String path, SimpleHandler.FunctionalHandler h) {
|
||||
globalRouter.get(path, h);
|
||||
return this;
|
||||
}
|
||||
public HttpServer get(String path, SimpleHandler.FunctionalHandler h) { globalRouter.get(path, h); return this; }
|
||||
public HttpServer post(String path, SimpleHandler.FunctionalHandler h) { globalRouter.post(path, h); return this; }
|
||||
public HttpServer put(String path, SimpleHandler.FunctionalHandler h) { globalRouter.put(path, h); return this; }
|
||||
public HttpServer delete(String path, SimpleHandler.FunctionalHandler h) { globalRouter.delete(path, h); return this; }
|
||||
public HttpServer patch(String path, SimpleHandler.FunctionalHandler h) { globalRouter.patch(path, h); return this; }
|
||||
public HttpServer options(String path, SimpleHandler.FunctionalHandler h) { globalRouter.options(path, h); return this; }
|
||||
public HttpServer head(String path, SimpleHandler.FunctionalHandler h) { globalRouter.head(path, h); return this; }
|
||||
public HttpServer trace(String path, SimpleHandler.FunctionalHandler h) { globalRouter.trace(path, h); return this; }
|
||||
public HttpServer connect(String path, SimpleHandler.FunctionalHandler h) { globalRouter.connect(path, h); return this; }
|
||||
public HttpServer purge(String path, SimpleHandler.FunctionalHandler h) { globalRouter.purge(path, h); return this; }
|
||||
|
||||
public HttpServer post(String path, SimpleHandler.FunctionalHandler h) {
|
||||
globalRouter.post(path, h);
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpServer put(String path, SimpleHandler.FunctionalHandler h) {
|
||||
globalRouter.put(path, h);
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpServer delete(String path, SimpleHandler.FunctionalHandler h) {
|
||||
globalRouter.delete(path, h);
|
||||
return this;
|
||||
}
|
||||
|
||||
private void process(Socket socket) {
|
||||
activeSockets.add(socket);
|
||||
@@ -161,7 +150,7 @@ public class HttpServer {
|
||||
|
||||
boolean keepAlive = isKeepAlive(request);
|
||||
|
||||
Response response = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
|
||||
Response response = new Response(200, ContentType.TEXT_PLAIN);
|
||||
RequestHandler handler = globalRouter.route(request);
|
||||
|
||||
try {
|
||||
@@ -178,29 +167,19 @@ public class HttpServer {
|
||||
response.setBody(result);
|
||||
}
|
||||
|
||||
request.getBody();
|
||||
|
||||
out.write(HTTP_1_1);
|
||||
writeStatusPhrase(out, response.getStatusCode());
|
||||
out.write(CRLF);
|
||||
out.write(CONTENT_TYPE);
|
||||
out.write(response.getContentType());
|
||||
out.write(CRLF);
|
||||
out.write(CONTENT_LENGTH);
|
||||
writeInt(out, response.getBody() != null ? response.getBody().length : 0);
|
||||
out.write(CRLF);
|
||||
out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
|
||||
out.write(CRLF);
|
||||
if (response.getBody() != null)
|
||||
out.write(response.getBody());
|
||||
out.flush();
|
||||
writeResponse(out, response, keepAlive);
|
||||
request.drain();
|
||||
|
||||
if (!keepAlive)
|
||||
break;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
if (!stopped)
|
||||
log.error("I/O error handling request", e);
|
||||
if (!stopped) {
|
||||
if (e instanceof java.net.SocketException)
|
||||
log.debug("Connection closed: {}", e.getMessage());
|
||||
else
|
||||
log.error("I/O error handling request", e);
|
||||
}
|
||||
} finally {
|
||||
activeSockets.remove(socket);
|
||||
}
|
||||
@@ -215,31 +194,87 @@ public class HttpServer {
|
||||
|| request.headerEquals("Connection", "keep-alive");
|
||||
}
|
||||
|
||||
private static void writeStatusPhrase(OutputStream out, int statusCode) throws IOException {
|
||||
byte[] phrase = HttpStatus.bytesForCode(statusCode);
|
||||
if (phrase != null) {
|
||||
out.write(phrase);
|
||||
/**
|
||||
* Writes a complete HTTP response. The fixed-body path (the common case for simple handlers
|
||||
* like /plaintext) is kept inline; streaming and chunked bodies are delegated to
|
||||
* {@link #writeStreamingBody} so the JIT can optimise this method aggressively.
|
||||
*/
|
||||
private static void writeResponse(OutputStream out, Response response, boolean keepAlive) throws IOException {
|
||||
out.write(HTTP_1_1);
|
||||
byte[] statusBytes = response.getStatusBytes();
|
||||
if (statusBytes != null) out.write(statusBytes);
|
||||
else writeStatusPhrase(out, response.getStatusCode());
|
||||
out.write(CRLF);
|
||||
out.write(CONTENT_TYPE);
|
||||
out.write(response.getContentType());
|
||||
out.write(CRLF);
|
||||
response.writeHeaders(out);
|
||||
|
||||
if (response.isStreaming()) {
|
||||
writeStreamingBody(out, response, keepAlive);
|
||||
} else {
|
||||
writeInt(out, statusCode);
|
||||
out.write(UNKNOWN_STATUS_SUFFIX);
|
||||
byte[] body = response.getBody();
|
||||
out.write(CONTENT_LENGTH);
|
||||
writeLong(out, body != null ? body.length : 0);
|
||||
out.write(CRLF);
|
||||
out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
|
||||
out.write(CRLF);
|
||||
if (body != null) out.write(body);
|
||||
}
|
||||
out.flush();
|
||||
}
|
||||
|
||||
/** Streaming write path — extracted from {@link #writeResponse} to keep the hot method small. */
|
||||
private static void writeStreamingBody(OutputStream out, Response response, boolean keepAlive) throws IOException {
|
||||
if (!response.isChunked()) {
|
||||
out.write(CONTENT_LENGTH);
|
||||
writeLong(out, response.getStreamLength());
|
||||
out.write(CRLF);
|
||||
out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
|
||||
out.write(CRLF);
|
||||
response.getStream().transferTo(out);
|
||||
} else {
|
||||
out.write(TRANSFER_CHUNKED);
|
||||
out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
|
||||
out.write(CRLF);
|
||||
writeChunked(out, response.getStream());
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeInt(OutputStream out, int value) throws IOException {
|
||||
if (value == 0) {
|
||||
out.write(DIGITS[0]);
|
||||
return;
|
||||
private static void writeStatusPhrase(OutputStream out, int statusCode) throws IOException {
|
||||
byte[] phrase = HttpStatus.bytesForCode(statusCode);
|
||||
if (phrase != null) out.write(phrase);
|
||||
else { writeLong(out, statusCode); out.write(UNKNOWN_STATUS_SUFFIX); }
|
||||
}
|
||||
|
||||
private static void writeLong(OutputStream out, long value) throws IOException {
|
||||
if (value == 0) { out.write(DIGITS[0]); return; }
|
||||
if (value < 0) { out.write('-'); value = -value; }
|
||||
long divisor = 1;
|
||||
while (value / divisor >= 10) divisor *= 10;
|
||||
while (divisor > 0) { out.write(DIGITS[(int) ((value / divisor) % 10)]); divisor /= 10; }
|
||||
}
|
||||
|
||||
private static void writeChunked(OutputStream out, InputStream stream) throws IOException {
|
||||
byte[] buf = new byte[8192];
|
||||
int n;
|
||||
while ((n = stream.read(buf)) > 0) {
|
||||
writeHex(out, n);
|
||||
out.write(CRLF);
|
||||
out.write(buf, 0, n);
|
||||
out.write(CRLF);
|
||||
}
|
||||
if (value < 0) {
|
||||
out.write('-');
|
||||
value = -value;
|
||||
}
|
||||
int divisor = 1;
|
||||
while (value / divisor >= 10)
|
||||
divisor *= 10;
|
||||
while (divisor > 0) {
|
||||
out.write(DIGITS[(value / divisor) % 10]);
|
||||
divisor /= 10;
|
||||
out.write(FINAL_CHUNK);
|
||||
}
|
||||
|
||||
private static void writeHex(OutputStream out, int value) throws IOException {
|
||||
int shift = 28;
|
||||
boolean leading = true;
|
||||
while (shift >= 0) {
|
||||
int digit = (value >>> shift) & 0xF;
|
||||
if (digit != 0 || !leading) { leading = false; out.write(digit < 10 ? '0' + digit : 'a' + digit - 10); }
|
||||
shift -= 4;
|
||||
}
|
||||
if (leading) out.write('0');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,5 +9,7 @@ public class HttpServerConfiguration {
|
||||
private int port;
|
||||
private String host;
|
||||
@Builder.Default
|
||||
private int acceptorThreads = 1;
|
||||
@Builder.Default
|
||||
private int maxHeaderBufferSize = 64 * 1024;
|
||||
}
|
||||
|
||||
@@ -1,47 +1,141 @@
|
||||
package dev.relism;
|
||||
|
||||
import dev.relism.api.multipart.Multipart;
|
||||
import dev.relism.api.multipart.Part;
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.models.*;
|
||||
import dev.relism.routing.Route;
|
||||
import dev.relism.routing.routers.fastpathrouter.FastPathRouterImpl;
|
||||
import dev.relism.http.HttpStatus;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.*;
|
||||
import java.nio.file.*;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
public class Main {
|
||||
|
||||
private static final int PORT = 8080;
|
||||
|
||||
/*
|
||||
@Route(method = "GET", path = "/profile")
|
||||
public static class ProfileHandler extends RequestHandler {
|
||||
@Override
|
||||
public Object handle(Request request, Response response) {
|
||||
return "Class based handler: User profile info under /api/profile";
|
||||
}
|
||||
}
|
||||
*/
|
||||
private static final int PORT = 8080;
|
||||
private static final Path UPLOAD_DIR = Path.of(System.getProperty("java.io.tmpdir"), "flash-uploads");
|
||||
|
||||
public static void main(String[] args) throws IOException {
|
||||
HttpServerConfiguration config = HttpServerConfiguration.builder()
|
||||
.port(PORT)
|
||||
.host("localhost")
|
||||
.build();
|
||||
Files.createDirectories(UPLOAD_DIR);
|
||||
|
||||
HttpServer server = new HttpServer(config);
|
||||
HttpServer server = new HttpServer(
|
||||
HttpServerConfiguration.builder().port(PORT).build());
|
||||
|
||||
FastPathRouterImpl apiRouter = new FastPathRouterImpl();
|
||||
server.mount("/api", apiRouter);
|
||||
|
||||
// apiRouter.register(new ProfileHandler());
|
||||
|
||||
server.get("/headers", (req, res) -> {
|
||||
throw new RuntimeException(req.getQueryParam("test"));
|
||||
server.get("/plaintext", (req, res) -> {
|
||||
res.type(ContentType.TEXT_PLAIN);
|
||||
return "Hello, World!";
|
||||
});
|
||||
|
||||
// ── File serving ──────────────────────────────────────────────────────
|
||||
|
||||
server.get("/files/{name}", (req, res) -> {
|
||||
Path file = UPLOAD_DIR.resolve(req.param("name"));
|
||||
if (!Files.exists(file))
|
||||
return res.status(404).body("not found");
|
||||
|
||||
server.start().thenRun(() -> log.info("Server started on port: " + PORT));
|
||||
return res.type(ContentType.BINARY)
|
||||
.stream(Files.newInputStream(file), Files.size(file))
|
||||
.header("Content-Disposition", "attachment; filename=\"" + file.getFileName() + "\"");
|
||||
});
|
||||
|
||||
// ── Chunked / dynamic content ─────────────────────────────────────────
|
||||
|
||||
// Virtual thread produces rows into a pipe; socket thread drains in 8 KB chunks.
|
||||
// No Content-Length needed — Transfer-Encoding: chunked.
|
||||
server.get("/export/users.csv", (req, res) -> {
|
||||
PipedOutputStream sink = new PipedOutputStream();
|
||||
PipedInputStream source = new PipedInputStream(sink, 64 * 1024);
|
||||
|
||||
Thread.ofVirtual().start(() -> {
|
||||
try (PrintWriter w = new PrintWriter(new BufferedWriter(new OutputStreamWriter(sink)))) {
|
||||
w.println("id,name,email");
|
||||
for (int i = 1; i <= 100_000; i++)
|
||||
w.printf("%d,User %d,user%d@example.com%n", i, i, i);
|
||||
}
|
||||
});
|
||||
|
||||
return res.type(ContentType.TEXT_CSV)
|
||||
.chunked(source)
|
||||
.header("Content-Disposition", "attachment; filename=\"users.csv\"");
|
||||
});
|
||||
|
||||
// ── Multipart upload ──────────────────────────────────────────────────
|
||||
|
||||
// Text fields are buffered eagerly; file is streamed zero-copy to disk.
|
||||
server.post("/upload/avatar", (req, res) -> {
|
||||
Multipart mp = Multipart.of(req);
|
||||
String userId = mp.field("userId");
|
||||
Part avatar = mp.file("avatar");
|
||||
|
||||
if (userId == null || avatar == null)
|
||||
return res.status(400).body("userId and avatar fields are required");
|
||||
|
||||
Path dest = UPLOAD_DIR.resolve(userId + "_" + avatar.filename());
|
||||
Files.copy(avatar.stream(), dest, StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
return res.body("saved %s for user %s".formatted(avatar.filename(), userId));
|
||||
});
|
||||
|
||||
// parts() forces a full scan and materialises all file bodies — accepted cost.
|
||||
server.post("/upload/bulk", (req, res) -> {
|
||||
List<Part> files = Multipart.of(req).parts("files");
|
||||
if (files.isEmpty())
|
||||
return res.status(HttpStatus.BAD_REQUEST).body("no files uploaded");
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Part f : files) {
|
||||
byte[] data = f.materialize();
|
||||
Files.write(UPLOAD_DIR.resolve(f.filename()), data);
|
||||
sb.append("%s %d bytes %s%n".formatted(f.filename(), data.length, f.contentType()));
|
||||
}
|
||||
return res.body(sb.toString());
|
||||
});
|
||||
|
||||
// Echo: file bytes flow socket-in → pipe → chunked-out.
|
||||
// The pipe decouples reading (request socket) from writing (response socket) so they
|
||||
// proceed concurrently on separate virtual threads, preventing the TCP deadlock that
|
||||
// would occur if both happened sequentially on the same thread for large files.
|
||||
server.post("/upload/echo", (req, res) -> {
|
||||
Part file = Multipart.of(req).file("file");
|
||||
if (file == null)
|
||||
return res.status(400).body("missing file field");
|
||||
|
||||
PipedOutputStream sink = new PipedOutputStream();
|
||||
PipedInputStream source = new PipedInputStream(sink, 64 * 1024);
|
||||
|
||||
InputStream fileStream = file.stream();
|
||||
Thread.ofVirtual().start(() -> {
|
||||
try { fileStream.transferTo(sink); } catch (IOException ignored) {}
|
||||
finally { try { sink.close(); } catch (IOException ignored) {} }
|
||||
});
|
||||
|
||||
String ct = file.contentType();
|
||||
return res.type(ct != null ? ct : "application/octet-stream")
|
||||
.chunked(source)
|
||||
.header("Content-Disposition", "attachment; filename=\"" + file.filename() + "\"");
|
||||
});
|
||||
|
||||
// Mixed form: text fields parsed eagerly, image streamed back with Content-Length.
|
||||
server.post("/upload/resize-preview", (req, res) -> {
|
||||
Multipart mp = Multipart.of(req);
|
||||
String width = mp.field("width");
|
||||
String height = mp.field("height");
|
||||
Part image = mp.file("image");
|
||||
|
||||
if (image == null)
|
||||
return res.status(400).body("image field required");
|
||||
|
||||
// materialize() accepted here — preview endpoint, size bounded
|
||||
byte[] data = image.materialize();
|
||||
log.info("resize request {}x{} for {} ({} bytes)", width, height, image.filename(), data.length);
|
||||
|
||||
return res.type(ContentType.BINARY)
|
||||
.stream(new ByteArrayInputStream(data), data.length)
|
||||
.header("X-Original-Name", image.filename())
|
||||
.header("X-Requested-Size", width + "x" + height);
|
||||
});
|
||||
|
||||
server.start().thenRun(() -> log.info("Server started on :{}", PORT));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package dev.relism;
|
||||
|
||||
import dev.relism.models.HeaderMap;
|
||||
import dev.relism.http.HttpMethod;
|
||||
import dev.relism.models.HeaderMap;
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.RequestLine;
|
||||
import dev.relism.routing.routers.fastpathrouter.FastPathViews;
|
||||
@@ -13,7 +13,7 @@ import java.io.InputStream;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* One instance per connection — the buffer is allocated once and reused across keep-alive
|
||||
* One instance per connection, the buffer is allocated once and reused across keep-alive
|
||||
* requests. Grows on demand (doubling, up to {@code maxHeaderBufferSize}). Zero String
|
||||
* allocations during parsing; paths, headers and protocol are exposed as {@link dev.relism.fpr.core.ByteView} slices.
|
||||
*/
|
||||
@@ -22,7 +22,10 @@ public class RequestParser {
|
||||
private static final int INITIAL_BUFFER_SIZE = 8192;
|
||||
|
||||
private final int maxHeaderBufferSize;
|
||||
private final HeaderMap headerMap = new HeaderMap();
|
||||
private byte[] buffer;
|
||||
private int bufBase = 0; // absolute start of valid data in buffer
|
||||
private int bufLen = 0; // number of valid bytes from bufBase
|
||||
|
||||
public RequestParser() { this(64 * 1024); }
|
||||
public RequestParser(int maxHeaderBufferSize) {
|
||||
@@ -31,30 +34,42 @@ public class RequestParser {
|
||||
}
|
||||
|
||||
public Request parse(InputStream in) throws IOException {
|
||||
int totalRead = 0;
|
||||
int headerEndIdx = -1;
|
||||
while (true) {
|
||||
if (totalRead == buffer.length) {
|
||||
if (buffer.length >= maxHeaderBufferSize)
|
||||
// Take ownership of any leftover bytes from the previous request, then reset so
|
||||
// early-returns leave the fields in a clean state.
|
||||
int base = bufBase;
|
||||
int totalRead = bufLen;
|
||||
bufBase = 0;
|
||||
bufLen = 0;
|
||||
|
||||
int headerEndIdx = totalRead > 0 ? findEndOfHeader(buffer, base, base + totalRead) : -1;
|
||||
while (headerEndIdx == -1) {
|
||||
if (base + totalRead == buffer.length) {
|
||||
if (base > 0) {
|
||||
// Compact: slide valid data to position 0 — rare path (~every N requests
|
||||
// where N = bufferSize / avgRequestSize rather than every request).
|
||||
System.arraycopy(buffer, base, buffer, 0, totalRead);
|
||||
base = 0;
|
||||
} else if (buffer.length >= maxHeaderBufferSize) {
|
||||
throw new IOException("Request headers exceed " + maxHeaderBufferSize + " bytes");
|
||||
buffer = Arrays.copyOf(buffer, Math.min(buffer.length * 2, maxHeaderBufferSize));
|
||||
} else {
|
||||
buffer = Arrays.copyOf(buffer, Math.min(buffer.length * 2, maxHeaderBufferSize));
|
||||
}
|
||||
}
|
||||
int n = in.read(buffer, totalRead, buffer.length - totalRead);
|
||||
int n = in.read(buffer, base + totalRead, buffer.length - base - totalRead);
|
||||
if (n <= 0) break;
|
||||
int prevTotal = totalRead;
|
||||
totalRead += n;
|
||||
headerEndIdx = findEndOfHeader(buffer, Math.max(0, prevTotal - 3), totalRead);
|
||||
if (headerEndIdx != -1) break;
|
||||
headerEndIdx = findEndOfHeader(buffer, base + Math.max(0, prevTotal - 3), base + totalRead);
|
||||
}
|
||||
if (totalRead <= 0) return null;
|
||||
if (headerEndIdx == -1) {
|
||||
throw new IOException("Request headers exceed " + maxHeaderBufferSize + " bytes");
|
||||
}
|
||||
|
||||
int methodEnd = find(buffer, 0, headerEndIdx, (byte) ' ');
|
||||
int methodEnd = find(buffer, base, headerEndIdx, (byte) ' ');
|
||||
if (methodEnd == -1) throw new IOException("Invalid request line (method)");
|
||||
|
||||
HttpMethod method = HttpMethod.fromBytes(buffer, 0, methodEnd);
|
||||
HttpMethod method = HttpMethod.fromBytes(buffer, base, methodEnd - base);
|
||||
if (method == null) throw new IOException("Unsupported HTTP method");
|
||||
|
||||
int pathStart = methodEnd + 1;
|
||||
@@ -74,9 +89,10 @@ public class RequestParser {
|
||||
|
||||
FastPathViews.RequestByteView protocolView = new FastPathViews.RequestByteView(buffer, protocolStart, protocolEnd - protocolStart);
|
||||
|
||||
HeaderMap headerMap = new HeaderMap(buffer);
|
||||
int current = find(buffer, protocolEnd, headerEndIdx, (byte) '\n') + 1;
|
||||
int contentLength = 0;
|
||||
int sectionStart = find(buffer, protocolEnd, headerEndIdx, (byte) '\n') + 1;
|
||||
int current = sectionStart;
|
||||
long contentLength = 0;
|
||||
boolean isChunked = false;
|
||||
|
||||
while (current < headerEndIdx) {
|
||||
int lineEnd = find(buffer, current, headerEndIdx + 1, (byte) '\r');
|
||||
@@ -87,20 +103,39 @@ public class RequestParser {
|
||||
int valueStart = colon + 1;
|
||||
while (valueStart < lineEnd && buffer[valueStart] == ' ') valueStart++;
|
||||
|
||||
headerMap.add(current, colon - current, valueStart, lineEnd - valueStart);
|
||||
|
||||
if (equalsIgnoreCase(buffer, current, colon, "content-length")) {
|
||||
contentLength = parseInt(buffer, valueStart, lineEnd);
|
||||
contentLength = parseLong(buffer, valueStart, lineEnd);
|
||||
} else if (equalsIgnoreCase(buffer, current, colon, "transfer-encoding")) {
|
||||
isChunked = equalsIgnoreCase(buffer, valueStart, lineEnd, "chunked");
|
||||
}
|
||||
}
|
||||
current = lineEnd + 2;
|
||||
}
|
||||
|
||||
headerMap.reset(buffer, sectionStart, headerEndIdx);
|
||||
|
||||
int bodyStart = headerEndIdx + 4;
|
||||
int preBufLen = totalRead - bodyStart;
|
||||
return Request.forParsed(
|
||||
new RequestLine(method, pathView, queryView, protocolView, headerMap),
|
||||
in, contentLength, buffer, bodyStart, preBufLen);
|
||||
int preBufLen = (base + totalRead) - bodyStart;
|
||||
|
||||
// Any bytes read beyond this request's body belong to the next request.
|
||||
// Store their absolute position in the buffer — no copy needed; the next parse()
|
||||
// call will read directly from bufBase without touching the data.
|
||||
if (!isChunked && contentLength == 0 && preBufLen > 0) {
|
||||
bufBase = bodyStart;
|
||||
bufLen = preBufLen;
|
||||
preBufLen = 0;
|
||||
} else if (!isChunked && contentLength > 0 && preBufLen > contentLength) {
|
||||
bufBase = bodyStart + (int) contentLength;
|
||||
bufLen = preBufLen - (int) contentLength;
|
||||
preBufLen = (int) contentLength;
|
||||
}
|
||||
|
||||
RequestLine requestLine = new RequestLine(method, pathView, queryView, protocolView, headerMap);
|
||||
|
||||
if (isChunked) {
|
||||
return Request.forParsed(requestLine, new ChunkedInputStream(in, buffer, bodyStart, preBufLen), -1L, null, 0, 0);
|
||||
}
|
||||
return Request.forParsed(requestLine, in, contentLength, buffer, bodyStart, preBufLen);
|
||||
}
|
||||
|
||||
private static int findEndOfHeader(byte[] buf, int from, int len) {
|
||||
@@ -130,8 +165,8 @@ public class RequestParser {
|
||||
return true;
|
||||
}
|
||||
|
||||
private static int parseInt(byte[] buf, int start, int end) {
|
||||
int value = 0;
|
||||
private static long parseLong(byte[] buf, int start, int end) {
|
||||
long value = 0;
|
||||
for (int i = start; i < end; i++) {
|
||||
byte c = buf[i];
|
||||
if (c >= '0' && c <= '9') value = value * 10 + (c - '0');
|
||||
|
||||
@@ -6,7 +6,7 @@ import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* Pre-compiled byte representations of common HTTP {@code Content-Type} values.
|
||||
* {@link #getBytes()} returns the pre-computed array directly — never allocates.
|
||||
* {@link #getBytes()} returns the pre-computed array directly, never allocates.
|
||||
*/
|
||||
@Getter
|
||||
public enum ContentType {
|
||||
|
||||
@@ -58,7 +58,7 @@ public enum HttpStatus {
|
||||
}
|
||||
}
|
||||
|
||||
private final int code;
|
||||
private final int code;
|
||||
private final byte[] bytes;
|
||||
|
||||
HttpStatus(int code, String reason) {
|
||||
@@ -66,7 +66,14 @@ public enum HttpStatus {
|
||||
this.bytes = (code + " " + reason).getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/** * Returns pre-compiled status bytes for the given code.
|
||||
/** Numeric status code (e.g. {@code 200}). */
|
||||
public int code() { return code; }
|
||||
|
||||
/** Pre-encoded {@code "200 OK"} bytes — zero allocation on the write path. */
|
||||
public byte[] bytes() { return bytes; }
|
||||
|
||||
/**
|
||||
* Returns pre-compiled status bytes for the given code.
|
||||
* Access is O(1) and generates zero garbage.
|
||||
*/
|
||||
public static byte[] bytesForCode(int code) {
|
||||
|
||||
@@ -1,65 +1,78 @@
|
||||
package dev.relism.models;
|
||||
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Zero-allocation header storage backed by raw byte offsets into the shared request buffer.
|
||||
* Offsets are recorded at parse time; Strings are allocated only on {@link #getFirst} / {@link #getAll}.
|
||||
* Lazy header access backed by a raw byte slice of the request buffer.
|
||||
* No allocations at parse time; Strings are created only when {@link #first} / {@link #all} is called.
|
||||
* One instance per connection; reused across keep-alive requests via {@link #reset}.
|
||||
*/
|
||||
@NoArgsConstructor
|
||||
public class HeaderMap {
|
||||
private static final int MAX_HEADERS = 32;
|
||||
private byte[] buffer;
|
||||
private int sectionStart;
|
||||
private int sectionEnd;
|
||||
|
||||
private final byte[] buffer;
|
||||
private final int[] keys = new int[MAX_HEADERS * 2]; // interleaved [start, len] pairs
|
||||
private final int[] values = new int[MAX_HEADERS * 2]; // interleaved [start, len] pairs
|
||||
private int count = 0;
|
||||
|
||||
public HeaderMap(byte[] buffer) {
|
||||
this.buffer = buffer;
|
||||
/** Resets this map to the header section {@code buffer[sectionStart, sectionEnd)}. */
|
||||
public void reset(byte[] buffer, int sectionStart, int sectionEnd) {
|
||||
this.buffer = buffer;
|
||||
this.sectionStart = sectionStart;
|
||||
this.sectionEnd = sectionEnd;
|
||||
}
|
||||
|
||||
public void add(int keyStart, int keyLen, int valStart, int valLen) {
|
||||
if (count >= MAX_HEADERS) throw new IllegalStateException("Too many headers: limit is " + MAX_HEADERS);
|
||||
keys[count * 2] = keyStart;
|
||||
keys[count * 2 + 1] = keyLen;
|
||||
values[count * 2] = valStart;
|
||||
values[count * 2 + 1] = valLen;
|
||||
count++;
|
||||
/** Returns the first value of header {@code name} (case-insensitive), or {@code null}. */
|
||||
public String first(String name) {
|
||||
long r = findFirst(name);
|
||||
if (r < 0) return null;
|
||||
int s = (int) (r >> 32), l = (int) (r & 0xFFFFFFFFL);
|
||||
return new String(buffer, s, l, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public String getFirst(String name) {
|
||||
int idx = indexOf(name);
|
||||
if (idx < 0) return null;
|
||||
return new String(buffer, values[idx * 2], values[idx * 2 + 1], StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public List<String> getAll(String name) {
|
||||
/** Returns all values of header {@code name} in declaration order, or an empty list. */
|
||||
public List<String> all(String name) {
|
||||
if (buffer == null) return List.of();
|
||||
List<String> result = null;
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (keyMatches(i, name)) {
|
||||
int i = sectionStart;
|
||||
while (i < sectionEnd) {
|
||||
int lineEnd = findCR(i);
|
||||
int colon = findColon(i, lineEnd);
|
||||
if (colon != -1 && keyMatches(i, colon - i, name)) {
|
||||
int vs = skipSpaces(colon + 1, lineEnd);
|
||||
if (result == null) result = new ArrayList<>();
|
||||
result.add(new String(buffer, values[i * 2], values[i * 2 + 1], StandardCharsets.UTF_8));
|
||||
result.add(new String(buffer, vs, lineEnd - vs, StandardCharsets.UTF_8));
|
||||
}
|
||||
i = lineEnd + 2;
|
||||
}
|
||||
return result != null ? result : List.of();
|
||||
}
|
||||
|
||||
public List<String> getAll() {
|
||||
/** Returns all header values in declaration order. */
|
||||
public List<String> all() {
|
||||
if (buffer == null) return List.of();
|
||||
List<String> result = new ArrayList<>();
|
||||
for (int i = 0; i < count; i++) {
|
||||
result.add(new String(buffer, values[i * 2], values[i * 2 + 1], StandardCharsets.UTF_8));
|
||||
int i = sectionStart;
|
||||
while (i < sectionEnd) {
|
||||
int lineEnd = findCR(i);
|
||||
int colon = findColon(i, lineEnd);
|
||||
if (colon != -1) {
|
||||
int vs = skipSpaces(colon + 1, lineEnd);
|
||||
result.add(new String(buffer, vs, lineEnd - vs, StandardCharsets.UTF_8));
|
||||
}
|
||||
i = lineEnd + 2;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public boolean headerValueEqualsIgnoreCase(String name, String value) {
|
||||
int idx = indexOf(name);
|
||||
if (idx < 0) return false;
|
||||
int vs = values[idx * 2], vl = values[idx * 2 + 1];
|
||||
/** Case-insensitive comparison of the first value of {@code name} against {@code value}. */
|
||||
public boolean valueEqualsIgnoreCase(String name, String value) {
|
||||
long r = findFirst(name);
|
||||
if (r < 0) return false;
|
||||
int vs = (int) (r >> 32), vl = (int) (r & 0xFFFFFFFFL);
|
||||
if (vl != value.length()) return false;
|
||||
for (int i = 0; i < vl; i++) {
|
||||
byte b = buffer[vs + i];
|
||||
@@ -71,31 +84,54 @@ public class HeaderMap {
|
||||
return true;
|
||||
}
|
||||
|
||||
public ByteView getView(String name) {
|
||||
int idx = indexOf(name);
|
||||
if (idx < 0) return null;
|
||||
final int start = values[idx * 2];
|
||||
final int len = values[idx * 2 + 1];
|
||||
/** Returns a zero-copy {@link ByteView} over the first value of {@code name}, or {@code null}. */
|
||||
public ByteView view(String name) {
|
||||
long r = findFirst(name);
|
||||
if (r < 0) return null;
|
||||
final int s = (int) (r >> 32), l = (int) (r & 0xFFFFFFFFL);
|
||||
return new ByteView() {
|
||||
public int length() { return len; }
|
||||
public byte byteAt(int i) { return buffer[start + i]; }
|
||||
public int length() { return l; }
|
||||
public byte byteAt(int i) { return buffer[s + i]; }
|
||||
};
|
||||
}
|
||||
|
||||
private int indexOf(String name) {
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (keyMatches(i, name)) return i;
|
||||
/** Returns {@code (valStart << 32) | valLen}, or {@code -1} if not found. */
|
||||
private long findFirst(String name) {
|
||||
if (buffer == null) return -1L;
|
||||
int i = sectionStart;
|
||||
while (i < sectionEnd) {
|
||||
int lineEnd = findCR(i);
|
||||
int colon = findColon(i, lineEnd);
|
||||
if (colon != -1 && keyMatches(i, colon - i, name)) {
|
||||
int vs = skipSpaces(colon + 1, lineEnd);
|
||||
return ((long) vs << 32) | (lineEnd - vs);
|
||||
}
|
||||
i = lineEnd + 2;
|
||||
}
|
||||
return -1L;
|
||||
}
|
||||
|
||||
private int findCR(int from) {
|
||||
for (int i = from; i < sectionEnd; i++) if (buffer[i] == '\r') return i;
|
||||
return sectionEnd;
|
||||
}
|
||||
|
||||
private int findColon(int from, int to) {
|
||||
for (int i = from; i < to; i++) if (buffer[i] == ':') return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
private boolean keyMatches(int i, String name) {
|
||||
int ks = keys[i * 2], kl = keys[i * 2 + 1];
|
||||
if (kl != name.length()) return false;
|
||||
for (int j = 0; j < kl; j++) {
|
||||
byte b = buffer[ks + j];
|
||||
private int skipSpaces(int from, int end) {
|
||||
while (from < end && buffer[from] == ' ') from++;
|
||||
return from;
|
||||
}
|
||||
|
||||
private boolean keyMatches(int start, int len, String name) {
|
||||
if (len != name.length()) return false;
|
||||
for (int i = 0; i < len; i++) {
|
||||
byte b = buffer[start + i];
|
||||
if (b >= 'A' && b <= 'Z') b += 32;
|
||||
char c = name.charAt(j);
|
||||
char c = name.charAt(i);
|
||||
if (c >= 'A' && c <= 'Z') c += 32;
|
||||
if (b != (byte) c) return false;
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package dev.relism.models;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
|
||||
/**
|
||||
* Deferred body reader. On the first {@link #get()} call, pre-buffered bytes from the header
|
||||
* read-ahead are used first, then the remainder is pulled from the socket stream. Result is cached.
|
||||
*/
|
||||
final class LazyBody {
|
||||
private static final byte[] EMPTY = new byte[0];
|
||||
|
||||
private final InputStream stream;
|
||||
private final int contentLength;
|
||||
private final byte[] preBuf;
|
||||
private final int preBufOff;
|
||||
private final int preBufLen;
|
||||
private byte[] resolved;
|
||||
|
||||
LazyBody(InputStream stream, int contentLength, byte[] preBuf, int preBufOff, int preBufLen) {
|
||||
this.stream = stream;
|
||||
this.contentLength = contentLength;
|
||||
this.preBuf = preBuf;
|
||||
this.preBufOff = preBufOff;
|
||||
this.preBufLen = preBufLen;
|
||||
}
|
||||
|
||||
static LazyBody of(byte[] bytes) {
|
||||
LazyBody lb = new LazyBody(null, bytes.length, null, 0, 0);
|
||||
lb.resolved = bytes;
|
||||
return lb;
|
||||
}
|
||||
|
||||
static LazyBody empty() {
|
||||
LazyBody lb = new LazyBody(null, 0, null, 0, 0);
|
||||
lb.resolved = EMPTY;
|
||||
return lb;
|
||||
}
|
||||
|
||||
byte[] get() {
|
||||
if (resolved != null) return resolved;
|
||||
byte[] buf = new byte[contentLength];
|
||||
int copied = Math.min(preBufLen, contentLength);
|
||||
if (copied > 0) System.arraycopy(preBuf, preBufOff, buf, 0, copied);
|
||||
if (copied < contentLength) {
|
||||
try {
|
||||
stream.readNBytes(buf, copied, contentLength - copied);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
return resolved = buf;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import java.util.List;
|
||||
|
||||
/**
|
||||
* Lazy query parameter access ({@code ?key=value&...}). Backed by a zero-copy {@link ByteView}
|
||||
* over the raw query string bytes — no parsing at construction, values decoded on demand.
|
||||
* over the raw query string bytes, no parsing at construction, values decoded on demand.
|
||||
*/
|
||||
public class QueryParams {
|
||||
public static final QueryParams EMPTY = new QueryParams(null);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dev.relism.models;
|
||||
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
import dev.relism.http.HttpMethod;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
@@ -9,58 +10,139 @@ import lombok.Value;
|
||||
import lombok.experimental.NonFinal;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Immutable view of an incoming HTTP/1.1 request. Constructed by {@link dev.relism.RequestParser}
|
||||
* and passed directly to route handlers — never modified after creation (path/query params are
|
||||
* injected once by the router before the handler runs).
|
||||
*
|
||||
* <pre>{@code
|
||||
* server.get("/users/{id}", (req, res) -> {
|
||||
* String id = req.param("id"); // /users/42 → "42"
|
||||
* String page = req.query("page"); // ?page=2 → "2"
|
||||
* String accept = req.header("Accept"); // first Accept value
|
||||
* byte[] body = req.body().bytes(); // materialise body
|
||||
* InputStream in = req.body().stream(); // zero-copy stream
|
||||
* });
|
||||
* }</pre>
|
||||
*/
|
||||
@Value
|
||||
@ToString
|
||||
public class Request {
|
||||
RequestLine requestLine;
|
||||
|
||||
@Getter(lombok.AccessLevel.NONE)
|
||||
@EqualsAndHashCode.Exclude
|
||||
@ToString.Exclude
|
||||
LazyBody lazyBody;
|
||||
RequestBody body;
|
||||
|
||||
@NonFinal @Setter @Getter PathParams pathParams;
|
||||
@NonFinal @Setter @Getter QueryParams queryParams;
|
||||
/** Internal: the parsed request line (method, path, query, protocol, headers). */
|
||||
RequestLine requestLine;
|
||||
|
||||
private Request(RequestLine requestLine, LazyBody lazyBody) {
|
||||
@NonFinal @Setter PathParams pathParams;
|
||||
@NonFinal @Setter QueryParams queryParams;
|
||||
|
||||
private Request(RequestLine requestLine, RequestBody body) {
|
||||
this.requestLine = requestLine;
|
||||
this.lazyBody = lazyBody;
|
||||
this.body = body;
|
||||
this.pathParams = null;
|
||||
this.queryParams = null;
|
||||
}
|
||||
|
||||
public Request(RequestLine requestLine, byte[] body) {
|
||||
this(requestLine, LazyBody.of(body));
|
||||
this(requestLine, RequestBody.of(body));
|
||||
}
|
||||
|
||||
public static Request forParsed(RequestLine requestLine, InputStream stream,
|
||||
int contentLength, byte[] headerBuf,
|
||||
long contentLength, byte[] headerBuf,
|
||||
int bodyStart, int preBufLen) {
|
||||
LazyBody lazy = contentLength > 0
|
||||
? new LazyBody(stream, contentLength, headerBuf, bodyStart, preBufLen)
|
||||
: LazyBody.empty();
|
||||
return new Request(requestLine, lazy);
|
||||
RequestBody rb = contentLength > 0 ? new RequestBody(stream, contentLength, headerBuf, bodyStart, preBufLen)
|
||||
: contentLength == 0 ? RequestBody.empty()
|
||||
: /* chunked */ new RequestBody(stream, -1L, null, 0, 0);
|
||||
return new Request(requestLine, rb);
|
||||
}
|
||||
|
||||
public byte[] getBody() { return lazyBody.get(); }
|
||||
// ── Request line ──────────────────────────────────────────────────────────
|
||||
|
||||
public String getHeader(String name) { return requestLine.getHeaders().getFirst(name); }
|
||||
public List<String> getHeaders(String name) { return requestLine.getHeaders().getAll(name); }
|
||||
public List<String> getHeaders() { return requestLine.getHeaders().getAll(); }
|
||||
public boolean headerEquals(String name, String value) { return requestLine.getHeaders().headerValueEqualsIgnoreCase(name, value); }
|
||||
/** HTTP method ({@code GET}, {@code POST}, …). */
|
||||
public HttpMethod method() { return requestLine.getMethod(); }
|
||||
|
||||
public String getPathParam(String name) {
|
||||
return pathParams != null ? pathParams.get(name) : null;
|
||||
/**
|
||||
* Request path decoded as UTF-8. Includes a leading slash; never includes the query string.
|
||||
* Example: a request for {@code /users/42?page=1} returns {@code "/users/42"}.
|
||||
*/
|
||||
public String path() {
|
||||
ByteView v = requestLine.getPath();
|
||||
byte[] buf = new byte[v.length()];
|
||||
for (int i = 0; i < v.length(); i++) buf[i] = v.byteAt(i);
|
||||
return new String(buf, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public String getQueryParam(String name) {
|
||||
return resolveQueryParams().get(name);
|
||||
}
|
||||
// ── Headers ───────────────────────────────────────────────────────────────
|
||||
|
||||
public List<String> getQueryParams(String name) {
|
||||
return resolveQueryParams().getAll(name);
|
||||
/**
|
||||
* Returns the first value of header {@code name}, or {@code null} if absent.
|
||||
* Lookup is case-insensitive ({@code "content-type"} and {@code "Content-Type"} are equivalent).
|
||||
*/
|
||||
public String header(String name) { return requestLine.getHeaders().first(name); }
|
||||
|
||||
/**
|
||||
* Returns all values of header {@code name} in declaration order.
|
||||
* Useful for headers that appear multiple times (e.g. {@code Accept}, {@code Cookie}).
|
||||
* Lookup is case-insensitive. Returns an empty list if the header is absent.
|
||||
*/
|
||||
public List<String> headers(String name) { return requestLine.getHeaders().all(name); }
|
||||
|
||||
/**
|
||||
* Returns all header values in declaration order, one entry per header line.
|
||||
* Useful for debugging; for targeted access prefer {@link #header(String)}.
|
||||
*/
|
||||
public List<String> headers() { return requestLine.getHeaders().all(); }
|
||||
|
||||
// ── Path parameters ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the path parameter named {@code name}, or {@code null}.
|
||||
* Parameters are declared in the route pattern (e.g. {@code /users/{id}}) and
|
||||
* injected by the router before the handler runs. Returns {@code null} if this
|
||||
* route has no such parameter or the route is not parametric.
|
||||
*/
|
||||
public String param(String name) { return pathParams != null ? pathParams.get(name) : null; }
|
||||
|
||||
// ── Query parameters ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the first query parameter named {@code name}, or {@code null}.
|
||||
* The query string is parsed lazily on the first call and cached for the request lifetime.
|
||||
* For {@code ?a=1&a=2}, returns {@code "1"}.
|
||||
*/
|
||||
public String query(String name) { return resolveQueryParams().get(name); }
|
||||
|
||||
/**
|
||||
* Returns all query parameters named {@code name} in declaration order.
|
||||
* For {@code ?tag=a&tag=b}, returns {@code ["a", "b"]}.
|
||||
* Returns an empty list if the parameter is absent.
|
||||
*/
|
||||
public List<String> queries(String name) { return resolveQueryParams().getAll(name); }
|
||||
|
||||
// ── Body ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the request body accessor. Use {@link RequestBody#bytes()} to materialise
|
||||
* the full body or {@link RequestBody#stream()} for zero-copy streaming access.
|
||||
* The two modes are mutually exclusive per request.
|
||||
*/
|
||||
public RequestBody body() { return body; }
|
||||
|
||||
/** Discards unread body bytes; called by the server after each request on keep-alive connections. */
|
||||
public void drain() { body.drain(); }
|
||||
|
||||
// ── Internal ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Internal: case-insensitive header value comparison used by the server keep-alive logic. */
|
||||
public boolean headerEquals(String name, String value) {
|
||||
return requestLine.getHeaders().valueEqualsIgnoreCase(name, value);
|
||||
}
|
||||
|
||||
private QueryParams resolveQueryParams() {
|
||||
|
||||
@@ -13,5 +13,5 @@ public abstract class RequestHandler {
|
||||
* return a {@link Response} to replace the whole response, any other non-null value
|
||||
* to set it as the body, or {@code null} to leave the response as-is.
|
||||
*/
|
||||
public abstract Object handle(Request request, Response response);
|
||||
public abstract Object handle(Request request, Response response) throws Exception;
|
||||
}
|
||||
@@ -1,45 +1,142 @@
|
||||
package dev.relism.models;
|
||||
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.http.HttpStatus;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* HTTP response. All mutating methods return {@code this} for fluent chaining.
|
||||
*
|
||||
* <pre>{@code
|
||||
* // fixed body
|
||||
* return new Response(200, "ok", ContentType.TEXT_PLAIN);
|
||||
*
|
||||
* // known-length stream → Content-Length header
|
||||
* return new Response(200, ContentType.BINARY).stream(Files.newInputStream(p), Files.size(p));
|
||||
*
|
||||
* // unknown-length stream → Transfer-Encoding: chunked
|
||||
* return new Response(200, ContentType.TEXT_PLAIN).chunked(source);
|
||||
* }</pre>
|
||||
*/
|
||||
@Getter
|
||||
@ToString
|
||||
public class Response {
|
||||
@Setter private int statusCode;
|
||||
private byte[] body;
|
||||
private byte[] contentType;
|
||||
private byte[] statusBytes; // pre-encoded "200 OK"; null when set via status(int)
|
||||
private byte[] body;
|
||||
@ToString.Exclude
|
||||
private InputStream stream;
|
||||
private long streamLength; // meaningful only when isStreaming() && !chunked
|
||||
private boolean chunked;
|
||||
private byte[] contentType;
|
||||
@Getter(lombok.AccessLevel.NONE)
|
||||
private List<byte[]> headers; // pre-encoded "Name: Value\r\n" entries
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Constructors
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public Response(int statusCode, ContentType contentType) {
|
||||
this.statusCode = statusCode;
|
||||
this.contentType = contentType.getBytes();
|
||||
}
|
||||
|
||||
public Response(int statusCode, byte[] body, ContentType contentType) {
|
||||
this.statusCode = statusCode;
|
||||
this.body = body;
|
||||
this.contentType = contentType.getBytes();
|
||||
this(statusCode, contentType);
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
public Response(int statusCode, String text, ContentType contentType) {
|
||||
this.statusCode = statusCode;
|
||||
this.body = text.getBytes(StandardCharsets.UTF_8);
|
||||
this.contentType = contentType.getBytes();
|
||||
this(statusCode, text.getBytes(StandardCharsets.UTF_8), contentType);
|
||||
}
|
||||
|
||||
public void setContentType(ContentType contentType) {
|
||||
this.contentType = contentType.getBytes();
|
||||
}
|
||||
// -------------------------------------------------------------------------
|
||||
// Fluent mutators
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public void setContentType(String contentType) {
|
||||
this.contentType = contentType.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
/** Sets the status code. The phrase is looked up from {@link HttpStatus} on the write path. */
|
||||
public Response status(int code) { this.statusCode = code; this.statusBytes = null; return this; }
|
||||
|
||||
public Response setBody(Object body) {
|
||||
if (body instanceof byte[] bytes) {
|
||||
this.body = bytes;
|
||||
} else if (body != null) {
|
||||
this.body = body.toString().getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
/** Sets the status from an {@link HttpStatus} constant. The pre-encoded bytes are used
|
||||
* directly on the write path — zero lookup, zero allocation. */
|
||||
public Response status(HttpStatus status) { this.statusCode = status.code(); this.statusBytes = status.bytes(); return this; }
|
||||
public Response type(ContentType ct) { this.contentType = ct.getBytes(); return this; }
|
||||
public Response type(String ct) { this.contentType = ct.getBytes(StandardCharsets.UTF_8); return this; }
|
||||
|
||||
public Response body(byte[] bytes) {
|
||||
this.body = bytes;
|
||||
this.stream = null;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Response body(String text) {
|
||||
return body(text.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/** Streaming response with known length; written with {@code Content-Length}. */
|
||||
public Response stream(InputStream is, long length) {
|
||||
this.stream = is;
|
||||
this.streamLength = length;
|
||||
this.chunked = false;
|
||||
this.body = null;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Streaming response with unknown length; written with {@code Transfer-Encoding: chunked}. */
|
||||
public Response chunked(InputStream is) {
|
||||
this.stream = is;
|
||||
this.chunked = true;
|
||||
this.body = null;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Adds a response header. Encoded once at call time; zero-alloc on the write path. */
|
||||
public Response header(String name, String value) {
|
||||
if (headers == null) headers = new ArrayList<>();
|
||||
headers.add((name + ": " + value + "\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
return this;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// State queries
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public boolean isStreaming() { return stream != null; }
|
||||
|
||||
/**
|
||||
* Pre-encoded status bytes (e.g. {@code "200 OK"}), or {@code null} if the status was set
|
||||
* via {@link #status(int)} — in which case {@link HttpStatus#bytesForCode} is used as fallback.
|
||||
*/
|
||||
public byte[] getStatusBytes() { return statusBytes; }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal setters used by HttpServer for handler return values
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Sets the body from an arbitrary handler return value. */
|
||||
public Response setBody(Object body) {
|
||||
if (body instanceof byte[] bytes) this.body = bytes;
|
||||
else if (body != null) this.body = body.toString().getBytes(StandardCharsets.UTF_8);
|
||||
return this;
|
||||
}
|
||||
|
||||
public void setContentType(ContentType ct) { this.contentType = ct.getBytes(); }
|
||||
|
||||
/** Returns custom headers, or an empty list if none were added. */
|
||||
public List<byte[]> getHeaders() { return headers != null ? headers : List.of(); }
|
||||
|
||||
/** Writes pre-encoded custom headers directly to {@code out}. Zero-alloc when no headers are set. */
|
||||
public void writeHeaders(OutputStream out) throws IOException {
|
||||
if (headers == null) return;
|
||||
for (int i = 0, n = headers.size(); i < n; i++) out.write(headers.get(i));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ public class SimpleHandler extends RequestHandler {
|
||||
private final FunctionalHandler delegate;
|
||||
|
||||
@Override
|
||||
public Object handle(Request request, Response response) {
|
||||
public Object handle(Request request, Response response) throws Exception {
|
||||
return delegate.handle(request, response);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,6 @@ public class SimpleHandler extends RequestHandler {
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface FunctionalHandler {
|
||||
Object handle(Request request, Response response);
|
||||
Object handle(Request request, Response response) throws Exception;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ import lombok.Getter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* Base router. Each router has a namespace prefix (default {@code "/"}); routes added via
|
||||
* {@link #get}, {@link #post}, etc. are relative to it. Error handlers are scoped to this router.
|
||||
* Base router. Each router has a namespace prefix (default {@code "/"});
|
||||
* Error handlers are scoped to this router.
|
||||
* The namespace is set automatically by {@link GlobalRouter#mount}.
|
||||
*/
|
||||
public abstract class AbstractRouter {
|
||||
@@ -57,21 +57,20 @@ public abstract class AbstractRouter {
|
||||
* Registers a route relative to this router's namespace. Return a {@link Response} to replace
|
||||
* it entirely, any other non-null value to set it as the body, or {@code null} to leave it unchanged.
|
||||
*/
|
||||
public AbstractRouter get(String path, SimpleHandler.FunctionalHandler handler) {
|
||||
return addRoute(HttpMethod.GET, PathUtils.sanitize(path), new SimpleHandler(handler));
|
||||
public AbstractRouter register(HttpMethod method, String path, SimpleHandler.FunctionalHandler handler) {
|
||||
return addRoute(method, PathUtils.sanitize(path), new SimpleHandler(handler));
|
||||
}
|
||||
|
||||
public AbstractRouter post(String path, SimpleHandler.FunctionalHandler handler) {
|
||||
return addRoute(HttpMethod.POST, PathUtils.sanitize(path), new SimpleHandler(handler));
|
||||
}
|
||||
|
||||
public AbstractRouter put(String path, SimpleHandler.FunctionalHandler handler) {
|
||||
return addRoute(HttpMethod.PUT, PathUtils.sanitize(path), new SimpleHandler(handler));
|
||||
}
|
||||
|
||||
public AbstractRouter delete(String path, SimpleHandler.FunctionalHandler handler) {
|
||||
return addRoute(HttpMethod.DELETE, PathUtils.sanitize(path), new SimpleHandler(handler));
|
||||
}
|
||||
public AbstractRouter get(String path, SimpleHandler.FunctionalHandler h) { return register(HttpMethod.GET, path, h); }
|
||||
public AbstractRouter post(String path, SimpleHandler.FunctionalHandler h) { return register(HttpMethod.POST, path, h); }
|
||||
public AbstractRouter put(String path, SimpleHandler.FunctionalHandler h) { return register(HttpMethod.PUT, path, h); }
|
||||
public AbstractRouter delete(String path, SimpleHandler.FunctionalHandler h) { return register(HttpMethod.DELETE, path, h); }
|
||||
public AbstractRouter patch(String path, SimpleHandler.FunctionalHandler h) { return register(HttpMethod.PATCH, path, h); }
|
||||
public AbstractRouter options(String path, SimpleHandler.FunctionalHandler h) { return register(HttpMethod.OPTIONS, path, h); }
|
||||
public AbstractRouter head(String path, SimpleHandler.FunctionalHandler h) { return register(HttpMethod.HEAD, path, h); }
|
||||
public AbstractRouter trace(String path, SimpleHandler.FunctionalHandler h) { return register(HttpMethod.TRACE, path, h); }
|
||||
public AbstractRouter connect(String path, SimpleHandler.FunctionalHandler h) { return register(HttpMethod.CONNECT, path, h); }
|
||||
public AbstractRouter purge(String path, SimpleHandler.FunctionalHandler h) { return register(HttpMethod.PURGE, path, h); }
|
||||
|
||||
/** Registers a class-based handler; the class must be annotated with {@link Route @Route}. */
|
||||
public AbstractRouter register(RequestHandler handler) {
|
||||
|
||||
@@ -39,7 +39,7 @@ public final class FastPathViews {
|
||||
}
|
||||
}
|
||||
|
||||
/** Mutable composite view: method bytes + path. Reused via ThreadLocal — call reset() before use. */
|
||||
/** Mutable composite view: method bytes + path. Reused via ThreadLocal, call reset() before use. */
|
||||
public static final class MethodPathByteView implements ByteView {
|
||||
private byte[] method;
|
||||
private ByteView path;
|
||||
|
||||
Reference in New Issue
Block a user