Feature/ext validation/request validation #15

Merged
Relism merged 10 commits from feature/ext-validation/request-validation into master 2026-09-09 14:21:17 +00:00
6 changed files with 312 additions and 1 deletions
Showing only changes of commit 4a85a27648 - Show all commits
@@ -7,6 +7,7 @@ import dev.relism.flash.routing.AbstractWsRouter;
import dev.relism.flash.transport.TransportFactory; import dev.relism.flash.transport.TransportFactory;
import java.io.IOException; import java.io.IOException;
import java.util.List;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
/** /**
@@ -28,6 +29,16 @@ public interface ServerHandle {
/** Gracefully stops the server, draining active connections. */ /** Gracefully stops the server, draining active connections. */
CompletableFuture<Void> stop(); CompletableFuture<Void> stop();
/**
* Port of the first bound listener. Valid as soon as the handle exists — listeners are
* bound at construction, not at {@link #start()} — so configuring with port {@code 0}
* and reading the OS-assigned port back is a supported pattern.
*/
int port();
/** Ports of every bound listener, in configuration order. */
List<Integer> ports();
static ServerHandle create(FlashConfiguration config, static ServerHandle create(FlashConfiguration config,
AbstractRouter httpRouter, AbstractRouter httpRouter,
AbstractWsRouter wsRouter) throws IOException { AbstractWsRouter wsRouter) throws IOException {
@@ -22,6 +22,7 @@ import lombok.extern.slf4j.Slf4j;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer; import java.util.function.Consumer;
@@ -138,6 +139,16 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
return this; return this;
} }
/**
* Applies a {@link FlashApplication} to this app. Runs immediately, so {@code configure}
* sees a context that is still open for declarations and a listener that is already
* bound ({@link #port()}).
*/
public FlashApp apply(FlashApplication application) {
Objects.requireNonNull(application, "application").configure(this);
return this;
}
// ── Lifecycle ───────────────────────────────────────────────────────────── // ── Lifecycle ─────────────────────────────────────────────────────────────
/** /**
@@ -166,7 +177,24 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
server.startAndBlock(); server.startAndBlock();
} }
public CompletableFuture<Void> stop() { return server.stop(); } /**
* Gracefully stops the server, then runs every {@link FlashContext#onClose} callback so
* services release what they hold. Draining first means in-flight requests still see a
* live service graph.
*/
public CompletableFuture<Void> stop() {
return server.stop().whenComplete((ignored, failure) -> ctx.runCloseCallbacks());
}
/**
* Port of the first bound listener. Usable before {@link #start()}: listeners bind when
* the app is created, so {@code FlashApp.create(cfg with port 0).port()} yields the
* OS-assigned port, and an app can even be configured against its own address.
*/
public int port() { return server.port(); }
/** Ports of every bound listener, in configuration order. */
public List<Integer> ports() { return server.ports(); }
// ── FlashRegistrar impl ─────────────────────────────────────────────────── // ── FlashRegistrar impl ───────────────────────────────────────────────────
@@ -0,0 +1,41 @@
package dev.relism.flash.extension;
/**
* One application's complete contribution to a {@link FlashApp} — its routes, extensions and
* services — expressed independently of which port or configuration it runs on.
*
* <p>Optional. The fluent form keeps working exactly as before; this interface exists so the
* same application can be created more than once, on more than one port:
*
* <pre>{@code
* public final class BlogApp implements FlashApplication {
* @Override public void configure(FlashApp app) {
* app.install(new JacksonExtension());
* app.mount("/api", scope -> scope.scan("dev.blog.api"));
* app.ws("/live", new FeedSocket());
* }
* }
*
* // production
* FlashApp.create(8080).apply(new BlogApp()).startAndBlock();
* }</pre>
*
* <p>It takes {@link FlashApp} rather than {@link FlashRegistrar} deliberately: {@code ws} and
* {@code mount} live on {@code FlashApp}, and an application that could not register a
* WebSocket route or a mounted namespace would be a half-application.
*
* <p>Being a {@code @FunctionalInterface}, a lambda and a named class are the same thing here —
* {@code app -> app.get("/ping", ...)} is as valid as {@code new BlogApp()}.
*
* @see FlashApp#apply(FlashApplication)
*/
@FunctionalInterface
public interface FlashApplication {
/**
* Registers this application onto {@code app}. Called immediately by
* {@link FlashApp#apply}, so the service graph is still open for declarations
* ({@code app.ctx().supply(...)}) and the listener is already bound ({@code app.port()}).
*/
void configure(FlashApp app);
}
@@ -1,10 +1,13 @@
package dev.relism.flash.extension; package dev.relism.flash.extension;
import lombok.extern.slf4j.Slf4j;
import java.util.*; import java.util.*;
import java.util.function.Function; import java.util.function.Function;
import java.util.function.Supplier; import java.util.function.Supplier;
/** Deterministic boot-time service graph, frozen before handlers are initialised. */ /** Deterministic boot-time service graph, frozen before handlers are initialised. */
@Slf4j
public final class FlashContext { public final class FlashContext {
private enum State { DECLARING, RESOLVING, READY } private enum State { DECLARING, RESOLVING, READY }
@@ -13,6 +16,7 @@ public final class FlashContext {
private final List<AnnotationProcessor> processors = new ArrayList<>(); private final List<AnnotationProcessor> processors = new ArrayList<>();
private final List<RouteListener> routeListeners = new ArrayList<>(); private final List<RouteListener> routeListeners = new ArrayList<>();
private final List<Runnable> readyCallbacks = new ArrayList<>(); private final List<Runnable> readyCallbacks = new ArrayList<>();
private final List<Runnable> closeCallbacks = new ArrayList<>();
private final List<FlashContext> children = new ArrayList<>(); private final List<FlashContext> children = new ArrayList<>();
private final Deque<Class<?>> resolutionPath = new ArrayDeque<>(); private final Deque<Class<?>> resolutionPath = new ArrayDeque<>();
private State state = State.DECLARING; private State state = State.DECLARING;
@@ -34,6 +38,23 @@ public final class FlashContext {
declare(type, new Binding<>(type, List.of(), ignored -> Objects.requireNonNull(instance, "instance"))); declare(type, new Binding<>(type, List.of(), ignored -> Objects.requireNonNull(instance, "instance")));
} }
/**
* Replaces the binding for {@code type}, whether or not one already exists.
*
* <p>The declaration rule everywhere else is that a duplicate is an error
* ({@link #provide}, {@link #supply}); this is the single deliberate exception, and it
* exists for tests — {@code flash-testing} installs overrides as the last extension so a
* fake wins over whatever the application or an extension declared. Using it in
* production code is legal but almost always a mistake, so a replacement is logged.
*/
public <T> void override(Class<T> type, T instance) {
requireDeclaring();
Objects.requireNonNull(type, "type");
Objects.requireNonNull(instance, "instance");
if (bindings.put(type, new Binding<>(type, List.of(), ignored -> instance)) != null)
log.info("Service binding overridden: {}", type.getName());
}
/** Declares a no-dependency boot factory. */ /** Declares a no-dependency boot factory. */
public <T> void supply(Class<T> type, Supplier<T> factory) { public <T> void supply(Class<T> type, Supplier<T> factory) {
declare(type, new Binding<>(type, List.of(), ignored -> factory.get())); declare(type, new Binding<>(type, List.of(), ignored -> factory.get()));
@@ -53,6 +74,21 @@ public final class FlashContext {
/** Registers work materialised after all services are resolved. */ /** Registers work materialised after all services are resolved. */
public void onReady(Runnable callback) { requireDeclaring(); readyCallbacks.add(Objects.requireNonNull(callback)); } public void onReady(Runnable callback) { requireDeclaring(); readyCallbacks.add(Objects.requireNonNull(callback)); }
/**
* Registers cleanup to run when the app stops, after in-flight requests have drained.
*
* <p>Deliberately callable outside {@code DECLARING} so a factory can register its own
* teardown while it is being resolved:
* <pre>{@code
* ctx.supply(DataSource.class, c -> {
* HikariDataSource ds = new HikariDataSource(cfg);
* c.onClose(ds::close);
* return ds;
* });
* }</pre>
*/
public void onClose(Runnable callback) { closeCallbacks.add(Objects.requireNonNull(callback)); }
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public <T> T require(Class<T> type) { public <T> T require(Class<T> type) {
if (state == State.DECLARING) if (state == State.DECLARING)
@@ -106,6 +142,21 @@ public final class FlashContext {
for (FlashContext child : children) child.runReadyCallbacks(); for (FlashContext child : children) child.runReadyCallbacks();
} }
/**
* Runs every {@link #onClose} callback once: children first, then this context's own in
* reverse registration order, so a service always closes before whatever it depends on.
* A throwing callback is logged and does not stop the rest. Clearing makes a second
* {@code stop()} a no-op.
*/
void runCloseCallbacks() {
for (FlashContext child : children) child.runCloseCallbacks();
for (int i = closeCallbacks.size() - 1; i >= 0; i--) {
try { closeCallbacks.get(i).run(); }
catch (RuntimeException e) { log.error("Close callback failed", e); }
}
closeCallbacks.clear();
}
/** Completes graph resolution and runs all deferred materialisation callbacks once. */ /** Completes graph resolution and runs all deferred materialisation callbacks once. */
public void complete() { public void complete() {
resolveAll(); resolveAll();
@@ -46,6 +46,14 @@ public final class ServerLifecycle implements ServerHandle {
this.acceptLatch = new CountDownLatch(TransportTuning.ACCEPT_THREADS * listeners.size()); this.acceptLatch = new CountDownLatch(TransportTuning.ACCEPT_THREADS * listeners.size());
} }
@Override
public int port() { return listeners.get(0).socket().getLocalPort(); }
@Override
public List<Integer> ports() {
return listeners.stream().map(bound -> bound.socket().getLocalPort()).toList();
}
/** Whether the server has begun shutting down. Passed down to every connection as a /** Whether the server has begun shutting down. Passed down to every connection as a
* {@link java.util.function.BooleanSupplier} so in-flight request loops can drain * {@link java.util.function.BooleanSupplier} so in-flight request loops can drain
* promptly instead of waiting for their next keep-alive request. */ * promptly instead of waiting for their next keep-alive request. */
@@ -0,0 +1,172 @@
package dev.relism.flash.extension;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
/**
* The four seams {@code flash-testing} is built on: reading back an ephemeral port,
* replacing a binding, applying an application, and closing services at stop.
*/
class FlashAppLifecycleTest {
private static FlashConfiguration ephemeral() {
return FlashConfiguration.builder().port(0).host("127.0.0.1")
.shutdownDrainTimeoutMs(250).build();
}
// ── port() ───────────────────────────────────────────────────────────────
@Test
void exposesTheOsAssignedPortBeforeStart() {
FlashApp app = FlashApp.create(ephemeral());
try {
assertTrue(app.port() > 0, "port 0 should resolve to a real bound port");
assertEquals(List.of(app.port()), app.ports());
} finally {
app.stop().join();
}
}
@Test
void keepsTheSamePortAcrossStart() {
FlashApp app = FlashApp.create(ephemeral()).get("/ping", (req, res) -> "pong");
try {
int beforeStart = app.port();
app.start();
assertEquals(beforeStart, app.port());
} finally {
app.stop().join();
}
}
// ── override() ───────────────────────────────────────────────────────────
@Test
void overrideReplacesAnExistingBinding() {
FlashContext ctx = new FlashContext();
ctx.provide(Greeter.class, () -> "real");
ctx.override(Greeter.class, () -> "fake");
ctx.resolveAll();
assertEquals("fake", ctx.require(Greeter.class).greet());
}
@Test
void overrideAlsoDeclaresWhenNothingWasBound() {
FlashContext ctx = new FlashContext();
ctx.override(Greeter.class, () -> "fake");
ctx.resolveAll();
assertEquals("fake", ctx.require(Greeter.class).greet());
}
@Test
void overrideIsRejectedOnceDeclarationsAreClosed() {
FlashContext ctx = new FlashContext();
ctx.resolveAll();
assertThrows(IllegalStateException.class, () -> ctx.override(Greeter.class, () -> "fake"));
}
@Test
void lastInstalledExtensionWinsOverAnEarlierProvider() {
FlashApp app = FlashApp.create(ephemeral())
.install((registrar, ctx) -> ctx.provide(Greeter.class, () -> "real"))
.install((registrar, ctx) -> ctx.override(Greeter.class, () -> "fake"));
try {
app.start();
assertEquals("fake", app.ctx().require(Greeter.class).greet());
} finally {
app.stop().join();
}
}
// ── apply() ──────────────────────────────────────────────────────────────
@Test
void applyRunsImmediatelyWithAnOpenContextAndABoundPort() {
List<Integer> portSeenInsideConfigure = new ArrayList<>();
FlashApp app = FlashApp.create(ephemeral()).apply(configured -> {
portSeenInsideConfigure.add(configured.port());
configured.ctx().provide(Greeter.class, () -> "from-application");
configured.get("/hello", (req, res) -> "hi");
});
try {
assertEquals(List.of(app.port()), portSeenInsideConfigure);
app.start();
assertEquals("from-application", app.ctx().require(Greeter.class).greet());
} finally {
app.stop().join();
}
}
// ── onClose() ────────────────────────────────────────────────────────────
@Test
void stopRunsCloseCallbacksInReverseOrder() {
List<String> closed = new ArrayList<>();
FlashApp app = FlashApp.create(ephemeral()).apply(configured -> {
configured.ctx().onClose(() -> closed.add("first"));
configured.ctx().onClose(() -> closed.add("second"));
});
app.start();
app.stop().join();
assertEquals(List.of("second", "first"), closed);
}
@Test
void aFactoryCanRegisterItsOwnTeardownWhileResolving() {
AtomicInteger closes = new AtomicInteger();
FlashApp app = FlashApp.create(ephemeral()).apply(configured ->
configured.ctx().supply(Greeter.class, services -> {
services.onClose(closes::incrementAndGet);
return () -> "resolved";
}));
app.start();
assertEquals(0, closes.get(), "must not close while the app is live");
app.stop().join();
assertEquals(1, closes.get());
}
@Test
void aThrowingCloseCallbackDoesNotStopTheRest() {
List<String> closed = new ArrayList<>();
FlashApp app = FlashApp.create(ephemeral()).apply(configured -> {
configured.ctx().onClose(() -> closed.add("ran"));
configured.ctx().onClose(() -> { throw new IllegalStateException("boom"); });
});
app.start();
assertDoesNotThrow(() -> app.stop().join());
assertEquals(List.of("ran"), closed);
}
@Test
void closeCallbacksRunAtMostOnce() {
AtomicInteger closes = new AtomicInteger();
FlashApp app = FlashApp.create(ephemeral())
.apply(configured -> configured.ctx().onClose(closes::incrementAndGet));
app.start();
app.stop().join();
app.stop().join();
assertEquals(1, closes.get());
}
@FunctionalInterface
interface Greeter { String greet(); }
}