refactor(core): make boot and middleware ordering deterministic
CI / Build & Test (push) Failing after 4m51s
CI / Build & Test (pull_request) Canceled after 23s

This commit is contained in:
Zakaria El Orche
2026-08-12 16:42:49 +00:00
parent d7f36a7aea
commit 891ef99b8e
51 changed files with 1395 additions and 504 deletions
@@ -1,7 +1,7 @@
package dev.relism.flash.extension;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.routing.Middleware;
import dev.relism.flash.routing.MiddlewareNode;
import java.util.List;
@@ -18,5 +18,5 @@ import java.util.List;
*/
@FunctionalInterface
public interface AnnotationProcessor {
List<Middleware> process(Class<? extends RequestHandler> handlerClass);
List<MiddlewareNode> process(Class<? extends RequestHandler> handlerClass);
}
@@ -1,32 +0,0 @@
package dev.relism.flash.extension;
/**
* Semantic execution phases for {@link FlashExtension#priority()}.
*
* <p>Phase determines the order in which annotation-processor middlewares are injected into
* the chain. Lower value = runs earlier (outermost wrapper = first at request time).
*
* <pre>
* Request ──► EARLY middlewares ──► DEFAULT middlewares ──► LATE middlewares ──► handler
* </pre>
*
* <p>Within the same phase, extensions execute in install order (sort is stable).
* Raw integers are valid for fine-grained ordering within a phase
* (e.g. {@code ExtensionPhase.EARLY.value + 10}).
*/
public enum ExtensionPhase {
/** Security guards, rate limiting — must short-circuit before expensive processing. */
EARLY(100),
/** Normal application extensions. Default when {@link FlashExtension#priority()} is not overridden. */
DEFAULT(500),
/** Observability, logging, diagnostics — must observe after all business logic. */
LATE(900);
/** The integer priority value used for sorting. */
public final int value;
ExtensionPhase(int value) { this.value = value; }
}
@@ -9,6 +9,8 @@ import dev.relism.flash.models.SimpleHandler;
import dev.relism.flash.routing.AbstractRouter;
import dev.relism.flash.routing.AbstractWsRouter;
import dev.relism.flash.routing.Middleware;
import dev.relism.flash.routing.MiddlewareGraph;
import dev.relism.flash.routing.MiddlewareNode;
import dev.relism.flash.routing.routers.fastpathrouter.FastPathRouterImpl;
import dev.relism.flash.routing.routers.fastpathrouter.FastPathWsRouterImpl;
import dev.relism.flash.websocket.WebSocketEndpoint;
@@ -19,7 +21,6 @@ import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
@@ -41,10 +42,9 @@ import java.util.function.Consumer;
*
* <h3>Startup sequence (both {@link #start()} and {@link #startAndBlock()})</h3>
* <ol>
* <li>Extensions sorted by {@link FlashExtension#priority()} — lower first.</li>
* <li>All {@link FlashExtension#provide} — register services, processors, listeners.</li>
* <li>All {@link FlashExtension#configure} declarations.</li>
* <li>{@link FlashContext#resolveAll()} — topo-sort, cycle detection.</li>
* <li>All {@link FlashExtension#routes} — routes registered, services available.</li>
* <li>Ready callbacks register routes with resolved services.</li>
* <li>Compile all routes into one flat FSM — zero prefix scanning at runtime.</li>
* <li>Accept loop started.</li>
* </ol>
@@ -62,7 +62,7 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
private final ServerHandle server;
private final FlashContext ctx = new FlashContext();
private final List<FlashExtension> extensions = new ArrayList<>();
private final List<Middleware> globalMiddlewares = new ArrayList<>();
private final List<MiddlewareNode> globalMiddlewares = new ArrayList<>();
private final List<RouteDefinition> deferredRoutes = new ArrayList<>();
private final List<WsRouteDefinition> deferredWsRoutes = new ArrayList<>();
@@ -131,10 +131,7 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
// ── Extensions ────────────────────────────────────────────────────────────
/**
* Registers an extension for two-phase installation at startup.
* Install order is irrelevant — all {@link FlashExtension#provide} calls complete
* before any {@link FlashExtension#routes} call begins.
* Extensions are sorted by {@link FlashExtension#priority()} before execution.
* Registers a declarative extension contribution.
*/
public FlashApp install(FlashExtension ext) {
extensions.add(ext);
@@ -174,7 +171,7 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
// ── FlashRegistrar impl ───────────────────────────────────────────────────
@Override
protected void addRoute(HttpMethod method, String path, RequestHandler handler, List<Middleware> mw) {
protected void addRoute(HttpMethod method, String path, RequestHandler handler, List<MiddlewareNode> mw) {
deferredRoutes.add(new RouteDefinition(
method, path, handler, List.of(), mw,
!(handler instanceof SimpleHandler), ctx, "/"));
@@ -186,27 +183,25 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
}
@Override
protected void addMiddleware(Middleware mw) { globalMiddlewares.add(mw); }
protected void addMiddleware(MiddlewareNode mw) { globalMiddlewares.add(mw); }
// ── Boot ─────────────────────────────────────────────────────────────────
private void boot() {
extensions.sort(Comparator.comparingInt(FlashExtension::priority));
extensions.forEach(e -> e.provide(ctx));
ctx.resolveAll();
extensions.forEach(e -> e.routes(this, ctx));
extensions.forEach(e -> e.configure(this, ctx));
ctx.complete();
compile();
compileWs();
router.compile();
wsRouter.compile();
}
// ── Compilation ──────────────────────────────────────────────────────────
private static final Middleware[] EMPTY_MW = new Middleware[0];
/** Middleware chain order per route: Global → Scope → Annotation → Explicit. */
private void compile() {
for (RouteDefinition def : deferredRoutes) {
List<Middleware> injected;
List<MiddlewareNode> injected;
if (def.classBasedHandler()) {
injected = def.ctx().processors().stream()
.flatMap(p -> p.process(def.handler().getClass()).stream())
@@ -215,7 +210,8 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
} else {
injected = List.of();
}
Middleware[] all = concat(globalMiddlewares, def.scopeMiddlewares(), injected, def.explicitMiddlewares());
Middleware[] all = MiddlewareGraph.order(def.method() + " " + def.path(),
concat(globalMiddlewares, def.scopeMiddlewares(), injected, def.explicitMiddlewares()));
emitEvent(def, all);
router.doRegister(def.method(), def.path(), def.handler(), all);
}
@@ -248,16 +244,12 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
listeners.forEach(l -> l.onRoute(event));
}
private static Middleware[] concat(List<Middleware> global, List<Middleware> scope,
List<Middleware> injected, List<Middleware> explicit) {
private static List<MiddlewareNode> concat(List<MiddlewareNode> global, List<MiddlewareNode> scope,
List<MiddlewareNode> injected, List<MiddlewareNode> explicit) {
int total = global.size() + scope.size() + injected.size() + explicit.size();
if (total == 0) return EMPTY_MW;
Middleware[] all = new Middleware[total];
int i = 0;
for (Middleware m : global) all[i++] = m;
for (Middleware m : scope) all[i++] = m;
for (Middleware m : injected) all[i++] = m;
for (Middleware m : explicit) all[i++] = m;
if (total == 0) return List.of();
List<MiddlewareNode> all = new ArrayList<>(total);
all.addAll(global); all.addAll(scope); all.addAll(injected); all.addAll(explicit);
return all;
}
}
@@ -1,176 +1,160 @@
package dev.relism.flash.extension;
import java.util.*;
import java.util.function.Function;
import java.util.function.Supplier;
/**
* Central service registry and boot-time hook coordinator.
*
* <p>Every handler, extension, and scope shares one (or a child of one) {@code FlashContext}.
* Three capabilities:
* <ol>
* <li><b>Service registry</b> — {@link #provide}/{@link #supply}/{@link #require}/{@link #find}.</li>
* <li><b>Annotation processors</b> — middleware injection from handler annotations at boot.</li>
* <li><b>Route listeners</b> — boot-time observation of the route graph.</li>
* </ol>
*
* <h3>Eager vs lazy registration</h3>
* <ul>
* <li>{@link #provide(Class, Object)} — instance is already constructed, registered immediately.</li>
* <li>{@link #supply(Class, Supplier)} — factory is registered; it runs once, at
* {@link #resolveAll()} time (called by {@link FlashApp#start()}) after all
* {@link FlashExtension#provide} phases complete. The factory may call
* {@link #require} for its own dependencies — the runtime resolves in topological
* order automatically and reports circular dependencies with the full cycle path.</li>
* </ul>
*
* <p>A child context (via {@link #child()}) inherits parent services and processors.
* Services provided on the child are scoped and invisible to the parent.
*/
public class FlashContext {
/** Deterministic boot-time service graph, frozen before handlers are initialised. */
public final class FlashContext {
private enum State { DECLARING, RESOLVING, READY }
private final FlashContext parent;
private final Map<Class<?>, Object> registry = new LinkedHashMap<>();
private final Map<Class<?>, Supplier<?>> pending = new LinkedHashMap<>();
private final List<AnnotationProcessor> processors = new ArrayList<>();
private final List<RouteListener> routeListeners = new ArrayList<>();
// Lazy caches — nulled whenever the corresponding list is mutated.
private final Map<Class<?>, Binding<?>> bindings = new LinkedHashMap<>();
private final List<AnnotationProcessor> processors = new ArrayList<>();
private final List<RouteListener> routeListeners = new ArrayList<>();
private final List<Runnable> readyCallbacks = new ArrayList<>();
private final List<FlashContext> children = new ArrayList<>();
private final Deque<Class<?>> resolutionPath = new ArrayDeque<>();
private State state = State.DECLARING;
private List<AnnotationProcessor> cachedProcessors;
private List<RouteListener> cachedListeners;
private List<RouteListener> cachedListeners;
// DFS stack — tracks in-progress resolutions to detect circular dependencies.
private final LinkedHashSet<Class<?>> resolutionStack = new LinkedHashSet<>();
public FlashContext() { this.parent = null; }
public FlashContext() { parent = null; }
private FlashContext(FlashContext parent) { this.parent = parent; }
/** Creates a child context that inherits this context's services and processors. */
public FlashContext child() { return new FlashContext(this); }
public FlashContext child() {
requireDeclaring();
FlashContext child = new FlashContext(this);
children.add(child);
return child;
}
// ── Service registry ─────────────────────────────────────────────────────
/** Registers an already-constructed {@code instance} under {@code type}. */
/** Binds an already-created singleton. Duplicate bindings are always an error. */
public <T> void provide(Class<T> type, T instance) {
registry.put(type, instance);
declare(type, new Binding<>(type, List.of(), ignored -> Objects.requireNonNull(instance, "instance")));
}
/**
* Registers a lazy factory for {@code type}. The factory runs once at
* {@link #resolveAll()} time (or on the first {@link #require} call for this type)
* and may call {@link #require} for its own dependencies — topological order
* is resolved automatically.
*
* <pre>{@code
* ctx.supply(JwtValidator.class, () ->
* new JwtValidator(ctx.require(OidcProviderMetadata.class).jwksUri()));
* }</pre>
*/
/** Declares a no-dependency boot factory. */
public <T> void supply(Class<T> type, Supplier<T> factory) {
pending.put(type, factory);
declare(type, new Binding<>(type, List.of(), ignored -> factory.get()));
}
/**
* Returns the service for {@code type}. Checks own scope first, then parent chain.
* Lazy-registered types are resolved on first access. Circular dependencies throw
* {@link IllegalStateException} with the full cycle path.
*
* @throws IllegalStateException if the service is not found anywhere in the context chain
*/
/** Declares a boot factory and its complete dependency set. */
public <T> void supply(Class<T> type, ServiceFactory<T> factory, Class<?>... dependencies) {
Objects.requireNonNull(factory, "factory");
declare(type, new Binding<>(type, List.of(dependencies), factory));
}
/** One-dependency factory with no application-side context lookup. */
public <A, T> void supply(Class<T> type, Class<A> dependency, Function<A, T> factory) {
supply(type, ignored -> factory.apply(require(dependency)), dependency);
}
/** Registers work materialised after all services are resolved. */
public void onReady(Runnable callback) { requireDeclaring(); readyCallbacks.add(Objects.requireNonNull(callback)); }
@SuppressWarnings("unchecked")
public <T> T require(Class<T> type) {
Object val = registry.get(type);
if (val != null) return (T) val;
if (pending.containsKey(type)) return resolve(type);
if (state == State.DECLARING)
throw new IllegalStateException("Service graph is still being declared; use FlashContext.onReady(...)");
Binding<?> binding = bindings.get(type);
if (binding != null) {
verifyDeclaredDependency(type);
return (T) resolve((Binding<Object>) binding);
}
if (parent != null) return parent.require(type);
throw new IllegalStateException(
"Service not found: " + type.getSimpleName() +
" — register it via FlashContext.provide()/supply() or install the required extension");
throw new IllegalStateException("No provider declared for " + type.getName() + dependencyTrace());
}
/** Returns the service for {@code type}, or empty if not found in this scope or any parent. */
@SuppressWarnings("unchecked")
public <T> Optional<T> find(Class<T> type) {
Object val = registry.get(type);
if (val != null) return Optional.of((T) val);
if (pending.containsKey(type)) return Optional.of(resolve(type));
return parent != null ? parent.find(type) : Optional.empty();
if (state == State.DECLARING)
throw new IllegalStateException("Service graph is still being declared; use FlashContext.onReady(...)");
if (bindings.containsKey(type)) return Optional.of(require(type));
return parent == null ? Optional.empty() : parent.find(type);
}
/** Alias for {@link #find} — prefer when semantics are "this may or may not exist". */
public <T> Optional<T> optional(Class<T> type) { return find(type); }
/**
* Eagerly resolves all pending lazy suppliers in topological order.
* Called once by {@link FlashApp#start()} after all {@link FlashExtension#provide}
* phases complete. Any circular dependency is reported with the full cycle path.
*/
void resolveAll() {
new ArrayList<>(pending.keySet()).forEach(this::resolve);
}
@SuppressWarnings("unchecked")
private <T> T resolve(Class<?> type) {
Object already = registry.get(type);
if (already != null) return (T) already; // resolved during an earlier DFS branch
if (!resolutionStack.add(type)) {
// type is already on the current DFS path → circular dependency
List<Class<?>> cycle = new ArrayList<>(resolutionStack);
cycle.add(type);
StringBuilder msg = new StringBuilder("Circular dependency: ");
for (int i = 0; i < cycle.size(); i++) {
if (i > 0) msg.append("");
msg.append(cycle.get(i).getSimpleName());
}
throw new IllegalStateException(msg.toString());
}
Supplier<?> factory = pending.get(type);
Object instance = factory.get(); // recursive require() calls happen here
registry.put(type, instance);
pending.remove(type);
resolutionStack.remove(type);
return (T) instance;
}
// ── Annotation processors ────────────────────────────────────────────────
/** Registers an {@link AnnotationProcessor}. Processors run once per class-based handler at boot. */
public void addAnnotationProcessor(AnnotationProcessor processor) {
processors.add(processor);
cachedProcessors = null;
requireDeclaring(); processors.add(Objects.requireNonNull(processor)); cachedProcessors = null;
}
public void addRouteListener(RouteListener listener) {
requireDeclaring(); routeListeners.add(Objects.requireNonNull(listener)); cachedListeners = null;
}
/** All processors visible from this context: parent-first, then own. Cached after first call. */
List<AnnotationProcessor> processors() {
if (cachedProcessors != null) return cachedProcessors;
if (parent == null) return cachedProcessors = List.copyOf(processors);
List<AnnotationProcessor> p = parent.processors();
if (processors.isEmpty()) return cachedProcessors = p;
List<AnnotationProcessor> merged = new ArrayList<>(p.size() + processors.size());
merged.addAll(p);
merged.addAll(processors);
return cachedProcessors = List.copyOf(merged);
List<AnnotationProcessor> all = parent == null ? new ArrayList<>() : new ArrayList<>(parent.processors());
all.addAll(processors); return cachedProcessors = List.copyOf(all);
}
// ── Route listeners ──────────────────────────────────────────────────────
/** Registers a boot-time {@link RouteListener}. Zero overhead on the request hot-path. */
public void addRouteListener(RouteListener listener) {
routeListeners.add(listener);
cachedListeners = null;
}
/** All route listeners visible from this context: parent-first, then own. Cached after first call. */
List<RouteListener> routeListeners() {
if (cachedListeners != null) return cachedListeners;
if (parent == null) return cachedListeners = List.copyOf(routeListeners);
List<RouteListener> p = parent.routeListeners();
if (routeListeners.isEmpty()) return cachedListeners = p;
List<RouteListener> merged = new ArrayList<>(p.size() + routeListeners.size());
merged.addAll(p);
merged.addAll(routeListeners);
return cachedListeners = List.copyOf(merged);
List<RouteListener> all = parent == null ? new ArrayList<>() : new ArrayList<>(parent.routeListeners());
all.addAll(routeListeners); return cachedListeners = List.copyOf(all);
}
}
void resolveAll() {
if (state != State.DECLARING) throw new IllegalStateException("Service graph has already been closed");
state = State.RESOLVING;
for (Binding<?> binding : bindings.values()) resolveUnchecked(binding);
for (FlashContext child : children) child.resolveAll();
state = State.READY;
}
void runReadyCallbacks() {
if (state != State.READY) throw new IllegalStateException("Service graph is not ready");
for (Runnable callback : List.copyOf(readyCallbacks)) callback.run();
readyCallbacks.clear();
for (FlashContext child : children) child.runReadyCallbacks();
}
/** Completes graph resolution and runs all deferred materialisation callbacks once. */
public void complete() {
resolveAll();
runReadyCallbacks();
}
private <T> void declare(Class<T> type, Binding<T> binding) {
requireDeclaring(); Objects.requireNonNull(type, "type");
if (bindings.putIfAbsent(type, binding) != null)
throw new IllegalStateException("Duplicate provider declared for " + type.getName());
}
private void requireDeclaring() {
if (state != State.DECLARING) throw new IllegalStateException("Flash service declarations are closed");
}
@SuppressWarnings("unchecked") private void resolveUnchecked(Binding<?> binding) { resolve((Binding<Object>) binding); }
private <T> T resolve(Binding<T> binding) {
if (binding.instance != null) return binding.instance;
if (binding.resolving) throw cycle(binding.type);
binding.resolving = true; resolutionPath.addLast(binding.type);
try {
for (Class<?> dependency : binding.dependencies) require(dependency);
return binding.instance = Objects.requireNonNull(binding.factory.create(this),
() -> "Provider returned null for " + binding.type.getName());
} finally {
resolutionPath.removeLast(); binding.resolving = false;
}
}
private IllegalStateException cycle(Class<?> type) {
StringBuilder out = new StringBuilder("Circular service dependency: ");
for (Class<?> node : resolutionPath) out.append(node.getSimpleName()).append(" -> ");
return new IllegalStateException(out.append(type.getSimpleName()).toString());
}
private String dependencyTrace() {
return resolutionPath.isEmpty() ? "" : " (required while creating " + resolutionPath.peekLast().getName() + ')';
}
private void verifyDeclaredDependency(Class<?> type) {
if (resolutionPath.isEmpty()) return;
Class<?> owner = resolutionPath.peekLast();
Binding<?> binding = bindings.get(owner);
if (binding != null && !binding.dependencies.contains(type))
throw new IllegalStateException(owner.getName() + " requested undeclared dependency " + type.getName());
}
@FunctionalInterface public interface ServiceFactory<T> { T create(FlashContext services); }
private static final class Binding<T> {
final Class<T> type; final List<Class<?>> dependencies; final ServiceFactory<T> factory;
T instance; boolean resolving;
Binding(Class<T> type, List<Class<?>> dependencies, ServiceFactory<T> factory) {
this.type = type; this.dependencies = dependencies; this.factory = factory;
}
}
}
@@ -1,70 +1,17 @@
package dev.relism.flash.extension;
/**
* Two-phase contract for all Flash extensions.
* One declarative contribution to a Flash application.
*
* <p>Extension lifecycle inside {@link FlashApp#start()}:
* <ol>
* <li>Extensions are sorted by {@link #priority()} — lower value runs first.</li>
* <li><b>Provide phase</b> — {@link #provide(FlashContext)} is called for <em>all</em>
* installed extensions. Use this phase to register services, annotation processors,
* and route listeners. Never call {@link FlashContext#require} here.</li>
* <li>Context resolution — {@link FlashContext#resolveAll()} performs topological
* resolution of lazy suppliers. Circular or missing dependencies fail here with
* a clear message before any request is served.</li>
* <li><b>Routes phase</b> — {@link #routes(FlashRegistrar, FlashContext)} is called for
* all extensions. All services are resolved; {@link FlashContext#require} is safe.</li>
* </ol>
*
* <h3>Priority and middleware ordering</h3>
* {@link #priority()} controls the order annotation processors are registered, which
* determines the annotation-layer middleware chain position:
* <pre>
* Request ──► EARLY processors' mw ──► DEFAULT processors' mw ──► LATE processors' mw ──► handler
* </pre>
* Use {@link ExtensionPhase} constants for semantic ordering:
* <pre>{@code
* @Override public int priority() { return ExtensionPhase.EARLY.value; }
* }</pre>
*
* <h3>Example</h3>
* <pre>{@code
* public class MetricsExtension implements FlashExtension {
*
* @Override public int priority() { return ExtensionPhase.LATE.value; }
*
* @Override
* public void provide(FlashContext ctx) {
* ctx.provide(MetricsRegistry.class, new PromMetricsRegistry());
* }
*
* @Override
* public void routes(FlashRegistrar<?> app, FlashContext ctx) {
* app.get("/metrics", (req, res) -> ctx.require(MetricsRegistry.class).scrape());
* }
* }
* }</pre>
* <p>Extensions never control lifecycle ordering. During {@link #configure}, they declare
* services, processors, listeners and ready callbacks. Flash closes declarations, validates and
* resolves the complete service graph, then executes ready callbacks to materialise routes.
*/
@FunctionalInterface
public interface FlashExtension {
/**
* Phase 1 — register services and processors.
* Safe: {@link FlashContext#provide}, {@link FlashContext#supply},
* {@link FlashContext#addAnnotationProcessor}, {@link FlashContext#addRouteListener}.
* Unsafe: {@link FlashContext#require} (services not yet resolved).
* Declares this extension's contribution. {@code ctx.require(...)} is intentionally illegal
* here: work needing resolved services belongs in {@link FlashContext#onReady(Runnable)}.
*/
default void provide(FlashContext ctx) {}
/**
* Phase 2 — register routes. All services are fully resolved.
* {@link FlashContext#require} is safe here.
*/
default void routes(FlashRegistrar<?> app, FlashContext ctx) {}
/**
* Execution priority. Lower = earlier in the annotation middleware chain.
* Tie-breaking: same value → install order (sort is stable).
* Default: {@link ExtensionPhase#DEFAULT} (500).
*/
default int priority() { return ExtensionPhase.DEFAULT.value; }
void configure(FlashRegistrar<?> app, FlashContext ctx);
}
@@ -6,11 +6,14 @@ import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.SimpleHandler;
import dev.relism.flash.websocket.WebSocketEndpoint;
import dev.relism.flash.routing.Middleware;
import dev.relism.flash.routing.MiddlewareKey;
import dev.relism.flash.routing.MiddlewareNode;
import dev.relism.flash.routing.Route;
import dev.relism.flash.routing.Routes;
import dev.relism.flash.routing.Ws;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
/**
* Common route-registration surface shared by {@link FlashApp} and {@link FlashScope}.
@@ -24,14 +27,15 @@ import java.util.List;
* app.get("/admin", handler, oidc.requireRole("admin"), rateLimiter)
* }</pre>
*
* <p>{@link FlashExtension#routes} receives a {@code FlashRegistrar<?>} for route
* registration. Extension installation ({@code install()}) is only available on
* <p>Extensions receive a {@link FlashApp} during their single configure declaration.
* Extension installation ({@code install()}) is only available on
* {@link FlashApp} — scoped install is intentionally unsupported.
*
* @param <SELF> concrete registrar type — enables fluent chaining without casting
*/
@SuppressWarnings("unchecked")
public abstract class FlashRegistrar<SELF extends FlashRegistrar<SELF>> {
private static final AtomicLong INLINE_KEYS = new AtomicLong();
// ── HTTP method registration ──────────────────────────────────────────────
@@ -47,6 +51,18 @@ public abstract class FlashRegistrar<SELF extends FlashRegistrar<SELF>> {
public final SELF purge (String path, SimpleHandler.FunctionalHandler h, Middleware... mw) { return route(HttpMethod.PURGE, path, h, mw); }
public final SELF query (String path, SimpleHandler.FunctionalHandler h, Middleware... mw) { return route(HttpMethod.QUERY, path, h, mw); }
public final SELF getWith (String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.GET, path, h, mw); }
public final SELF postWith (String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.POST, path, h, mw); }
public final SELF putWith (String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.PUT, path, h, mw); }
public final SELF deleteWith (String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.DELETE, path, h, mw); }
public final SELF patchWith (String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.PATCH, path, h, mw); }
public final SELF optionsWith(String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.OPTIONS, path, h, mw); }
public final SELF headWith (String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.HEAD, path, h, mw); }
public final SELF traceWith (String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.TRACE, path, h, mw); }
public final SELF connectWith(String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.CONNECT, path, h, mw); }
public final SELF purgeWith (String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.PURGE, path, h, mw); }
public final SELF queryWith (String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.QUERY, path, h, mw); }
// ── Middleware ────────────────────────────────────────────────────────────
/**
@@ -56,7 +72,13 @@ public abstract class FlashRegistrar<SELF extends FlashRegistrar<SELF>> {
* Order-independent: middleware is resolved at {@link FlashApp#start()}.
*/
public final SELF use(Middleware... middlewares) {
for (Middleware m : middlewares) addMiddleware(m);
for (Middleware m : middlewares) addMiddleware(inline(m));
return (SELF) this;
}
/** Adds named middleware nodes whose ordering constraints are compiled at boot. */
public final SELF use(MiddlewareNode... middlewares) {
for (MiddlewareNode m : middlewares) addMiddleware(m);
return (SELF) this;
}
@@ -93,18 +115,29 @@ public abstract class FlashRegistrar<SELF extends FlashRegistrar<SELF>> {
* Subclasses may prepend a namespace prefix and inject scope middlewares before storing.
*/
protected abstract void addRoute(HttpMethod method, String path,
RequestHandler handler, List<Middleware> mw);
RequestHandler handler, List<MiddlewareNode> mw);
protected abstract void addWsRoute(String path, WebSocketEndpoint endpoint);
/** Registers a middleware in this registrar's own scope (global or scope-level). */
protected abstract void addMiddleware(Middleware mw);
protected abstract void addMiddleware(MiddlewareNode mw);
private SELF route(HttpMethod method, String path, SimpleHandler.FunctionalHandler h, Middleware[] mw) {
MiddlewareNode[] nodes = new MiddlewareNode[mw.length];
for (int i = 0; i < mw.length; i++) nodes[i] = inline(mw[i]);
addRoute(method, path, new SimpleHandler(h), mw.length == 0 ? List.of() : List.of(nodes));
return (SELF) this;
}
private SELF route(HttpMethod method, String path, SimpleHandler.FunctionalHandler h, MiddlewareNode[] mw) {
addRoute(method, path, new SimpleHandler(h), mw.length == 0 ? List.of() : List.of(mw));
return (SELF) this;
}
private static MiddlewareNode inline(Middleware middleware) {
return MiddlewareNode.of(MiddlewareKey.of("flash.inline." + INLINE_KEYS.incrementAndGet()), middleware);
}
protected static RequestHandler instantiate(Class<?> cls) {
try { return (RequestHandler) cls.getDeclaredConstructor().newInstance(); }
catch (Exception e) {
@@ -3,7 +3,7 @@ package dev.relism.flash.extension;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.SimpleHandler;
import dev.relism.flash.routing.Middleware;
import dev.relism.flash.routing.MiddlewareNode;
import dev.relism.flash.routing.PathUtils;
import dev.relism.flash.websocket.WebSocketEndpoint;
@@ -28,7 +28,7 @@ public final class FlashScope extends FlashRegistrar<FlashScope> {
private final String namespace;
private final FlashContext ctx;
private final List<Middleware> scopeMiddlewares = new ArrayList<>();
private final List<MiddlewareNode> scopeMiddlewares = new ArrayList<>();
private final List<RouteDefinition> deferredRoutes = new ArrayList<>();
private final List<WsRouteDefinition> deferredWsRoutes = new ArrayList<>();
@@ -44,8 +44,8 @@ public final class FlashScope extends FlashRegistrar<FlashScope> {
// ── FlashRegistrar impl ───────────────────────────────────────────────────
@Override
protected void addRoute(HttpMethod method, String path, RequestHandler handler, List<Middleware> mw) {
List<Middleware> scopeMw = scopeMiddlewares.isEmpty() ? List.of() : List.copyOf(scopeMiddlewares);
protected void addRoute(HttpMethod method, String path, RequestHandler handler, List<MiddlewareNode> mw) {
List<MiddlewareNode> scopeMw = scopeMiddlewares.isEmpty() ? List.of() : List.copyOf(scopeMiddlewares);
deferredRoutes.add(new RouteDefinition(
method, ns(path), handler, scopeMw, mw,
!(handler instanceof SimpleHandler), ctx, namespace));
@@ -57,7 +57,7 @@ public final class FlashScope extends FlashRegistrar<FlashScope> {
}
@Override
protected void addMiddleware(Middleware mw) { scopeMiddlewares.add(mw); }
protected void addMiddleware(MiddlewareNode mw) { scopeMiddlewares.add(mw); }
// ── Internal (called by FlashApp.mount) ───────────────────────────────────
@@ -2,7 +2,7 @@ package dev.relism.flash.extension;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.routing.Middleware;
import dev.relism.flash.routing.MiddlewareNode;
import java.util.List;
@@ -26,8 +26,8 @@ record RouteDefinition(
HttpMethod method,
String path,
RequestHandler handler,
List<Middleware> scopeMiddlewares,
List<Middleware> explicitMiddlewares,
List<MiddlewareNode> scopeMiddlewares,
List<MiddlewareNode> explicitMiddlewares,
boolean classBasedHandler,
FlashContext ctx,
String namespace
@@ -32,6 +32,9 @@ import java.nio.charset.StandardCharsets;
*/
public abstract class AbstractRouter {
/** Eagerly validates and compiles this route graph before traffic is accepted. */
public void compile() {}
// Pre-encoded prod JSON error bodies — zero allocation on error paths.
private static final byte[] JSON_404 = "{\"error\":\"Not Found\",\"status\":404}"
.getBytes(StandardCharsets.UTF_8);
@@ -8,6 +8,9 @@ import dev.relism.flash.websocket.WebSocketHandler;
public abstract class AbstractWsRouter {
/** Eagerly validates and compiles this WebSocket route graph before traffic is accepted. */
public void compile() {}
public final AbstractWsRouter register(HttpMethod method, String path, WebSocketHandler handler) {
return addRoute(method, PathUtils.sanitize(path), handler);
}
@@ -0,0 +1,55 @@
package dev.relism.flash.routing;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.SimpleHandler;
import java.util.*;
/** Boot-only DAG compiler for a route's middleware nodes. */
public final class MiddlewareGraph {
private MiddlewareGraph() {}
public static Middleware[] order(String route, List<MiddlewareNode> nodes) {
if (nodes.isEmpty()) return new Middleware[0];
Map<MiddlewareKey, Integer> index = new LinkedHashMap<>();
for (int i = 0; i < nodes.size(); i++) {
MiddlewareKey key = nodes.get(i).key();
if (index.putIfAbsent(key, i) != null)
throw new IllegalStateException("Duplicate middleware " + key.value() + " on " + route);
}
List<Set<Integer>> outgoing = new ArrayList<>(nodes.size());
int[] incoming = new int[nodes.size()];
for (int i = 0; i < nodes.size(); i++) outgoing.add(new LinkedHashSet<>());
for (int source = 0; source < nodes.size(); source++) {
for (MiddlewareNode.Constraint c : nodes.get(source).constraints()) {
Integer target = index.get(c.target());
if (target == null) {
if (c.required()) throw new IllegalStateException("Middleware " + nodes.get(source).key().value()
+ " on " + route + " requires " + c.target().value() + " to be present");
continue;
}
int from = c.relation() == MiddlewareNode.Relation.AFTER ? target : source;
int to = c.relation() == MiddlewareNode.Relation.AFTER ? source : target;
if (outgoing.get(from).add(to)) incoming[to]++;
}
}
PriorityQueue<Integer> ready = new PriorityQueue<>();
for (int i = 0; i < incoming.length; i++) if (incoming[i] == 0) ready.add(i);
Middleware[] ordered = new Middleware[nodes.size()];
int out = 0;
while (!ready.isEmpty()) {
int current = ready.remove();
ordered[out++] = nodes.get(current).middleware();
for (int next : outgoing.get(current)) if (--incoming[next] == 0) ready.add(next);
}
if (out != nodes.size()) throw new IllegalStateException("Middleware ordering cycle on " + route);
return ordered;
}
/** Pre-composes a sorted chain once at boot. */
public static RequestHandler compose(RequestHandler handler, Middleware[] ordered) {
RequestHandler current = handler;
for (int i = ordered.length - 1; i >= 0; i--) current = new SimpleHandler(ordered[i].wrap(current));
return current;
}
}
@@ -0,0 +1,11 @@
package dev.relism.flash.routing;
import java.util.Objects;
/** Stable boot-time identity of a middleware node. Never consulted while handling a request. */
public record MiddlewareKey(String value) {
public MiddlewareKey {
if (value == null || value.isBlank()) throw new IllegalArgumentException("Middleware key cannot be blank");
}
public static MiddlewareKey of(String value) { return new MiddlewareKey(value); }
}
@@ -0,0 +1,35 @@
package dev.relism.flash.routing;
import java.util.*;
/**
* A named middleware contribution and its ordering constraints.
*
* <p>Constraints are resolved only while Flash compiles a route. The resulting handler chain
* contains no keys, graphs, ordering checks or additional request-path allocations.
*/
public final class MiddlewareNode {
private final MiddlewareKey key;
private final Middleware middleware;
private final List<Constraint> constraints = new ArrayList<>();
private MiddlewareNode(MiddlewareKey key, Middleware middleware) {
this.key = Objects.requireNonNull(key, "key");
this.middleware = Objects.requireNonNull(middleware, "middleware");
}
public static MiddlewareNode of(MiddlewareKey key, Middleware middleware) { return new MiddlewareNode(key, middleware); }
public MiddlewareNode after(MiddlewareKey key) { constraints.add(new Constraint(key, Relation.AFTER, true)); return this; }
public MiddlewareNode afterIfPresent(MiddlewareKey key) { constraints.add(new Constraint(key, Relation.AFTER, false)); return this; }
public MiddlewareNode before(MiddlewareKey key) { constraints.add(new Constraint(key, Relation.BEFORE, true)); return this; }
public MiddlewareNode beforeIfPresent(MiddlewareKey key) { constraints.add(new Constraint(key, Relation.BEFORE, false)); return this; }
public MiddlewareKey key() { return key; }
Middleware middleware() { return middleware; }
List<Constraint> constraints() { return List.copyOf(constraints); }
enum Relation { BEFORE, AFTER }
record Constraint(MiddlewareKey target, Relation relation, boolean required) {
Constraint { Objects.requireNonNull(target, "target"); }
}
}
@@ -92,4 +92,7 @@ public class FastPathRouterImpl extends AbstractRouter {
}
}
}
@Override
public void compile() { ensureCompiled(); }
}
@@ -56,6 +56,9 @@ public final class FastPathWsRouterImpl extends AbstractWsRouter {
}
}
@Override
public void compile() { ensureCompiled(); }
private static final class Context {
private static final ThreadLocal<MatchResult<WebSocketHandler>> RESULT =
ThreadLocal.withInitial(() -> new MatchResult<>(32, 128));
@@ -0,0 +1,46 @@
package dev.relism.flash.extension;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class FlashContextTest {
@Test
void resolvesDeclaredGraphRegardlessOfDeclarationOrder() {
FlashContext ctx = new FlashContext();
ctx.supply(Service.class, Dependency.class, Service::new);
ctx.provide(Dependency.class, new Dependency());
ctx.resolveAll();
assertNotNull(ctx.require(Service.class).dependency);
}
@Test
void rejectsCircularDeclaredGraphBeforeReadyCallbacks() {
FlashContext ctx = new FlashContext();
ctx.supply(Left.class, services -> new Left(), Right.class);
ctx.supply(Right.class, services -> new Right(), Left.class);
IllegalStateException error = assertThrows(IllegalStateException.class, ctx::resolveAll);
assertEquals("Circular service dependency: Left -> Right -> Left", error.getMessage());
}
@Test
void rejectsAFactoryLookupThatWasNotDeclared() {
FlashContext ctx = new FlashContext();
ctx.provide(Dependency.class, new Dependency());
ctx.supply(Service.class, services -> new Service(services.require(Dependency.class)));
IllegalStateException error = assertThrows(IllegalStateException.class, ctx::resolveAll);
assertTrue(error.getMessage().contains("undeclared dependency"));
}
private static final class Dependency {}
private static final class Service { final Dependency dependency; Service(Dependency dependency) { this.dependency = dependency; } }
private static final class Left {}
private static final class Right {}
}
@@ -0,0 +1,42 @@
package dev.relism.flash.routing;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class MiddlewareGraphTest {
private static final Middleware NOOP = next -> next::handle;
@Test
void ordersNodesFromConstraintsNotRegistrationOrder() {
MiddlewareKey auth = MiddlewareKey.of("auth");
MiddlewareKey audit = MiddlewareKey.of("audit");
Middleware authMiddleware = next -> next::handle;
Middleware auditMiddleware = next -> next::handle;
MiddlewareNode auditNode = MiddlewareNode.of(audit, auditMiddleware).after(auth);
MiddlewareNode authNode = MiddlewareNode.of(auth, authMiddleware);
Middleware[] ordered = MiddlewareGraph.order("GET /", List.of(auditNode, authNode));
assertSame(authMiddleware, ordered[0]);
assertSame(auditMiddleware, ordered[1]);
}
@Test
void rejectsAbsentRequiredPredecessor() {
MiddlewareNode audit = MiddlewareNode.of(MiddlewareKey.of("audit"), NOOP)
.after(MiddlewareKey.of("auth"));
assertThrows(IllegalStateException.class, () -> MiddlewareGraph.order("GET /", List.of(audit)));
}
@Test
void acceptsAbsentOptionalPredecessor() {
MiddlewareNode audit = MiddlewareNode.of(MiddlewareKey.of("audit"), NOOP)
.afterIfPresent(MiddlewareKey.of("auth"));
assertEquals(1, MiddlewareGraph.order("GET /", List.of(audit)).length);
}
}