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
|
.DS_Store
|
||||||
|
|
||||||
CLAUDE.md
|
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