Add Middleware API; untrack Main.java and local artifacts
- Add Middleware.java: @FunctionalInterface with Middleware.of() chain composition and andThen() helper; zero allocations on hot-path - Remove flash/Main.java from versioning (scratch/test entrypoint) - Update .gitignore: exclude flash-bench/, nuxt-shadcn-dashboard/, /dev/, /docs/, Main.java, *.text — all root-level local artifacts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
5f0681a922
commit
7c1edffd6e
@@ -39,3 +39,12 @@ build/
|
||||
.DS_Store
|
||||
|
||||
CLAUDE.md
|
||||
|
||||
### Local / scratch ###
|
||||
flash/src/main/java/dev/relism/Main.java
|
||||
flash-bench/
|
||||
nuxt-shadcn-dashboard/
|
||||
/dev/
|
||||
/docs/
|
||||
jmh-result.text
|
||||
*.text
|
||||
@@ -1,141 +0,0 @@
|
||||
package dev.relism;
|
||||
|
||||
import dev.relism.api.multipart.Multipart;
|
||||
import dev.relism.api.multipart.Part;
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.http.HttpStatus;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.file.*;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
public class Main {
|
||||
|
||||
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 {
|
||||
Files.createDirectories(UPLOAD_DIR);
|
||||
|
||||
HttpServer server = new HttpServer(
|
||||
HttpServerConfiguration.builder().port(PORT).build());
|
||||
|
||||
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");
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package dev.relism.routing;
|
||||
|
||||
import dev.relism.models.RequestHandler;
|
||||
import dev.relism.models.SimpleHandler;
|
||||
|
||||
/**
|
||||
* Stateless interceptor applied around a {@link RequestHandler}.
|
||||
*
|
||||
* <p>Middleware is composed <em>once at registration time</em> and stored as a pre-built handler
|
||||
* chain inside the router's state machine. There is zero runtime overhead: no lists are visited,
|
||||
* no lookups performed, and no objects allocated on the request hot-path.
|
||||
*
|
||||
* <p>Middleware is intentionally stateless — it can read {@link dev.relism.models.Request},
|
||||
* write to {@link dev.relism.models.Response}, and decide whether to call {@code next} or
|
||||
* short-circuit. Shared state between layers belongs in HDI handler chains, not here.
|
||||
*
|
||||
* <pre>{@code
|
||||
* // Inline definition — pure lambda, no wrapper types needed
|
||||
* Middleware auth = next -> (req, res) -> {
|
||||
* if (req.header("Authorization") == null) { res.status(401); return null; }
|
||||
* return next.handle(req, res);
|
||||
* };
|
||||
*
|
||||
* // Named reusable chain — composed into a single object at call time
|
||||
* Middleware secured = Middleware.of(cors, auth, rateLimit);
|
||||
*
|
||||
* // Router-level: applied to every handler registered on this router
|
||||
* AbstractRouter api = new MyRouter(cors, auth);
|
||||
*
|
||||
* // Handler-level: applied only to this route
|
||||
* router.get("/admin", (req, res) -> "secret", auth);
|
||||
* router.register(new AdminHandler(), auth, logging);
|
||||
* }</pre>
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface Middleware {
|
||||
|
||||
/**
|
||||
* Wraps {@code next} with this middleware's logic. Called <em>once at boot time</em>
|
||||
* during handler registration. Returns a {@link SimpleHandler.FunctionalHandler} lambda
|
||||
* — no wrapper types required at the call site.
|
||||
*
|
||||
* <p>The returned functional handler is boxed into a {@link SimpleHandler} internally by
|
||||
* {@link AbstractRouter}; callers never need to do this manually.
|
||||
*/
|
||||
SimpleHandler.FunctionalHandler wrap(RequestHandler next);
|
||||
|
||||
/**
|
||||
* Composes an ordered chain of middlewares into a single {@link Middleware}.
|
||||
* The first element is the outermost wrapper and therefore executes first.
|
||||
*
|
||||
* <p>The composition loop runs once when {@link #wrap} is called (i.e. at registration
|
||||
* time), not at construction of the chain. Zero allocations on the hot-path.
|
||||
*
|
||||
* <pre>{@code
|
||||
* Middleware secured = Middleware.of(cors, auth, rateLimit);
|
||||
* // execution order: cors → auth → rateLimit → handler
|
||||
* }</pre>
|
||||
*/
|
||||
static Middleware of(Middleware... chain) {
|
||||
if (chain.length == 0) return next -> next::handle;
|
||||
if (chain.length == 1) return chain[0];
|
||||
return next -> {
|
||||
RequestHandler h = next;
|
||||
for (int i = chain.length - 1; i >= 0; i--)
|
||||
h = new SimpleHandler(chain[i].wrap(h));
|
||||
return h::handle;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a composed middleware that applies {@code this} first, then {@code after}.
|
||||
* Prefer {@link #of} for three or more middlewares.
|
||||
*
|
||||
* <pre>{@code
|
||||
* Middleware secured = cors.andThen(auth);
|
||||
* }</pre>
|
||||
*/
|
||||
default Middleware andThen(Middleware after) {
|
||||
return next -> this.wrap(new SimpleHandler(after.wrap(next)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user