Feature/ext validation/request validation #15
@@ -7,6 +7,7 @@ import dev.relism.flash.routing.AbstractWsRouter;
|
||||
import dev.relism.flash.transport.TransportFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
@@ -28,6 +29,16 @@ public interface ServerHandle {
|
||||
/** Gracefully stops the server, draining active connections. */
|
||||
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,
|
||||
AbstractRouter httpRouter,
|
||||
AbstractWsRouter wsRouter) throws IOException {
|
||||
|
||||
@@ -22,6 +22,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
@@ -138,6 +139,16 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -166,7 +177,24 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
||||
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 ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/** Deterministic boot-time service graph, frozen before handlers are initialised. */
|
||||
@Slf4j
|
||||
public final class FlashContext {
|
||||
private enum State { DECLARING, RESOLVING, READY }
|
||||
|
||||
@@ -13,6 +16,7 @@ public final class FlashContext {
|
||||
private final List<AnnotationProcessor> processors = new ArrayList<>();
|
||||
private final List<RouteListener> routeListeners = 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 Deque<Class<?>> resolutionPath = new ArrayDeque<>();
|
||||
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")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
public <T> void supply(Class<T> type, Supplier<T> factory) {
|
||||
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. */
|
||||
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")
|
||||
public <T> T require(Class<T> type) {
|
||||
if (state == State.DECLARING)
|
||||
@@ -106,6 +142,21 @@ public final class FlashContext {
|
||||
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. */
|
||||
public void complete() {
|
||||
resolveAll();
|
||||
|
||||
@@ -46,6 +46,14 @@ public final class ServerLifecycle implements ServerHandle {
|
||||
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
|
||||
* {@link java.util.function.BooleanSupplier} so in-flight request loops can drain
|
||||
* 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(); }
|
||||
}
|
||||
Reference in New Issue
Block a user