refactor(core): make boot and middleware ordering deterministic
This commit is contained in:
@@ -65,24 +65,24 @@ app.get("/users/{id}", (req, res) -> {
|
|||||||
|
|
||||||
### Class-based handlers
|
### Class-based handlers
|
||||||
|
|
||||||
Extend `RequestHandler` (or a subclass like `JacksonHandler`) and annotate with `@Route`:
|
Extend `RequestHandler`, annotate it, then scan its package. Dependencies are cached in
|
||||||
|
`onInit()` after Flash has resolved its complete boot-time service graph:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
@Route(method = HttpMethod.GET, path = "/api/users")
|
@GET("/api/users")
|
||||||
public class ListUsers extends JacksonHandler {
|
public class ListUsers extends RequestHandler {
|
||||||
@Override
|
private UserService users;
|
||||||
public Object handle(Request req, Response res) throws Exception {
|
|
||||||
return json(res, List.of("alice", "bob"));
|
@Override protected void onInit() { users = require(UserService.class); }
|
||||||
}
|
@Override public Object handle(Request req, Response res) { return users.list(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register:
|
app.scan("dev.example.api");
|
||||||
app.register(new ListUsers());
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Middleware
|
### Middleware
|
||||||
|
|
||||||
Apply middleware via `.with()` on the `RouteHandle` returned by any registration call:
|
Apply middleware at registration. Flash composes the final chain at boot:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
Middleware authCheck = next -> (req, res) -> {
|
Middleware authCheck = next -> (req, res) -> {
|
||||||
@@ -91,14 +91,13 @@ Middleware authCheck = next -> (req, res) -> {
|
|||||||
return next.handle(req, res);
|
return next.handle(req, res);
|
||||||
};
|
};
|
||||||
|
|
||||||
app.get("/secure", (req, res) -> "secret data")
|
app.get("/secure", (req, res) -> "secret data", authCheck);
|
||||||
.with(authCheck);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Multiple middlewares are composed outermost-first (left-to-right in the call):
|
Multiple middlewares are composed outermost-first (left-to-right in the call):
|
||||||
|
|
||||||
```java
|
```java
|
||||||
app.get("/admin", handler).with(logging, auth, rateLimit);
|
app.get("/admin", handler, logging, auth, rateLimit);
|
||||||
// execution order: logging → auth → rateLimit → handler
|
// execution order: logging → auth → rateLimit → handler
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -120,22 +119,22 @@ processors, services):
|
|||||||
```java
|
```java
|
||||||
app.mount("/api", scope -> {
|
app.mount("/api", scope -> {
|
||||||
scope.get("/health", (req, res) -> "ok"); // → GET /api/health
|
scope.get("/health", (req, res) -> "ok"); // → GET /api/health
|
||||||
scope.register(new UserHandler()); // @Route(path="/users") → GET /api/users
|
|
||||||
scope.scan("dev.example.api");
|
scope.scan("dev.example.api");
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## Extensions
|
## Extensions
|
||||||
|
|
||||||
Extensions are installed before route registration. Each extension receives the `FlashRegistrar`
|
Extensions have one declarative `configure` method. They declare services, processors and route
|
||||||
and `FlashContext` — it can register routes, expose services, and register annotation processors.
|
callbacks; Flash resolves the complete graph, materialises routes, compiles both routers, then
|
||||||
|
opens listeners. Extension install order never makes a service “not ready”.
|
||||||
|
|
||||||
```java
|
```java
|
||||||
FlashApp.create(8080)
|
FlashApp.create(8080)
|
||||||
.install(new JacksonExtension())
|
.install(new JacksonExtension())
|
||||||
.install(new OpenApiExtension("/openapi", "My API", "1.0.0"))
|
.install(new OpenApiExtension("/openapi", "My API", "1.0.0"))
|
||||||
.install(new OidcExtension(oidcConfig))
|
.install(new OidcExtension(oidcConfig))
|
||||||
.register(new MyHandler())
|
.scan("dev.example.handlers")
|
||||||
.start();
|
.start();
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+6
-8
@@ -4,9 +4,11 @@ import dev.relism.flash.ext.data.core.Tx;
|
|||||||
import dev.relism.flash.ext.data.core.TxDefinition;
|
import dev.relism.flash.ext.data.core.TxDefinition;
|
||||||
import dev.relism.flash.ext.data.core.TxManager;
|
import dev.relism.flash.ext.data.core.TxManager;
|
||||||
import dev.relism.flash.ext.data.core.TransactionPropagation;
|
import dev.relism.flash.ext.data.core.TransactionPropagation;
|
||||||
import dev.relism.flash.extension.ExtensionPhase;
|
import dev.relism.flash.extension.FlashRegistrar;
|
||||||
import dev.relism.flash.extension.FlashContext;
|
import dev.relism.flash.extension.FlashContext;
|
||||||
import dev.relism.flash.extension.FlashExtension;
|
import dev.relism.flash.extension.FlashExtension;
|
||||||
|
import dev.relism.flash.routing.MiddlewareKey;
|
||||||
|
import dev.relism.flash.routing.MiddlewareNode;
|
||||||
import dev.relism.flash.routing.Middleware;
|
import dev.relism.flash.routing.Middleware;
|
||||||
import jakarta.transaction.Transactional;
|
import jakarta.transaction.Transactional;
|
||||||
|
|
||||||
@@ -14,6 +16,7 @@ import java.util.List;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
public final class DataExtension implements FlashExtension {
|
public final class DataExtension implements FlashExtension {
|
||||||
|
private static final MiddlewareKey TRANSACTION = MiddlewareKey.of("flash.data.transaction");
|
||||||
private final TxManager txManager;
|
private final TxManager txManager;
|
||||||
private final Tx tx;
|
private final Tx tx;
|
||||||
|
|
||||||
@@ -23,7 +26,7 @@ public final class DataExtension implements FlashExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void provide(FlashContext ctx) {
|
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
ctx.provide(Tx.class, tx);
|
ctx.provide(Tx.class, tx);
|
||||||
ctx.provide(TxManager.class, txManager);
|
ctx.provide(TxManager.class, txManager);
|
||||||
ctx.addAnnotationProcessor(handlerClass -> {
|
ctx.addAnnotationProcessor(handlerClass -> {
|
||||||
@@ -36,15 +39,10 @@ public final class DataExtension implements FlashExtension {
|
|||||||
Middleware middleware = next -> (req, res) -> {
|
Middleware middleware = next -> (req, res) -> {
|
||||||
return tx.call(definition, () -> next.handle(req, res));
|
return tx.call(definition, () -> next.handle(req, res));
|
||||||
};
|
};
|
||||||
return List.of(middleware);
|
return List.of(MiddlewareNode.of(TRANSACTION, middleware));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public int priority() {
|
|
||||||
return ExtensionPhase.EARLY.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private TransactionPropagation mapTxType(Transactional.TxType txType) {
|
private TransactionPropagation mapTxType(Transactional.TxType txType) {
|
||||||
return switch (txType) {
|
return switch (txType) {
|
||||||
case REQUIRED -> TransactionPropagation.REQUIRED;
|
case REQUIRED -> TransactionPropagation.REQUIRED;
|
||||||
|
|||||||
+4
-2
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||||
import dev.relism.flash.extension.FlashContext;
|
import dev.relism.flash.extension.FlashContext;
|
||||||
|
import dev.relism.flash.extension.FlashRegistrar;
|
||||||
import dev.relism.flash.extension.FlashExtension;
|
import dev.relism.flash.extension.FlashExtension;
|
||||||
import dev.relism.flash.routing.Middleware;
|
import dev.relism.flash.routing.Middleware;
|
||||||
|
|
||||||
@@ -12,7 +13,8 @@ import dev.relism.flash.routing.Middleware;
|
|||||||
*
|
*
|
||||||
* <p>Exposes a {@link Json} utility instance in the {@link FlashContext} under
|
* <p>Exposes a {@link Json} utility instance in the {@link FlashContext} under
|
||||||
* {@code Json.class}. Any handler or extension can retrieve it via {@code ctx.require(Json.class)}
|
* {@code Json.class}. Any handler or extension can retrieve it via {@code ctx.require(Json.class)}
|
||||||
* inside {@code onInit()} (class-based) or inside {@link FlashExtension#routes} (extensions).
|
* inside {@code onInit()} (class-based) or from a {@link FlashContext#onReady(Runnable)}
|
||||||
|
* callback (extensions).
|
||||||
*
|
*
|
||||||
* <p>The raw {@link ObjectMapper} is also registered under {@code ObjectMapper.class}
|
* <p>The raw {@link ObjectMapper} is also registered under {@code ObjectMapper.class}
|
||||||
* for extensions that need direct mapper access (e.g. OpenAPI schema generation).
|
* for extensions that need direct mapper access (e.g. OpenAPI schema generation).
|
||||||
@@ -83,7 +85,7 @@ public class JacksonExtension implements FlashExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void provide(FlashContext ctx) {
|
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
Json json = new Json(mapper);
|
Json json = new Json(mapper);
|
||||||
ctx.provide(Json.class, json);
|
ctx.provide(Json.class, json);
|
||||||
ctx.provide(ObjectMapper.class, mapper);
|
ctx.provide(ObjectMapper.class, mapper);
|
||||||
|
|||||||
+3
-2
@@ -19,12 +19,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||||||
class JacksonExtensionTest {
|
class JacksonExtensionTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void provide_registers_json_mapper_and_middleware() {
|
void configure_registers_json_mapper_and_middleware() {
|
||||||
FlashContext ctx = new FlashContext();
|
FlashContext ctx = new FlashContext();
|
||||||
ObjectMapper mapper = new ObjectMapper();
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
JacksonExtension ext = new JacksonExtension(mapper);
|
JacksonExtension ext = new JacksonExtension(mapper);
|
||||||
|
|
||||||
ext.provide(ctx);
|
ext.configure(null, ctx);
|
||||||
|
ctx.complete();
|
||||||
|
|
||||||
assertNotNull(ctx.require(Json.class));
|
assertNotNull(ctx.require(Json.class));
|
||||||
assertNotNull(ctx.require(JacksonMiddleware.class));
|
assertNotNull(ctx.require(JacksonMiddleware.class));
|
||||||
|
|||||||
+9
-15
@@ -5,12 +5,13 @@ import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
|
|||||||
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
|
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
|
||||||
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
|
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
|
||||||
import dev.relism.flash.extension.AnnotationProcessor;
|
import dev.relism.flash.extension.AnnotationProcessor;
|
||||||
import dev.relism.flash.extension.ExtensionPhase;
|
import dev.relism.flash.extension.FlashRegistrar;
|
||||||
import dev.relism.flash.extension.FlashContext;
|
import dev.relism.flash.extension.FlashContext;
|
||||||
import dev.relism.flash.extension.FlashExtension;
|
import dev.relism.flash.extension.FlashExtension;
|
||||||
import dev.relism.flash.extension.FlashRegistrar;
|
|
||||||
import dev.relism.flash.http.HttpStatus;
|
import dev.relism.flash.http.HttpStatus;
|
||||||
import dev.relism.flash.routing.Middleware;
|
import dev.relism.flash.routing.Middleware;
|
||||||
|
import dev.relism.flash.routing.MiddlewareKey;
|
||||||
|
import dev.relism.flash.routing.MiddlewareNode;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -44,21 +45,16 @@ import java.util.Map;
|
|||||||
* app.install(new LimiterExtension(
|
* app.install(new LimiterExtension(
|
||||||
* new LimiterConfig().registerResolver("auth_user", req -> ClaimsHolder.user().sub())));
|
* new LimiterConfig().registerResolver("auth_user", req -> ClaimsHolder.user().sub())));
|
||||||
*
|
*
|
||||||
* // inside FlashExtension.routes() or after install():
|
* // inside a FlashContext.onReady(...) callback:
|
||||||
* Guard guard = ctx.require(Guard.class);
|
* Guard guard = ctx.require(Guard.class);
|
||||||
* app.get("/api/search", handler, guard.limit("ip", 50, 1, TimeUnit.SECONDS));
|
* app.get("/api/search", handler, guard.limit("ip", 50, 1, TimeUnit.SECONDS));
|
||||||
* }</pre>
|
* }</pre>
|
||||||
*/
|
*/
|
||||||
public final class LimiterExtension implements FlashExtension {
|
public final class LimiterExtension implements FlashExtension {
|
||||||
|
private static final MiddlewareKey LIMIT = MiddlewareKey.of("flash.limiter.limit");
|
||||||
|
|
||||||
private final LimiterConfig config;
|
private final LimiterConfig config;
|
||||||
|
|
||||||
/**
|
|
||||||
* Rate limiting runs before authentication — cheaper check rejects over-limit
|
|
||||||
* requests before any token validation occurs.
|
|
||||||
*/
|
|
||||||
@Override public int priority() { return ExtensionPhase.EARLY.value; }
|
|
||||||
|
|
||||||
/** Installs with default config (only the built-in {@code "ip"} resolver). */
|
/** Installs with default config (only the built-in {@code "ip"} resolver). */
|
||||||
public LimiterExtension() {
|
public LimiterExtension() {
|
||||||
this(new LimiterConfig());
|
this(new LimiterConfig());
|
||||||
@@ -70,7 +66,7 @@ public final class LimiterExtension implements FlashExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void provide(FlashContext ctx) {
|
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
BucketStore store = new BucketStore();
|
BucketStore store = new BucketStore();
|
||||||
Guard guard = new Guard(config, store);
|
Guard guard = new Guard(config, store);
|
||||||
|
|
||||||
@@ -90,17 +86,15 @@ public final class LimiterExtension implements FlashExtension {
|
|||||||
ann.strategy().create()
|
ann.strategy().create()
|
||||||
);
|
);
|
||||||
|
|
||||||
return List.of(buildMiddleware(resolver, cfg, store));
|
return List.of(MiddlewareNode.of(LIMIT, buildMiddleware(resolver, cfg, store)));
|
||||||
});
|
});
|
||||||
}
|
ctx.onReady(() -> {
|
||||||
|
|
||||||
@Override
|
|
||||||
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
|
|
||||||
try {
|
try {
|
||||||
OpenApiIntegration.register(ctx);
|
OpenApiIntegration.register(ctx);
|
||||||
} catch (NoClassDefFoundError ignored) {
|
} catch (NoClassDefFoundError ignored) {
|
||||||
// flash-ext-openapi not available — OpenAPI integration disabled
|
// flash-ext-openapi not available — OpenAPI integration disabled
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Package-private helper — shared with Guard ────────────────────────────
|
// ── Package-private helper — shared with Guard ────────────────────────────
|
||||||
|
|||||||
+6
-3
@@ -41,7 +41,8 @@ class LimiterOpenApiInteropTest {
|
|||||||
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
||||||
ctx.provide(OpenApiContributorRegistry.class, registry);
|
ctx.provide(OpenApiContributorRegistry.class, registry);
|
||||||
|
|
||||||
new LimiterExtension().routes(null, ctx);
|
new LimiterExtension().configure(null, ctx);
|
||||||
|
ctx.complete();
|
||||||
|
|
||||||
assertEquals(1, registry.contributors().size());
|
assertEquals(1, registry.contributors().size());
|
||||||
}
|
}
|
||||||
@@ -51,7 +52,8 @@ class LimiterOpenApiInteropTest {
|
|||||||
FlashContext ctx = new FlashContext();
|
FlashContext ctx = new FlashContext();
|
||||||
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
||||||
ctx.provide(OpenApiContributorRegistry.class, registry);
|
ctx.provide(OpenApiContributorRegistry.class, registry);
|
||||||
new LimiterExtension().routes(null, ctx);
|
new LimiterExtension().configure(null, ctx);
|
||||||
|
ctx.complete();
|
||||||
|
|
||||||
OpenApiContributor contributor = registry.contributors().getFirst();
|
OpenApiContributor contributor = registry.contributors().getFirst();
|
||||||
OpenApiOperationContribution operation = contributor.operationFor(LimitedHandler.class);
|
OpenApiOperationContribution operation = contributor.operationFor(LimitedHandler.class);
|
||||||
@@ -73,7 +75,8 @@ class LimiterOpenApiInteropTest {
|
|||||||
FlashContext ctx = new FlashContext();
|
FlashContext ctx = new FlashContext();
|
||||||
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
||||||
ctx.provide(OpenApiContributorRegistry.class, registry);
|
ctx.provide(OpenApiContributorRegistry.class, registry);
|
||||||
new LimiterExtension().routes(null, ctx);
|
new LimiterExtension().configure(null, ctx);
|
||||||
|
ctx.complete();
|
||||||
|
|
||||||
OpenApiContributor contributor = registry.contributors().getFirst();
|
OpenApiContributor contributor = registry.contributors().getFirst();
|
||||||
OpenApiOperationContribution operation = contributor.operationFor(PlainHandler.class);
|
OpenApiOperationContribution operation = contributor.operationFor(PlainHandler.class);
|
||||||
|
|||||||
@@ -53,4 +53,6 @@ public class GetWeatherTool extends McpTool {
|
|||||||
- [`tools-resources-prompts.md`](tools-resources-prompts.md) — defining tools, resources, prompts
|
- [`tools-resources-prompts.md`](tools-resources-prompts.md) — defining tools, resources, prompts
|
||||||
- [`transport.md`](transport.md) — Streamable HTTP scope, session/SSE limitations, Origin validation
|
- [`transport.md`](transport.md) — Streamable HTTP scope, session/SSE limitations, Origin validation
|
||||||
- [`security.md`](security.md) — `McpSecurity` policy, OAuth2 resolution, RFC 9728 / RFC 8707
|
- [`security.md`](security.md) — `McpSecurity` policy, OAuth2 resolution, RFC 9728 / RFC 8707
|
||||||
|
- [`keycloak.md`](keycloak.md) — Keycloak-specific setup cookbook: Dynamic Client Registration,
|
||||||
|
the RFC 8707 audience mapper gotcha, and how to verify/debug it
|
||||||
- [`jackson-interop.md`](jackson-interop.md) — why this extension does not depend on `flash-ext-jackson`
|
- [`jackson-interop.md`](jackson-interop.md) — why this extension does not depend on `flash-ext-jackson`
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Keycloak cookbook
|
||||||
|
|
||||||
|
`security.md` covers the OAuth2 mechanics `McpOidcIntegration` implements against any
|
||||||
|
`flash-ext-oidc`-compatible provider. This is the Keycloak-specific setup: the exact Admin
|
||||||
|
Console configuration for a working MCP OAuth2 flow with open Dynamic Client Registration
|
||||||
|
(DCR) — no pre-registered clients, any MCP client self-registers on first connect.
|
||||||
|
|
||||||
|
## 1. Allow Dynamic Client Registration
|
||||||
|
|
||||||
|
MCP clients (Claude Desktop, Claude.ai, MCP Inspector, others) don't share one static OAuth
|
||||||
|
client — each has its own `redirect_uri` and none know your realm in advance. They self-register
|
||||||
|
on first connect via `POST {issuer}/clients-registrations/openid-connect` (the
|
||||||
|
`registration_endpoint` from the AS metadata document, reached via the RFC 9728 Protected
|
||||||
|
Resource Metadata document `McpExtension` publishes).
|
||||||
|
|
||||||
|
**Clients → Client registration**: remove the **Trusted Hosts** policy — it rejects anonymous
|
||||||
|
registration from hosts not on an explicit allowlist (`403` / `"Host not trusted"`), which
|
||||||
|
doesn't scale to arbitrary future agents. This does not weaken end-user authentication — DCR
|
||||||
|
only grants an app a `client_id`; every user still authenticates against Keycloak's real login
|
||||||
|
screen regardless of which client asked. Lighter hygiene policies (**Max Clients Limit**,
|
||||||
|
**Consent Required**) can stay, they don't interfere.
|
||||||
|
|
||||||
|
## 2. RFC 8707 audience: mapper on `basic`, not a custom scope
|
||||||
|
|
||||||
|
`McpOidcIntegration` rejects (403) any token whose `aud` doesn't include the MCP endpoint's
|
||||||
|
canonical URL. Keycloak doesn't add this by default. The obvious fix — a custom client scope
|
||||||
|
with an Audience mapper, marked Default, added to Allowed Client Scopes — **does not work**:
|
||||||
|
clients created via the `openid-connect` DCR endpoint only ever get scopes they explicitly
|
||||||
|
request, and most MCP clients (including MCP Inspector) don't request anything beyond what a
|
||||||
|
server tells them to via `scopes_supported` (step 3). Default-scope auto-attachment, which is
|
||||||
|
how a normal manually-created client would pick up a custom Default scope, doesn't apply to
|
||||||
|
DCR-created clients at all.
|
||||||
|
|
||||||
|
`basic` is the one built-in scope Keycloak attaches to every client unconditionally, regardless
|
||||||
|
of what it registered with. Put the audience mapper there:
|
||||||
|
|
||||||
|
1. **Client scopes → `basic`** → **Mappers** → **Add mapper** → **By configuration** →
|
||||||
|
**Audience**.
|
||||||
|
2. **Included Custom Audience** = the exact value your server expects — check
|
||||||
|
`GET {parent-of-rootPath}/.well-known/oauth-protected-resource{rootPath}` on the running
|
||||||
|
server for the `resource` field it publishes (auto-derived from the request's
|
||||||
|
forwarded/`Host` headers — see `security.md`). Leave **Included Client Audience** empty (that
|
||||||
|
targets another Keycloak client, not a resource URL).
|
||||||
|
3. **Add to access token** = ON.
|
||||||
|
4. **Save.**
|
||||||
|
|
||||||
|
This is unconditional and works regardless of client cooperation — keep it even after step 3
|
||||||
|
below gets other claims flowing normally, since audience binding is a hard spec requirement
|
||||||
|
that shouldn't depend on a client bothering to request the right scope.
|
||||||
|
|
||||||
|
## 3. Other claims (username, email...): `scopes_supported` + Allowed Client Scopes
|
||||||
|
|
||||||
|
`OidcUser.username()`/`.email()`/`.name()` read `preferred_username`/`email`/`name` — normally
|
||||||
|
from the `profile`/`email` client scopes, which DCR clients don't get either, same root cause.
|
||||||
|
Unlike audience, this **is** fixable the "normal" way, because it doesn't need to survive a
|
||||||
|
completely uncooperative client:
|
||||||
|
|
||||||
|
`McpConfig.scopesSupported("openid", "profile", "email")` publishes those scopes in the PRM
|
||||||
|
document. MCP clients that read it (confirmed for MCP Inspector) echo them back in their DCR
|
||||||
|
registration request — `"scope": "openid profile email offline_access"` (`offline_access` is
|
||||||
|
Inspector's own addition, for refresh tokens). For that request to actually succeed, **Allowed
|
||||||
|
Client Scopes** needs, exactly:
|
||||||
|
|
||||||
|
- **`openid` listed explicitly.** The one genuinely non-obvious step: `openid` is not covered by
|
||||||
|
**Allow Default Scopes** (On by default) the way other realm-Default scopes are, even though
|
||||||
|
every OIDC request includes it. Until it's listed here, registration fails with a generic
|
||||||
|
`403 insufficient_scope` / `"Not permitted to use specified clientScope"` regardless of
|
||||||
|
whether everything else is configured correctly.
|
||||||
|
- **`offline_access` listed explicitly** — it's Optional, not Default, so `ALLOW_DEFAULT_SCOPES`
|
||||||
|
doesn't cover it either.
|
||||||
|
- **`profile`/`email` — do not list them here.** Mark them **Default** on the **Client scopes**
|
||||||
|
page (Assigned Type column) instead, and leave **Allow Default Scopes** = On. Adding an
|
||||||
|
already-Default scope to this list explicitly gets rejected on save
|
||||||
|
(`"Client scopes not allowed: [...]"`) — the list is for *additional* Optional scopes only.
|
||||||
|
|
||||||
|
With that, a real client's token comes back with `preferred_username`/`email` populated
|
||||||
|
normally.
|
||||||
|
|
||||||
|
### Fallback for anything else
|
||||||
|
|
||||||
|
For a claim not covered by `openid profile email` (a custom attribute, a role) — or for a client
|
||||||
|
that ignores `scopes_supported` entirely — add a **User Property** mapper to `basic` too
|
||||||
|
(Property `username` → Token Claim Name `preferred_username`, or whatever's needed), same as the
|
||||||
|
audience mapper in step 2. Unconditional, works regardless of client cooperation, costs one
|
||||||
|
mapper per claim, once, at the realm level — not per tool.
|
||||||
|
|
||||||
|
## Verifying without a full OAuth round-trip
|
||||||
|
|
||||||
|
**Clients → (any client) → Client scopes → Evaluate**: pick a user, run it — Default scopes
|
||||||
|
(including `basic`) apply automatically and won't appear in the "Select scope parameters"
|
||||||
|
picker, which only lists Optional ones — and check the **Generated Access Token** preview.
|
||||||
|
Confirms mappers work without a browser + real MCP client round-trip each time.
|
||||||
|
|
||||||
|
## If a real client still gets rejected
|
||||||
|
|
||||||
|
`McpOidcIntegration.audienceGuard` logs the actual mismatch at `WARN`:
|
||||||
|
|
||||||
|
```
|
||||||
|
[flash-ext-mcp] Rejecting token (RFC 8707): aud=<token's actual aud> does not include expected
|
||||||
|
resource identifier "<what this server expects>" — ...
|
||||||
|
```
|
||||||
|
|
||||||
|
`aud=null` → the `basic` mapper produced nothing (most common cause: **Included Custom
|
||||||
|
Audience** left blank — the mapper saves fine and silently does nothing without it). A non-null
|
||||||
|
`aud` that still doesn't match → compare byte-for-byte — the expected side is derived from the
|
||||||
|
request's own forwarded/`Host` headers, so scheme/host/trailing-slash mismatches show up here
|
||||||
|
directly, as does a proxy hop that drops `X-Forwarded-Host`.
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
# Security
|
# Security
|
||||||
|
|
||||||
|
Provider-specific setup steps (not generic OAuth2 mechanics) live in separate cookbooks —
|
||||||
|
[`keycloak.md`](keycloak.md) for Keycloak: enabling Dynamic Client Registration, why the RFC 8707
|
||||||
|
audience mapper needs to go on the built-in `basic` scope instead of a custom one, and the exact
|
||||||
|
Allowed Client Scopes configuration `scopes_supported` needs to actually work.
|
||||||
|
|
||||||
## `McpSecurity`
|
## `McpSecurity`
|
||||||
|
|
||||||
`McpConfig.security(...)` controls how the MCP endpoint reacts to `flash-ext-oidc` being
|
`McpConfig.security(...)` controls how the MCP endpoint reacts to `flash-ext-oidc` being
|
||||||
@@ -34,44 +39,120 @@ MCP-only install, no OAuth2 anywhere in the app), the first such reference throw
|
|||||||
fails, and only when there's something to fail. This mirrors `OidcExtension`'s own lazy bridge to
|
fails, and only when there's something to fail. This mirrors `OidcExtension`'s own lazy bridge to
|
||||||
`flash-ext-openapi` — same technique, same reason.
|
`flash-ext-openapi` — same technique, same reason.
|
||||||
|
|
||||||
## OAuth2 resolution details
|
## OAuth2 resolution details — zero-config by default
|
||||||
|
|
||||||
When oidc is available and `security() != NONE`:
|
When oidc is available and `security() != NONE`, `McpOidcIntegration` (an isolated,
|
||||||
|
lazily-loaded bridge — see its javadoc) derives everything an MCP OAuth2 resource server needs
|
||||||
|
straight from the installed `OidcMiddleware`, with no additional `McpConfig` calls required:
|
||||||
|
|
||||||
1. The MCP route is wrapped with `flash-ext-oidc`'s own `OidcMiddleware.protect()` — the same
|
1. The MCP route is wrapped with `flash-ext-oidc`'s own `OidcMiddleware.protect(resourceMetadataPath)`
|
||||||
Bearer-token/JWKS validation path used everywhere else in Flash5. No JWT parsing or JWKS
|
— the same Bearer-token/JWKS validation path used everywhere else in Flash5, plus a
|
||||||
handling is reimplemented here.
|
`resource_metadata` challenge parameter (see below). No JWT parsing or JWKS handling is
|
||||||
2. If `McpConfig.resourceIdentifier(...)` is set, an additional audience guard runs after
|
reimplemented here.
|
||||||
`protect()`: it reads the validated claims from `ClaimsHolder` and rejects (`403`) any token
|
2. An audience guard always runs after `protect(...)`: it reads the validated claims from
|
||||||
whose `aud` claim does not include the configured resource identifier — **RFC 8707 Resource
|
`ClaimsHolder` and rejects (`403`) any token whose `aud` claim does not include the resource
|
||||||
Indicators / audience binding**. This is genuinely new behavior, not something
|
identifier — **RFC 8707 Resource Indicators / audience binding**, enforced unconditionally,
|
||||||
`flash-ext-oidc` does on its own: `OidcMiddleware` validates `aud` against its own
|
not opt-in. `OidcMiddleware` itself validates `aud` against its own `clientId` for ID
|
||||||
`clientId` for ID tokens, but deliberately does not enforce audience on access tokens (it
|
tokens, but deliberately does not enforce audience on access tokens (it varies by provider)
|
||||||
varies by provider) — the MCP extension adds that check on top, scoped to its own resource
|
— the MCP extension adds that check on top, scoped to its own resource identifier.
|
||||||
identifier.
|
3. The resource identifier is the canonical URI of the MCP endpoint, resolved **per request** by
|
||||||
3. If `resourceIdentifier(...)` is left unset, only standard bearer validation runs — no
|
`OidcMiddleware#selfOrigin` + `rootPath` — the same scheme/host resolution `OidcExtension`
|
||||||
audience binding. Fine for a first integration; RFC 8707 becomes meaningful once you have
|
uses for its own redirect URIs: `X-Forwarded-Host`/`X-Forwarded-Proto` when the request came
|
||||||
more than one resource server sharing the same authorization server.
|
through a reverse proxy, otherwise `{selfScheme()}://{Host header}`. Behind a proxy the
|
||||||
|
`Host` alone is the upstream address the proxy dialled, which would publish a resource
|
||||||
|
identifier no client can reach. `McpConfig.resourceIdentifier(...)` still overrides it
|
||||||
|
outright for a proxy that forwards neither header.
|
||||||
|
4. The authorization server issuer is read from `OidcMiddleware#issuer()` unless
|
||||||
|
`McpConfig.authorizationServerIssuer(...)` overrides it.
|
||||||
|
|
||||||
## RFC 9728 Protected Resource Metadata
|
## RFC 9728 Protected Resource Metadata
|
||||||
|
|
||||||
If both `resourceIdentifier(...)` and `authorizationServerIssuer(...)` are set (and the endpoint
|
Whenever the endpoint ends up protected, `flash-ext-mcp` publishes a Protected Resource Metadata
|
||||||
ends up protected), `flash-ext-mcp` publishes a Protected Resource Metadata document at
|
document at `/.well-known/oauth-protected-resource{rootPath}` — no explicit `resourceIdentifier`/
|
||||||
`/.well-known/oauth-protected-resource{rootPath}`:
|
`authorizationServerIssuer` configuration required, both are auto-derived as described above:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "resource": "https://mcp.example.com/mcp", "authorization_servers": ["https://auth.example.com/realms/myrealm"] }
|
{ "resource": "https://mcp.example.com/mcp", "authorization_servers": ["https://auth.example.com/realms/myrealm"] }
|
||||||
```
|
```
|
||||||
|
|
||||||
This lets a spec-compliant MCP client discover which authorization server to use without
|
`resource` is computed per request from the incoming request's forwarded/`Host` headers (see
|
||||||
out-of-band configuration. `authorizationServerIssuer` has to be supplied explicitly because
|
above), so the document is correct without hardcoding the server's own public URL.
|
||||||
`flash-ext-oidc` does not expose its resolved issuer/discovery metadata through `FlashContext` —
|
|
||||||
only `OidcMiddleware` and `JwtValidator` are registered there. Passing it separately avoids
|
|
||||||
reaching into `flash-ext-oidc` internals for a value the app owner already has at hand (it's the
|
|
||||||
same issuer they configured `OidcExtension` with).
|
|
||||||
|
|
||||||
Without an issuer configured, bearer validation still works exactly the same — the client just
|
### `scopes_supported`
|
||||||
needs the authorization server configured out-of-band instead of discovering it automatically.
|
|
||||||
|
Optional per RFC 9728, omitted from the document entirely unless set via
|
||||||
|
`McpConfig.scopesSupported("openid", "profile", "email")`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "resource": "...", "authorization_servers": ["..."], "scopes_supported": ["openid", "profile", "email"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
This is pure advertisement — token validation doesn't change based on it — but it matters in
|
||||||
|
practice: a client that ignores it and requests no scope at all (many do — see `keycloak.md`)
|
||||||
|
only gets back whatever the authorization server treats as always-included regardless of
|
||||||
|
request, which for Keycloak is just its built-in `basic` scope. A client that *does* read
|
||||||
|
`scopes_supported` and echoes it back in its authorization/token requests gets a token with the
|
||||||
|
claims those scopes actually provide (`profile` → `preferred_username`/`name`, etc.), without
|
||||||
|
needing every one of those claims hand-mapped onto `basic`. Set it to whatever scopes your
|
||||||
|
`McpTool`s actually read off `ClaimsHolder`/`OidcUser` — there's no way to auto-derive this list,
|
||||||
|
it depends entirely on what your tools do with the claims.
|
||||||
|
|
||||||
|
## `WWW-Authenticate: resource_metadata` (RFC 9728 §5.1)
|
||||||
|
|
||||||
|
The MCP Authorization spec **requires** a `401` to carry `resource_metadata` in
|
||||||
|
`WWW-Authenticate`, pointing at the Protected Resource Metadata document above — this is how a
|
||||||
|
spec-compliant client discovers the authorization server without out-of-band configuration.
|
||||||
|
`OidcMiddleware.protect(String resourceMetadataPath)` (an overload added specifically for this)
|
||||||
|
builds that challenge automatically:
|
||||||
|
|
||||||
|
```
|
||||||
|
WWW-Authenticate: Bearer realm="...", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
|
||||||
|
```
|
||||||
|
|
||||||
|
The plain `OidcMiddleware.protect()` (no argument), used by every other Flash5 app, is
|
||||||
|
unaffected — this parameter is additive and MCP-specific.
|
||||||
|
|
||||||
|
## Per-tool `@RolesAllowed`/`@ScopesAllowed`
|
||||||
|
|
||||||
|
`McpTool` subclasses can carry `flash-ext-oidc`'s `@RolesAllowed`/`@ScopesAllowed`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Tool(name = "delete_route", description = "Delete a route")
|
||||||
|
@RolesAllowed("admin")
|
||||||
|
public class DeleteRouteTool extends McpTool {
|
||||||
|
@Override public ToolResponse call(ToolArguments args) { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This does **not** reuse `flash-ext-oidc`'s per-route middleware mechanism (`ctx.addAnnotationProcessor`,
|
||||||
|
the thing that makes these annotations work on a `RequestHandler`) — it can't: every tool shares
|
||||||
|
one HTTP route (`POST {rootPath}`), already wrapped by whatever `McpSecurity` resolved above, so
|
||||||
|
there is no per-tool route to attach a different middleware chain to. Instead,
|
||||||
|
`McpOidcIntegration.compileToolPolicy` reads the annotations once at boot (`McpRegistry.scan`)
|
||||||
|
and compiles them into a closure (`McpAuthPolicy`) that `McpDispatcher` runs *after* the
|
||||||
|
route-wide auth has already succeeded and *before* invoking the specific tool named in the
|
||||||
|
`tools/call` request — narrowing what's already-authenticated, not replacing it. A denial is a
|
||||||
|
normal `isError: true` tool result (see `ToolResponse.error`), not an HTTP-level rejection — the
|
||||||
|
model sees why, the same as any other tool failure.
|
||||||
|
|
||||||
|
Roles are read via `OidcUser#hasRole` against `McpConfig.rolesClaimPath(...)` (default
|
||||||
|
`"realm_access.roles"`, matching `OidcConfig`'s own default — set this explicitly if the two
|
||||||
|
diverge; there's no way to read `OidcConfig`'s actual configured value from here). Scopes use
|
||||||
|
`OidcUser#hasScope`'s built-in default claim paths (`scope`/`scp`), no extra config needed.
|
||||||
|
`@ScopesAllowed(match = ScopesAllowed.Match.ANY)` and multi-role `@RolesAllowed({"admin",
|
||||||
|
"editor"})` (OR semantics) both work exactly as they do on a `RequestHandler`.
|
||||||
|
|
||||||
|
**`@Authenticated` alone has no effect and fails boot.** Once oidc is active for a server, every
|
||||||
|
tool call is already authenticated — there's no per-tool public/authenticated split the way
|
||||||
|
there is for HTTP routes, so a bare `@Authenticated` on a tool can't mean anything and would
|
||||||
|
silently do nothing if allowed to compile. Boot fails instead, with a message pointing at
|
||||||
|
`@RolesAllowed`/`@ScopesAllowed` as the actual narrowing mechanism.
|
||||||
|
|
||||||
|
**Annotating a tool without active OAuth2 also fails boot**, not silently at request time: if
|
||||||
|
`@RolesAllowed`/`@ScopesAllowed`/`@Authenticated` shows up on a tool while `McpSecurity` resolved
|
||||||
|
to unprotected (`NONE`, or `AUTO` with no oidc installed), that's very likely a forgotten
|
||||||
|
`OidcExtension` install or a `McpSecurity.NONE` left over from local dev — `IllegalStateException`
|
||||||
|
at `app.start()`.
|
||||||
|
|
||||||
## The `HttpException` safety net
|
## The `HttpException` safety net
|
||||||
|
|
||||||
|
|||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compiled per-tool authorization requirement, built once at boot by {@link McpOidcIntegration}
|
||||||
|
* from {@code @RolesAllowed}/{@code @ScopesAllowed} on an {@link McpTool} subclass — {@code null}
|
||||||
|
* on {@link McpRegistry.RegisteredTool} means no restriction beyond whatever {@link McpSecurity}
|
||||||
|
* already enforces route-wide.
|
||||||
|
*
|
||||||
|
* <p>{@code check} is a closure, not a raw role/scope list — this is what lets this record (and
|
||||||
|
* its only caller, {@link McpDispatcher}) stay free of any compile-time reference to a {@code
|
||||||
|
* flash-ext-oidc} type, preserving the same classload isolation {@link McpOidcIntegration}'s
|
||||||
|
* javadoc describes for the rest of the OIDC bridge. Only the plain-JDK {@link Supplier}
|
||||||
|
* signature crosses the boundary; the closure itself, built once inside {@code
|
||||||
|
* McpOidcIntegration}, is the only place that ever touches {@code OidcUser}/{@code ClaimsHolder}.
|
||||||
|
*
|
||||||
|
* <p>Returns {@code null} from {@link #check()}{@code .get()} when authorized, or a
|
||||||
|
* human-readable denial reason otherwise — invoked once per {@code tools/call} against an
|
||||||
|
* annotated tool, never allocated on that path (the closure and its captured role/scope arrays
|
||||||
|
* are built exactly once, at boot).
|
||||||
|
*/
|
||||||
|
record McpAuthPolicy(Supplier<String> check) {}
|
||||||
+43
-9
@@ -12,7 +12,7 @@ import java.util.List;
|
|||||||
* .rootPath("/mcp")
|
* .rootPath("/mcp")
|
||||||
* .toolsPackage("com.example.tools")
|
* .toolsPackage("com.example.tools")
|
||||||
* .security(McpSecurity.REQUIRED)
|
* .security(McpSecurity.REQUIRED)
|
||||||
* .resourceIdentifier("https://mcp.example.com/mcp")
|
* .scopesSupported("openid", "profile", "email")
|
||||||
* .build();
|
* .build();
|
||||||
* }</pre>
|
* }</pre>
|
||||||
*/
|
*/
|
||||||
@@ -27,6 +27,8 @@ public final class McpConfig {
|
|||||||
private final String resourceIdentifier;
|
private final String resourceIdentifier;
|
||||||
private final String authorizationServerIssuer;
|
private final String authorizationServerIssuer;
|
||||||
private final List<String> allowedOrigins;
|
private final List<String> allowedOrigins;
|
||||||
|
private final List<String> scopesSupported;
|
||||||
|
private final String rolesClaimPath;
|
||||||
|
|
||||||
private McpConfig(Builder b) {
|
private McpConfig(Builder b) {
|
||||||
this.name = b.name;
|
this.name = b.name;
|
||||||
@@ -38,6 +40,8 @@ public final class McpConfig {
|
|||||||
this.resourceIdentifier = b.resourceIdentifier;
|
this.resourceIdentifier = b.resourceIdentifier;
|
||||||
this.authorizationServerIssuer = b.authorizationServerIssuer;
|
this.authorizationServerIssuer = b.authorizationServerIssuer;
|
||||||
this.allowedOrigins = List.copyOf(b.allowedOrigins);
|
this.allowedOrigins = List.copyOf(b.allowedOrigins);
|
||||||
|
this.scopesSupported = List.copyOf(b.scopesSupported);
|
||||||
|
this.rolesClaimPath = b.rolesClaimPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
String name() { return name; }
|
String name() { return name; }
|
||||||
@@ -49,6 +53,8 @@ public final class McpConfig {
|
|||||||
String resourceIdentifier() { return resourceIdentifier; }
|
String resourceIdentifier() { return resourceIdentifier; }
|
||||||
String authorizationServerIssuer() { return authorizationServerIssuer; }
|
String authorizationServerIssuer() { return authorizationServerIssuer; }
|
||||||
List<String> allowedOrigins() { return allowedOrigins; }
|
List<String> allowedOrigins() { return allowedOrigins; }
|
||||||
|
List<String> scopesSupported() { return scopesSupported; }
|
||||||
|
String rolesClaimPath() { return rolesClaimPath; }
|
||||||
|
|
||||||
public static Builder builder(String name) { return new Builder(name); }
|
public static Builder builder(String name) { return new Builder(name); }
|
||||||
|
|
||||||
@@ -62,6 +68,8 @@ public final class McpConfig {
|
|||||||
private String resourceIdentifier;
|
private String resourceIdentifier;
|
||||||
private String authorizationServerIssuer;
|
private String authorizationServerIssuer;
|
||||||
private final List<String> allowedOrigins = new ArrayList<>();
|
private final List<String> allowedOrigins = new ArrayList<>();
|
||||||
|
private final List<String> scopesSupported = new ArrayList<>();
|
||||||
|
private String rolesClaimPath = "realm_access.roles";
|
||||||
|
|
||||||
private Builder(String name) {
|
private Builder(String name) {
|
||||||
if (name == null || name.isBlank())
|
if (name == null || name.isBlank())
|
||||||
@@ -85,18 +93,22 @@ public final class McpConfig {
|
|||||||
public Builder security(McpSecurity security) { this.security = security; return this; }
|
public Builder security(McpSecurity security) { this.security = security; return this; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resource identifier used for RFC 8707 audience binding: tokens whose {@code aud} claim
|
* Canonical URI of this MCP endpoint, used for RFC 8707 audience binding: tokens whose
|
||||||
* does not include this value are rejected. Optional — if unset, only standard bearer
|
* {@code aud} claim does not include this value are rejected. Optional — when
|
||||||
* validation (signature/issuer/expiry) is enforced, not audience binding.
|
* {@code flash-ext-oidc} is installed, this is auto-derived per request from the
|
||||||
|
* forwarded/{@code Host} headers (same resolution {@code OidcExtension} uses for its own
|
||||||
|
* redirect URIs) and audience binding is enforced unconditionally. Set this explicitly
|
||||||
|
* only to override that guess — a reverse proxy that forwards neither
|
||||||
|
* {@code X-Forwarded-Host} nor {@code X-Forwarded-Proto}.
|
||||||
*/
|
*/
|
||||||
public Builder resourceIdentifier(String resourceIdentifier) { this.resourceIdentifier = resourceIdentifier; return this; }
|
public Builder resourceIdentifier(String resourceIdentifier) { this.resourceIdentifier = resourceIdentifier; return this; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authorization server issuer URL, used to publish an RFC 9728 Protected Resource
|
* Authorization server issuer URL, published in the RFC 9728 Protected Resource
|
||||||
* Metadata document at {@code /.well-known/oauth-protected-resource{rootPath}} so MCP
|
* Metadata document at {@code /.well-known/oauth-protected-resource{rootPath}}. Optional
|
||||||
* clients can discover it automatically. Requires {@link #resourceIdentifier(String)}
|
* — when {@code flash-ext-oidc} is installed, this is auto-derived from its configured
|
||||||
* to also be set. Optional — without it, bearer validation still works, clients just
|
* issuer. Set this explicitly only to override that (e.g. publishing a different issuer
|
||||||
* need the authorization server configured out-of-band.
|
* than the one actually validating tokens).
|
||||||
*/
|
*/
|
||||||
public Builder authorizationServerIssuer(String issuer) { this.authorizationServerIssuer = issuer; return this; }
|
public Builder authorizationServerIssuer(String issuer) { this.authorizationServerIssuer = issuer; return this; }
|
||||||
|
|
||||||
@@ -107,6 +119,28 @@ public final class McpConfig {
|
|||||||
*/
|
*/
|
||||||
public Builder allowedOrigins(String... origins) { this.allowedOrigins.addAll(List.of(origins)); return this; }
|
public Builder allowedOrigins(String... origins) { this.allowedOrigins.addAll(List.of(origins)); return this; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth2 scopes this server expects clients to request, published as {@code
|
||||||
|
* scopes_supported} in the RFC 9728 Protected Resource Metadata document. Optional per
|
||||||
|
* the spec — omitted from the document entirely if never set. A spec-compliant client
|
||||||
|
* reads this to know what to put in its authorization/token requests instead of
|
||||||
|
* requesting nothing; see {@code docs/keycloak.md}'s "same story for any other claim"
|
||||||
|
* section for why this matters in practice (a client that requests no scope only gets
|
||||||
|
* whatever your authorization server treats as always-included, e.g. Keycloak's `basic`).
|
||||||
|
* Purely advertisement — this server still validates whatever token it actually receives
|
||||||
|
* the same way regardless of what a client requested.
|
||||||
|
*/
|
||||||
|
public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim path used to resolve roles for {@code @RolesAllowed} on an {@link McpTool} —
|
||||||
|
* same dot-path syntax and default (Keycloak's {@code realm_access.roles}) as {@code
|
||||||
|
* OidcConfig#rolesClaimPath()}. Set this only if the two configs diverge; there is no
|
||||||
|
* way to auto-derive it from the installed {@code OidcExtension} (see {@code
|
||||||
|
* docs/security.md}'s {@code @RolesAllowed}/{@code @ScopesAllowed} section for why).
|
||||||
|
*/
|
||||||
|
public Builder rolesClaimPath(String rolesClaimPath) { this.rolesClaimPath = rolesClaimPath; return this; }
|
||||||
|
|
||||||
public McpConfig build() {
|
public McpConfig build() {
|
||||||
if (toolsPackage == null || toolsPackage.isBlank())
|
if (toolsPackage == null || toolsPackage.isBlank())
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException(
|
||||||
|
|||||||
+11
-2
@@ -17,7 +17,11 @@ import java.io.IOException;
|
|||||||
* error — the model needs to see it. Everything else that goes wrong (bad params, unknown
|
* error — the model needs to see it. Everything else that goes wrong (bad params, unknown
|
||||||
* tool/resource/prompt name, resource/prompt handler exceptions) is a JSON-RPC error object,
|
* tool/resource/prompt name, resource/prompt handler exceptions) is a JSON-RPC error object,
|
||||||
* always returned with HTTP 200: the HTTP request itself succeeded, only the RPC did not. Only
|
* always returned with HTTP 200: the HTTP request itself succeeded, only the RPC did not. Only
|
||||||
* malformed HTTP-level input (unparsable JSON, not a JSON object) gets HTTP 400.
|
* malformed HTTP-level input (unparsable JSON, not a JSON object) gets HTTP 400. A
|
||||||
|
* {@code @RolesAllowed}/{@code @ScopesAllowed} denial (see {@link McpAuthPolicy}) is the same
|
||||||
|
* category — {@code isError: true}, tool never invoked — not a transport-level rejection; the
|
||||||
|
* route-wide 401/403 for "not authenticated at all" already happened earlier, in the {@code
|
||||||
|
* OidcMiddleware}/audience-guard middleware chain, before this dispatcher ever runs.
|
||||||
*/
|
*/
|
||||||
final class McpDispatcher {
|
final class McpDispatcher {
|
||||||
|
|
||||||
@@ -132,13 +136,18 @@ final class McpDispatcher {
|
|||||||
if (tool == null)
|
if (tool == null)
|
||||||
throw McpProtocolException.invalidParams("Unknown tool: " + name);
|
throw McpProtocolException.invalidParams("Unknown tool: " + name);
|
||||||
|
|
||||||
ToolArguments args = new ToolArguments(params.path("arguments"));
|
|
||||||
ToolResponse result;
|
ToolResponse result;
|
||||||
|
String denied = tool.policy() != null ? tool.policy().check().get() : null;
|
||||||
|
if (denied != null) {
|
||||||
|
result = ToolResponse.error("Tool \"" + name + "\" denied: " + denied);
|
||||||
|
} else {
|
||||||
|
ToolArguments args = new ToolArguments(params.path("arguments"));
|
||||||
try {
|
try {
|
||||||
result = tool.instance().call(args);
|
result = tool.instance().call(args);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
result = ToolResponse.error("Tool \"" + name + "\" failed: " + e.getMessage());
|
result = ToolResponse.error("Tool \"" + name + "\" failed: " + e.getMessage());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
ToolResponse finalResult = result;
|
ToolResponse finalResult = result;
|
||||||
writeResult(res, id, gen -> {
|
writeResult(res, id, gen -> {
|
||||||
gen.writeStartObject();
|
gen.writeStartObject();
|
||||||
|
|||||||
+31
-30
@@ -25,14 +25,14 @@ import java.util.List;
|
|||||||
* .build()))
|
* .build()))
|
||||||
* .start();
|
* .start();
|
||||||
*
|
*
|
||||||
* // With flash-ext-oidc as the OAuth2 resource server
|
* // With flash-ext-oidc as the OAuth2 resource server — zero extra config: issuer, canonical
|
||||||
|
* // resource identifier, RFC 8707 audience binding and RFC 9728 metadata are all derived from
|
||||||
|
* // the installed OidcExtension.
|
||||||
* FlashApp.create(8080)
|
* FlashApp.create(8080)
|
||||||
* .install(new OidcExtension(oidcConfig))
|
* .install(new OidcExtension(oidcConfig))
|
||||||
* .install(new McpExtension(McpConfig.builder("my-mcp-server")
|
* .install(new McpExtension(McpConfig.builder("my-mcp-server")
|
||||||
* .toolsPackage("com.example.tools")
|
* .toolsPackage("com.example.tools")
|
||||||
* .security(McpSecurity.REQUIRED)
|
* .security(McpSecurity.REQUIRED)
|
||||||
* .resourceIdentifier("https://mcp.example.com/mcp")
|
|
||||||
* .authorizationServerIssuer("https://auth.example.com/realms/myrealm")
|
|
||||||
* .build()))
|
* .build()))
|
||||||
* .start();
|
* .start();
|
||||||
* }</pre>
|
* }</pre>
|
||||||
@@ -51,42 +51,40 @@ public class McpExtension implements FlashExtension {
|
|||||||
this.config = config;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Everything — scanning, binding, security resolution, route registration — happens here
|
|
||||||
* rather than in {@link #provide}, because binding a tool calls its {@code onInit()}, which
|
|
||||||
* may call {@code require()} on services other extensions registered lazily via
|
|
||||||
* {@code ctx.supply()}. Per {@link FlashExtension}'s contract, {@code require()} is only
|
|
||||||
* safe once {@code routes()} runs, after every extension's {@code provide()} phase has
|
|
||||||
* completed and {@code FlashContext.resolveAll()} has run.
|
|
||||||
*/
|
|
||||||
@Override
|
@Override
|
||||||
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
|
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
McpRegistry registry = McpRegistry.scan(config.toolsPackage(), ctx);
|
ctx.onReady(() -> registerRoutes(app, ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void registerRoutes(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
|
// Resolved before scanning so McpRegistry knows, per tool, whether @RolesAllowed/
|
||||||
|
// @ScopesAllowed are backed by real OAuth2 protection or a boot-time misconfiguration
|
||||||
|
// (see McpOidcIntegration#compileToolPolicy) — must run first, not after.
|
||||||
|
McpOidcIntegration.Resolved secured = resolveSecurity(ctx);
|
||||||
|
McpRegistry registry = McpRegistry.scan(config.toolsPackage(), ctx, secured != null, config.rolesClaimPath());
|
||||||
McpDispatcher dispatcher = new McpDispatcher(registry, config.name(), config.version(), config.instructions());
|
McpDispatcher dispatcher = new McpDispatcher(registry, config.name(), config.version(), config.instructions());
|
||||||
|
|
||||||
List<Middleware> chain = new ArrayList<>(3);
|
List<Middleware> chain = new ArrayList<>(3);
|
||||||
chain.add(McpTransportGuards.httpExceptionGuard());
|
chain.add(McpTransportGuards.httpExceptionGuard());
|
||||||
chain.add(McpTransportGuards.originGuard(config.allowedOrigins()));
|
chain.add(McpTransportGuards.originGuard(config.allowedOrigins()));
|
||||||
|
if (secured != null) chain.add(secured.security());
|
||||||
Middleware security = resolveSecurity(ctx);
|
|
||||||
if (security != null) chain.add(security);
|
|
||||||
|
|
||||||
app.post(config.rootPath(), (req, res) -> { dispatcher.handle(req, res); return null; },
|
app.post(config.rootPath(), (req, res) -> { dispatcher.handle(req, res); return null; },
|
||||||
chain.toArray(Middleware[]::new));
|
chain.toArray(Middleware[]::new));
|
||||||
|
|
||||||
registerResourceMetadata(app, security != null);
|
registerResourceMetadata(app, secured);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Middleware resolveSecurity(FlashContext ctx) {
|
private McpOidcIntegration.Resolved resolveSecurity(FlashContext ctx) {
|
||||||
if (config.security() == McpSecurity.NONE) return null;
|
if (config.security() == McpSecurity.NONE) return null;
|
||||||
|
|
||||||
Middleware oidcSecurity;
|
McpOidcIntegration.Resolved resolved;
|
||||||
try {
|
try {
|
||||||
oidcSecurity = McpOidcIntegration.resolve(ctx, config);
|
resolved = McpOidcIntegration.resolve(ctx, config);
|
||||||
} catch (NoClassDefFoundError e) {
|
} catch (NoClassDefFoundError e) {
|
||||||
oidcSecurity = null; // flash-ext-oidc not on the classpath at all
|
resolved = null; // flash-ext-oidc not on the classpath at all
|
||||||
}
|
}
|
||||||
if (oidcSecurity != null) return oidcSecurity;
|
if (resolved != null) return resolved;
|
||||||
|
|
||||||
if (config.security() == McpSecurity.REQUIRED) {
|
if (config.security() == McpSecurity.REQUIRED) {
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException(
|
||||||
@@ -102,17 +100,20 @@ public class McpExtension implements FlashExtension {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void registerResourceMetadata(FlashRegistrar<?> app, boolean secured) {
|
/**
|
||||||
if (!secured) return;
|
* RFC 9728 Protected Resource Metadata, built once security is resolved — no longer
|
||||||
String resourceId = config.resourceIdentifier();
|
* conditioned on {@code resourceIdentifier}/{@code authorizationServerIssuer} being set
|
||||||
String issuer = config.authorizationServerIssuer();
|
* explicitly, since {@link McpOidcIntegration#resolve} now derives both by default. The
|
||||||
if (resourceId == null || resourceId.isBlank() || issuer == null || issuer.isBlank()) return;
|
* {@code resource} field is computed per request (it depends on that request's own
|
||||||
|
* forwarded/{@code Host} headers) via {@link McpOidcIntegration.Resolved#resourceIdentifier()}.
|
||||||
String body = McpResourceMetadata.build(resourceId, issuer);
|
*/
|
||||||
|
private void registerResourceMetadata(FlashRegistrar<?> app, McpOidcIntegration.Resolved secured) {
|
||||||
|
if (secured == null) return;
|
||||||
String path = "/.well-known/oauth-protected-resource" + config.rootPath();
|
String path = "/.well-known/oauth-protected-resource" + config.rootPath();
|
||||||
app.get(path, (req, res) -> {
|
app.get(path, (req, res) -> {
|
||||||
res.type(ContentType.JSON);
|
res.type(ContentType.JSON);
|
||||||
return body;
|
return McpResourceMetadata.build(
|
||||||
|
secured.resourceIdentifier().apply(req), secured.issuer(), config.scopesSupported());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+138
-15
@@ -1,45 +1,84 @@
|
|||||||
package dev.relism.flash.ext.mcp;
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.oidc.Authenticated;
|
||||||
import dev.relism.flash.ext.oidc.ClaimsHolder;
|
import dev.relism.flash.ext.oidc.ClaimsHolder;
|
||||||
import dev.relism.flash.ext.oidc.OidcMiddleware;
|
import dev.relism.flash.ext.oidc.OidcMiddleware;
|
||||||
|
import dev.relism.flash.ext.oidc.OidcUser;
|
||||||
|
import dev.relism.flash.ext.oidc.RolesAllowed;
|
||||||
|
import dev.relism.flash.ext.oidc.ScopesAllowed;
|
||||||
import dev.relism.flash.exceptions.HttpException;
|
import dev.relism.flash.exceptions.HttpException;
|
||||||
import dev.relism.flash.extension.FlashContext;
|
import dev.relism.flash.extension.FlashContext;
|
||||||
|
import dev.relism.flash.models.Request;
|
||||||
import dev.relism.flash.routing.Middleware;
|
import dev.relism.flash.routing.Middleware;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lazy, isolated bridge to {@code flash-ext-oidc}.
|
* Lazy, isolated bridge to {@code flash-ext-oidc}.
|
||||||
*
|
*
|
||||||
* <p>References to OIDC types only ever resolve when {@link #resolve} is actually invoked —
|
* <p>References to OIDC types only ever resolve when {@link #resolve}/{@link #compileToolPolicy}
|
||||||
* never at {@link McpExtension} class-load time — because they live in this separate nested
|
* are actually invoked — never at {@link McpExtension} class-load time — because they live in
|
||||||
* class. The caller wraps the invocation in {@code catch (NoClassDefFoundError)}, exactly like
|
* this separate nested class. The caller wraps the invocation in {@code catch
|
||||||
* {@code OidcExtension}'s own lazy bridge to {@code flash-ext-openapi}. This is what lets
|
* (NoClassDefFoundError)}, exactly like {@code OidcExtension}'s own lazy bridge to {@code
|
||||||
* {@code flash-ext-mcp} run standalone (MCP-only, no OAuth2) when {@code flash-ext-oidc} is not
|
* flash-ext-openapi}. This is what lets {@code flash-ext-mcp} run standalone (MCP-only, no
|
||||||
* even on the classpath.
|
* OAuth2) when {@code flash-ext-oidc} is not even on the classpath. {@link Resolved}/{@link
|
||||||
|
* McpAuthPolicy} carry only oidc-free types back out ({@link Middleware}, {@link String}, a
|
||||||
|
* {@link Function}, a {@link Supplier}) so no other class in this package ever has to reference
|
||||||
|
* an OIDC type.
|
||||||
|
*
|
||||||
|
* <p>Zero-config by design: when {@code flash-ext-oidc} is installed, everything an MCP OAuth2
|
||||||
|
* resource server needs — issuer, canonical resource identifier, RFC 8707 audience binding, and
|
||||||
|
* a spec-compliant {@code WWW-Authenticate} challenge (RFC 9728 §5.1) — is derived straight from
|
||||||
|
* the installed {@link OidcMiddleware}, with no additional {@link McpConfig} calls.
|
||||||
|
* {@link McpConfig#resourceIdentifier(String)}/{@link McpConfig#authorizationServerIssuer(String)}
|
||||||
|
* remain as explicit overrides for the rare case where that guess is wrong.
|
||||||
*/
|
*/
|
||||||
|
@Slf4j
|
||||||
final class McpOidcIntegration {
|
final class McpOidcIntegration {
|
||||||
|
|
||||||
|
private static final String[] NO_VALUES = new String[0];
|
||||||
|
|
||||||
private McpOidcIntegration() {}
|
private McpOidcIntegration() {}
|
||||||
|
|
||||||
/** Returns the security {@link Middleware} to apply, or {@code null} if oidc is not installed. */
|
/** Everything {@link McpExtension} needs once oidc security is resolved. */
|
||||||
static Middleware resolve(FlashContext ctx, McpConfig config) {
|
record Resolved(Middleware security, String issuer, Function<Request, String> resourceIdentifier) {}
|
||||||
|
|
||||||
|
/** Returns the resolved security bundle, or {@code null} if oidc is not installed. */
|
||||||
|
static Resolved resolve(FlashContext ctx, McpConfig config) {
|
||||||
Optional<OidcMiddleware> oidc = ctx.find(OidcMiddleware.class);
|
Optional<OidcMiddleware> oidc = ctx.find(OidcMiddleware.class);
|
||||||
if (oidc.isEmpty()) return null;
|
if (oidc.isEmpty()) return null;
|
||||||
|
|
||||||
Middleware protect = oidc.get().protect();
|
OidcMiddleware oidcMw = oidc.get();
|
||||||
String resourceId = config.resourceIdentifier();
|
String resourceMetadataPath = "/.well-known/oauth-protected-resource" + config.rootPath();
|
||||||
if (resourceId == null || resourceId.isBlank()) return protect;
|
String issuer = config.authorizationServerIssuer() != null
|
||||||
|
? config.authorizationServerIssuer() : oidcMw.issuer();
|
||||||
|
Function<Request, String> resourceId = req -> config.resourceIdentifier() != null
|
||||||
|
? config.resourceIdentifier()
|
||||||
|
: OidcMiddleware.selfOrigin(req, oidcMw.selfScheme()) + config.rootPath();
|
||||||
|
|
||||||
return Middleware.of(protect, audienceGuard(resourceId));
|
Middleware protect = oidcMw.protect(resourceMetadataPath);
|
||||||
|
Middleware secured = Middleware.of(protect, audienceGuard(resourceId));
|
||||||
|
return new Resolved(secured, issuer, resourceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** RFC 8707 audience binding: rejects tokens whose {@code aud} claim doesn't include ours. */
|
/**
|
||||||
private static Middleware audienceGuard(String resourceIdentifier) {
|
* RFC 8707 audience binding, unconditionally enforced once oidc is protecting the MCP
|
||||||
|
* route — no longer opt-in behind an explicit {@code resourceIdentifier(...)} call.
|
||||||
|
*/
|
||||||
|
private static Middleware audienceGuard(Function<Request, String> resourceIdentifier) {
|
||||||
return next -> (req, res) -> {
|
return next -> (req, res) -> {
|
||||||
Map<String, Object> claims = ClaimsHolder.get();
|
Map<String, Object> claims = ClaimsHolder.get();
|
||||||
if (claims != null && !audienceMatches(claims.get("aud"), resourceIdentifier)) {
|
String expected = resourceIdentifier.apply(req);
|
||||||
|
if (claims != null && !audienceMatches(claims.get("aud"), expected)) {
|
||||||
|
log.warn("[flash-ext-mcp] Rejecting token (RFC 8707): aud={} does not include expected " +
|
||||||
|
"resource identifier \"{}\" — the authorization server must include this exact " +
|
||||||
|
"value in the access token's aud claim (e.g. an Audience protocol mapper in " +
|
||||||
|
"Keycloak) for this MCP server to accept it.", claims.get("aud"), expected);
|
||||||
throw HttpException.forbidden();
|
throw HttpException.forbidden();
|
||||||
}
|
}
|
||||||
return next.handle(req, res);
|
return next.handle(req, res);
|
||||||
@@ -53,4 +92,88 @@ final class McpOidcIntegration {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compiles {@code @RolesAllowed}/{@code @ScopesAllowed} on a tool class into a {@link
|
||||||
|
* McpAuthPolicy}, or returns {@code null} if the tool carries none of the three OIDC
|
||||||
|
* annotations. Called once per tool at boot ({@link McpRegistry#scan}), never on the
|
||||||
|
* request hot path — the {@link Supplier} it returns is what runs per {@code tools/call},
|
||||||
|
* closing over the already-normalized role/scope arrays so the hot path itself allocates
|
||||||
|
* nothing beyond what {@link OidcUser#hasRole}/{@link OidcUser#hasScope} already do.
|
||||||
|
*
|
||||||
|
* <p>Fails fast at boot, not silently at request time, for the two ways this can be
|
||||||
|
* misconfigured: the annotation present without OAuth2 actually protecting this MCP server
|
||||||
|
* ({@code oidcActive == false}), and {@code @Authenticated} — which has no per-tool meaning
|
||||||
|
* here (see below) — used at all.
|
||||||
|
*/
|
||||||
|
static McpAuthPolicy compileToolPolicy(Class<? extends McpTool> toolClass, boolean oidcActive,
|
||||||
|
String rolesClaimPath) {
|
||||||
|
Authenticated auth = toolClass.getAnnotation(Authenticated.class);
|
||||||
|
RolesAllowed roles = toolClass.getAnnotation(RolesAllowed.class);
|
||||||
|
ScopesAllowed scopes = toolClass.getAnnotation(ScopesAllowed.class);
|
||||||
|
if (auth == null && roles == null && scopes == null) return null;
|
||||||
|
|
||||||
|
if (!oidcActive) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"MCP tool \"" + toolClass.getSimpleName() + "\" declares @Authenticated/@RolesAllowed/" +
|
||||||
|
"@ScopesAllowed, but this MCP server has no active OAuth2 protection — flash-ext-oidc " +
|
||||||
|
"is not installed for it, or McpSecurity is NONE. These annotations require " +
|
||||||
|
"McpSecurity.AUTO/REQUIRED with an OidcExtension installed; install one, or remove the " +
|
||||||
|
"annotation from " + toolClass.getSimpleName() + ".");
|
||||||
|
}
|
||||||
|
if (auth != null) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"MCP tool \"" + toolClass.getSimpleName() + "\" is annotated @Authenticated, which has " +
|
||||||
|
"no effect on an McpTool: the whole MCP endpoint is already all-or-nothing " +
|
||||||
|
"authenticated once oidc is active (McpSecurity.AUTO/REQUIRED) — unlike a RequestHandler " +
|
||||||
|
"route, there is no per-tool public/authenticated split to opt into. Remove it, or use " +
|
||||||
|
"@RolesAllowed/@ScopesAllowed to narrow further.");
|
||||||
|
}
|
||||||
|
|
||||||
|
String[] requiredRoles = roles != null ? normalizeRequired("RolesAllowed", roles.value()) : NO_VALUES;
|
||||||
|
String[] requiredScopes = scopes != null ? normalizeRequired("ScopesAllowed", scopes.value()) : NO_VALUES;
|
||||||
|
ScopesAllowed.Match scopeMatch = scopes != null ? scopes.match() : ScopesAllowed.Match.ALL;
|
||||||
|
|
||||||
|
Supplier<String> check = () -> {
|
||||||
|
OidcUser user = ClaimsHolder.user();
|
||||||
|
if (user == null) return "not authenticated";
|
||||||
|
if (requiredRoles.length > 0 && !hasAnyRole(user, rolesClaimPath, requiredRoles))
|
||||||
|
return "missing required role (any of: " + String.join(", ", requiredRoles) + ")";
|
||||||
|
if (requiredScopes.length > 0 && !hasScopes(user, requiredScopes, scopeMatch))
|
||||||
|
return "missing required scope (" + scopeMatch + " of: " + String.join(", ", requiredScopes) + ")";
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
return new McpAuthPolicy(check);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasAnyRole(OidcUser user, String claimPath, String[] roles) {
|
||||||
|
for (String role : roles) if (user.hasRole(claimPath, role)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasScopes(OidcUser user, String[] scopes, ScopesAllowed.Match match) {
|
||||||
|
if (match == ScopesAllowed.Match.ALL) {
|
||||||
|
for (String scope : scopes) if (!user.hasScope(scope)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (String scope : scopes) if (user.hasScope(scope)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors {@code OidcAuthPolicy}'s own normalization — trim, dedupe, require non-blank. */
|
||||||
|
private static String[] normalizeRequired(String annotationName, String[] values) {
|
||||||
|
if (values == null || values.length == 0)
|
||||||
|
throw new IllegalStateException("@" + annotationName + " requires at least one value");
|
||||||
|
|
||||||
|
LinkedHashSet<String> normalized = new LinkedHashSet<>(values.length);
|
||||||
|
for (String raw : values) {
|
||||||
|
if (raw == null) continue;
|
||||||
|
String trimmed = raw.trim();
|
||||||
|
if (!trimmed.isEmpty()) normalized.add(trimmed);
|
||||||
|
}
|
||||||
|
if (normalized.isEmpty())
|
||||||
|
throw new IllegalStateException("@" + annotationName + " requires at least one non-empty value");
|
||||||
|
|
||||||
|
return normalized.toArray(String[]::new);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-3
@@ -24,7 +24,8 @@ final class McpRegistry {
|
|||||||
|
|
||||||
private static final String EMPTY_ARRAY = "[]";
|
private static final String EMPTY_ARRAY = "[]";
|
||||||
|
|
||||||
record RegisteredTool(String name, McpTool instance) {}
|
/** {@code policy} is {@code null} unless the tool carries @RolesAllowed/@ScopesAllowed. */
|
||||||
|
record RegisteredTool(String name, McpTool instance, McpAuthPolicy policy) {}
|
||||||
record RegisteredResource(String uri, McpResource instance) {}
|
record RegisteredResource(String uri, McpResource instance) {}
|
||||||
record RegisteredPrompt(String name, McpPrompt instance) {}
|
record RegisteredPrompt(String name, McpPrompt instance) {}
|
||||||
|
|
||||||
@@ -38,7 +39,16 @@ final class McpRegistry {
|
|||||||
|
|
||||||
private McpRegistry() {}
|
private McpRegistry() {}
|
||||||
|
|
||||||
static McpRegistry scan(String packageName, FlashContext ctx) {
|
/**
|
||||||
|
* @param oidcActive whether this MCP server's route is actually OAuth2-protected right
|
||||||
|
* now (see {@link McpOidcIntegration#resolve}) — gates whether
|
||||||
|
* {@code @RolesAllowed}/{@code @ScopesAllowed} on a tool are honored or
|
||||||
|
* rejected at boot as a misconfiguration; see
|
||||||
|
* {@link McpOidcIntegration#compileToolPolicy}.
|
||||||
|
* @param rolesClaimPath claim path forwarded to {@code @RolesAllowed} checks; see
|
||||||
|
* {@link McpConfig#rolesClaimPath(String)}.
|
||||||
|
*/
|
||||||
|
static McpRegistry scan(String packageName, FlashContext ctx, boolean oidcActive, String rolesClaimPath) {
|
||||||
McpPackageScanner.ScanResult found = McpPackageScanner.scan(packageName);
|
McpPackageScanner.ScanResult found = McpPackageScanner.scan(packageName);
|
||||||
McpRegistry registry = new McpRegistry();
|
McpRegistry registry = new McpRegistry();
|
||||||
|
|
||||||
@@ -46,7 +56,8 @@ final class McpRegistry {
|
|||||||
Tool ann = cls.getAnnotation(Tool.class);
|
Tool ann = cls.getAnnotation(Tool.class);
|
||||||
McpTool instance = instantiate(cls);
|
McpTool instance = instantiate(cls);
|
||||||
instance.bind(ctx);
|
instance.bind(ctx);
|
||||||
if (registry.tools.putIfAbsent(ann.name(), new RegisteredTool(ann.name(), instance)) != null)
|
McpAuthPolicy policy = compileToolPolicy(cls, oidcActive, rolesClaimPath);
|
||||||
|
if (registry.tools.putIfAbsent(ann.name(), new RegisteredTool(ann.name(), instance, policy)) != null)
|
||||||
throw new InitializationException("Duplicate MCP tool name: \"" + ann.name() + "\"");
|
throw new InitializationException("Duplicate MCP tool name: \"" + ann.name() + "\"");
|
||||||
}
|
}
|
||||||
for (Class<? extends McpResource> cls : found.resources()) {
|
for (Class<? extends McpResource> cls : found.resources()) {
|
||||||
@@ -163,6 +174,24 @@ final class McpRegistry {
|
|||||||
gen.writeEndArray();
|
gen.writeEndArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Isolated the same way {@link McpOidcIntegration#resolve} is — {@code
|
||||||
|
* NoClassDefFoundError} here means {@code flash-ext-oidc} genuinely isn't on the runtime
|
||||||
|
* classpath, in which case a tool couldn't have been compiled against
|
||||||
|
* {@code @RolesAllowed}/{@code @ScopesAllowed} in the first place, so there's nothing to
|
||||||
|
* check (and nothing lost: {@code oidcActive} is only ever {@code true} once {@link
|
||||||
|
* McpOidcIntegration#resolve} has already succeeded once this boot, which proves those
|
||||||
|
* types resolve fine).
|
||||||
|
*/
|
||||||
|
private static McpAuthPolicy compileToolPolicy(Class<? extends McpTool> cls, boolean oidcActive,
|
||||||
|
String rolesClaimPath) {
|
||||||
|
try {
|
||||||
|
return McpOidcIntegration.compileToolPolicy(cls, oidcActive, rolesClaimPath);
|
||||||
|
} catch (NoClassDefFoundError e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static <T> T instantiate(Class<T> cls) {
|
private static <T> T instantiate(Class<T> cls) {
|
||||||
try {
|
try {
|
||||||
Constructor<T> ctor = cls.getDeclaredConstructor();
|
Constructor<T> ctor = cls.getDeclaredConstructor();
|
||||||
|
|||||||
+9
-1
@@ -1,17 +1,25 @@
|
|||||||
package dev.relism.flash.ext.mcp;
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/** RFC 9728 OAuth 2.0 Protected Resource Metadata document, built once at boot. */
|
/** RFC 9728 OAuth 2.0 Protected Resource Metadata document, built once at boot. */
|
||||||
final class McpResourceMetadata {
|
final class McpResourceMetadata {
|
||||||
|
|
||||||
private McpResourceMetadata() {}
|
private McpResourceMetadata() {}
|
||||||
|
|
||||||
static String build(String resourceIdentifier, String authorizationServerIssuer) {
|
/** {@code scopesSupported} is optional per RFC 9728 — omitted from the document if empty. */
|
||||||
|
static String build(String resourceIdentifier, String authorizationServerIssuer, List<String> scopesSupported) {
|
||||||
return McpJson.buildString(gen -> {
|
return McpJson.buildString(gen -> {
|
||||||
gen.writeStartObject();
|
gen.writeStartObject();
|
||||||
gen.writeStringField("resource", resourceIdentifier);
|
gen.writeStringField("resource", resourceIdentifier);
|
||||||
gen.writeArrayFieldStart("authorization_servers");
|
gen.writeArrayFieldStart("authorization_servers");
|
||||||
gen.writeString(authorizationServerIssuer);
|
gen.writeString(authorizationServerIssuer);
|
||||||
gen.writeEndArray();
|
gen.writeEndArray();
|
||||||
|
if (!scopesSupported.isEmpty()) {
|
||||||
|
gen.writeArrayFieldStart("scopes_supported");
|
||||||
|
for (String scope : scopesSupported) gen.writeString(scope);
|
||||||
|
gen.writeEndArray();
|
||||||
|
}
|
||||||
gen.writeEndObject();
|
gen.writeEndObject();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-4
@@ -19,6 +19,8 @@ import java.security.interfaces.RSAPrivateKey;
|
|||||||
import java.security.interfaces.RSAPublicKey;
|
import java.security.interfaces.RSAPublicKey;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,16 +58,27 @@ final class FakeOidcProvider implements AutoCloseable {
|
|||||||
|
|
||||||
/** Mints a valid RS256 access token — bearer-validation only, no full authorization-code round-trip needed. */
|
/** Mints a valid RS256 access token — bearer-validation only, no full authorization-code round-trip needed. */
|
||||||
String signToken(String subject, String audience) {
|
String signToken(String subject, String audience) {
|
||||||
|
return signToken(subject, audience, null, NO_ROLES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as {@link #signToken(String, String)}, plus a {@code scope} claim (space-delimited,
|
||||||
|
* matching {@link dev.relism.flash.ext.oidc.OidcUser#hasScope}'s default claim path) and a
|
||||||
|
* Keycloak-shaped {@code realm_access.roles} claim (matching {@code McpConfig}'s default
|
||||||
|
* {@code rolesClaimPath}) when {@code roles} is non-empty.
|
||||||
|
*/
|
||||||
|
String signToken(String subject, String audience, String scope, String... roles) {
|
||||||
try {
|
try {
|
||||||
JWTClaimsSet claims = new JWTClaimsSet.Builder()
|
JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder()
|
||||||
.issuer(issuer)
|
.issuer(issuer)
|
||||||
.subject(subject)
|
.subject(subject)
|
||||||
.audience(audience)
|
.audience(audience)
|
||||||
.issueTime(Date.from(Instant.now()))
|
.issueTime(Date.from(Instant.now()))
|
||||||
.expirationTime(Date.from(Instant.now().plusSeconds(300)))
|
.expirationTime(Date.from(Instant.now().plusSeconds(300)));
|
||||||
.build();
|
if (scope != null) builder.claim("scope", scope);
|
||||||
|
if (roles.length > 0) builder.claim("realm_access", Map.of("roles", List.of(roles)));
|
||||||
SignedJWT jwt = new SignedJWT(
|
SignedJWT jwt = new SignedJWT(
|
||||||
new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID()).build(), claims);
|
new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID()).build(), builder.build());
|
||||||
jwt.sign(new RSASSASigner(rsaKey));
|
jwt.sign(new RSASSASigner(rsaKey));
|
||||||
return jwt.serialize();
|
return jwt.serialize();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -73,6 +86,8 @@ final class FakeOidcProvider implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final String[] NO_ROLES = new String[0];
|
||||||
|
|
||||||
private String discoveryDocument() {
|
private String discoveryDocument() {
|
||||||
return "{"
|
return "{"
|
||||||
+ "\"issuer\":\"" + issuer + "\","
|
+ "\"issuer\":\"" + issuer + "\","
|
||||||
|
|||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.oidc.OidcConfig;
|
||||||
|
import dev.relism.flash.ext.oidc.OidcExtension;
|
||||||
|
import dev.relism.flash.extension.FlashApp;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.net.ServerSocket;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code @RolesAllowed}/{@code @ScopesAllowed} on an {@link McpTool} — see
|
||||||
|
* {@link McpOidcIntegration#compileToolPolicy}. Same real-discovery/real-JWKS/real-RS256-token
|
||||||
|
* approach as {@link McpExtensionSecurityTest}, against {@code fixtures.secured}'s tools.
|
||||||
|
*/
|
||||||
|
class McpAuthPolicyTest {
|
||||||
|
|
||||||
|
private static final String SECURED_TOOLS = "dev.relism.flash.ext.mcp.authfixtures.secured";
|
||||||
|
private static final String AUTHENTICATED_ONLY_TOOLS = "dev.relism.flash.ext.mcp.authfixtures.authenticatedonly";
|
||||||
|
|
||||||
|
private FlashApp app;
|
||||||
|
private FakeOidcProvider provider;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
if (app != null) app.stop();
|
||||||
|
if (provider != null) provider.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rolesAllowed_deniesWithoutRole_allowsWithRole() throws Exception {
|
||||||
|
int port = bootSecuredApp(SECURED_TOOLS);
|
||||||
|
String resourceId = "http://127.0.0.1:" + port + "/mcp";
|
||||||
|
|
||||||
|
String noRole = provider.signToken("user-1", resourceId, null);
|
||||||
|
HttpResponse<String> denied = callTool(port, "admin_only", noRole);
|
||||||
|
assertEquals(200, denied.statusCode());
|
||||||
|
assertTrue(denied.body().contains("\"isError\":true"), denied.body());
|
||||||
|
assertTrue(denied.body().contains("missing required role"), denied.body());
|
||||||
|
|
||||||
|
String withRole = provider.signToken("user-1", resourceId, null, "admin");
|
||||||
|
HttpResponse<String> allowed = callTool(port, "admin_only", withRole);
|
||||||
|
assertEquals(200, allowed.statusCode());
|
||||||
|
assertTrue(allowed.body().contains("\"isError\":false"), allowed.body());
|
||||||
|
assertTrue(allowed.body().contains("ok"), allowed.body());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void scopesAllowed_deniesWithoutScope_allowsWithScope() throws Exception {
|
||||||
|
int port = bootSecuredApp(SECURED_TOOLS);
|
||||||
|
String resourceId = "http://127.0.0.1:" + port + "/mcp";
|
||||||
|
|
||||||
|
String noScope = provider.signToken("user-1", resourceId, "read");
|
||||||
|
HttpResponse<String> denied = callTool(port, "write_only", noScope);
|
||||||
|
assertEquals(200, denied.statusCode());
|
||||||
|
assertTrue(denied.body().contains("\"isError\":true"), denied.body());
|
||||||
|
assertTrue(denied.body().contains("missing required scope"), denied.body());
|
||||||
|
|
||||||
|
String withScope = provider.signToken("user-1", resourceId, "read write");
|
||||||
|
HttpResponse<String> allowed = callTool(port, "write_only", withScope);
|
||||||
|
assertEquals(200, allowed.statusCode());
|
||||||
|
assertTrue(allowed.body().contains("\"isError\":false"), allowed.body());
|
||||||
|
assertTrue(allowed.body().contains("written"), allowed.body());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unannotatedTool_unaffectedByOtherToolsPolicies() throws Exception {
|
||||||
|
int port = bootSecuredApp(SECURED_TOOLS);
|
||||||
|
String resourceId = "http://127.0.0.1:" + port + "/mcp";
|
||||||
|
|
||||||
|
String plain = provider.signToken("user-1", resourceId, null);
|
||||||
|
HttpResponse<String> resp = callTool(port, "open", plain);
|
||||||
|
assertEquals(200, resp.statusCode());
|
||||||
|
assertTrue(resp.body().contains("\"isError\":false"), resp.body());
|
||||||
|
assertTrue(resp.body().contains("open"), resp.body());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toolAnnotated_butSecurityNone_failsAtBoot() throws Exception {
|
||||||
|
provider = new FakeOidcProvider();
|
||||||
|
int port = freePort();
|
||||||
|
app = FlashApp.create(port);
|
||||||
|
app.install(new OidcExtension(OidcConfig.builder(
|
||||||
|
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
|
||||||
|
app.install(new McpExtension(McpConfig.builder("secure-server")
|
||||||
|
.toolsPackage(SECURED_TOOLS)
|
||||||
|
.security(McpSecurity.NONE)
|
||||||
|
.build()));
|
||||||
|
|
||||||
|
IllegalStateException e = assertThrows(IllegalStateException.class, () -> app.start());
|
||||||
|
assertTrue(e.getMessage().contains("no active OAuth2 protection"), e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bareAuthenticated_hasNoEffect_failsAtBoot() throws Exception {
|
||||||
|
provider = new FakeOidcProvider();
|
||||||
|
int port = freePort();
|
||||||
|
app = FlashApp.create(port);
|
||||||
|
app.install(new OidcExtension(OidcConfig.builder(
|
||||||
|
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
|
||||||
|
app.install(new McpExtension(McpConfig.builder("secure-server")
|
||||||
|
.toolsPackage(AUTHENTICATED_ONLY_TOOLS)
|
||||||
|
.security(McpSecurity.REQUIRED)
|
||||||
|
.build()));
|
||||||
|
|
||||||
|
IllegalStateException e = assertThrows(IllegalStateException.class, () -> app.start());
|
||||||
|
assertTrue(e.getMessage().contains("no effect"), e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private int bootSecuredApp(String toolsPackage) throws Exception {
|
||||||
|
provider = new FakeOidcProvider();
|
||||||
|
int port = freePort();
|
||||||
|
|
||||||
|
app = FlashApp.create(port);
|
||||||
|
app.install(new OidcExtension(OidcConfig.builder(
|
||||||
|
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
|
||||||
|
app.install(new McpExtension(McpConfig.builder("secure-server")
|
||||||
|
.toolsPackage(toolsPackage)
|
||||||
|
.security(McpSecurity.REQUIRED)
|
||||||
|
.build()));
|
||||||
|
app.start();
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpResponse<String> callTool(int port, String toolName, String token) throws Exception {
|
||||||
|
String body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"" + toolName + "\"}}";
|
||||||
|
HttpRequest.Builder req = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/mcp"))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.header("Authorization", "Bearer " + token)
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(body));
|
||||||
|
return HttpClient.newHttpClient().send(req.build(), HttpResponse.BodyHandlers.ofString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int freePort() throws Exception {
|
||||||
|
try (ServerSocket s = new ServerSocket(0)) {
|
||||||
|
return s.getLocalPort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
@@ -88,6 +88,69 @@ class McpExtensionSecurityTest {
|
|||||||
assertTrue(resp.body().contains("\"protocolVersion\""));
|
assertTrue(resp.body().contains("\"protocolVersion\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void required_withOidc_noExplicitResourceIdentifier_derivesFromRequestAndEnforcesAudience() throws Exception {
|
||||||
|
int port = bootSecuredApp(null);
|
||||||
|
String derivedResourceId = "http://127.0.0.1:" + port + "/mcp";
|
||||||
|
|
||||||
|
String matching = provider.signToken("user-1", derivedResourceId);
|
||||||
|
assertEquals(200, post(port, initializeBody(), matching).statusCode());
|
||||||
|
|
||||||
|
String mismatched = provider.signToken("user-1", "https://someone-else.example.com/resource");
|
||||||
|
assertEquals(403, post(port, initializeBody(), mismatched).statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void required_withOidc_missingToken_challengeIncludesResourceMetadata() throws Exception {
|
||||||
|
int port = bootSecuredApp(null);
|
||||||
|
|
||||||
|
HttpResponse<String> resp = post(port, initializeBody(), null);
|
||||||
|
assertEquals(401, resp.statusCode());
|
||||||
|
String challenge = resp.headers().firstValue("WWW-Authenticate").orElse("");
|
||||||
|
assertTrue(challenge.contains(
|
||||||
|
"resource_metadata=\"http://127.0.0.1:" + port + "/.well-known/oauth-protected-resource/mcp\""),
|
||||||
|
"WWW-Authenticate: " + challenge);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void required_withOidc_noExplicitConfig_publishesProtectedResourceMetadata() throws Exception {
|
||||||
|
int port = bootSecuredApp(null);
|
||||||
|
|
||||||
|
HttpResponse<String> resp = HttpClient.newHttpClient().send(
|
||||||
|
HttpRequest.newBuilder(URI.create(
|
||||||
|
"http://127.0.0.1:" + port + "/.well-known/oauth-protected-resource/mcp")).GET().build(),
|
||||||
|
HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
|
assertEquals(200, resp.statusCode());
|
||||||
|
assertTrue(resp.body().contains("\"resource\":\"http://127.0.0.1:" + port + "/mcp\""), resp.body());
|
||||||
|
assertTrue(resp.body().contains("\"authorization_servers\":[\"" + provider.issuer() + "\"]"), resp.body());
|
||||||
|
assertTrue(!resp.body().contains("scopes_supported"), "scopes_supported must be omitted when unset: " + resp.body());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void scopesSupported_published_inProtectedResourceMetadata() throws Exception {
|
||||||
|
provider = new FakeOidcProvider();
|
||||||
|
int port = freePort();
|
||||||
|
|
||||||
|
app = FlashApp.create(port);
|
||||||
|
app.install(new OidcExtension(OidcConfig.builder(
|
||||||
|
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
|
||||||
|
app.install(new McpExtension(McpConfig.builder("secure-server")
|
||||||
|
.toolsPackage(TOOLS_PACKAGE)
|
||||||
|
.security(McpSecurity.REQUIRED)
|
||||||
|
.scopesSupported("openid", "profile", "email")
|
||||||
|
.build()));
|
||||||
|
app.start();
|
||||||
|
|
||||||
|
HttpResponse<String> resp = HttpClient.newHttpClient().send(
|
||||||
|
HttpRequest.newBuilder(URI.create(
|
||||||
|
"http://127.0.0.1:" + port + "/.well-known/oauth-protected-resource/mcp")).GET().build(),
|
||||||
|
HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
|
assertEquals(200, resp.statusCode());
|
||||||
|
assertTrue(resp.body().contains("\"scopes_supported\":[\"openid\",\"profile\",\"email\"]"), resp.body());
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private int bootSecuredApp(String resourceIdentifier) throws Exception {
|
private int bootSecuredApp(String resourceIdentifier) throws Exception {
|
||||||
|
|||||||
+2
-2
@@ -16,7 +16,7 @@ class McpRegistryTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void scan_findsAndPrecompilesToolsResourcesPrompts() throws Exception {
|
void scan_findsAndPrecompilesToolsResourcesPrompts() throws Exception {
|
||||||
McpRegistry registry = McpRegistry.scan("dev.relism.flash.ext.mcp.fixtures", new FlashContext());
|
McpRegistry registry = McpRegistry.scan("dev.relism.flash.ext.mcp.fixtures", new FlashContext(), false, "realm_access.roles");
|
||||||
|
|
||||||
assertTrue(registry.hasTools());
|
assertTrue(registry.hasTools());
|
||||||
assertTrue(registry.hasResources());
|
assertTrue(registry.hasResources());
|
||||||
@@ -47,7 +47,7 @@ class McpRegistryTest {
|
|||||||
@Test
|
@Test
|
||||||
void scan_emptyPackage_throwsInitializationException() {
|
void scan_emptyPackage_throwsInitializationException() {
|
||||||
assertThrows(InitializationException.class,
|
assertThrows(InitializationException.class,
|
||||||
() -> McpRegistry.scan("dev.relism.flash.ext.mcp.doesnotexist", new FlashContext()));
|
() -> McpRegistry.scan("dev.relism.flash.ext.mcp.doesnotexist", new FlashContext(), false, "realm_access.roles"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static JsonNode findByField(JsonNode array, String field, String value) {
|
private static JsonNode findByField(JsonNode array, String field, String value) {
|
||||||
|
|||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package dev.relism.flash.ext.mcp.authfixtures.authenticatedonly;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.mcp.McpTool;
|
||||||
|
import dev.relism.flash.ext.mcp.TextContent;
|
||||||
|
import dev.relism.flash.ext.mcp.Tool;
|
||||||
|
import dev.relism.flash.ext.mcp.ToolArguments;
|
||||||
|
import dev.relism.flash.ext.mcp.ToolResponse;
|
||||||
|
import dev.relism.flash.ext.oidc.Authenticated;
|
||||||
|
|
||||||
|
/** Deliberately misconfigured fixture: bare @Authenticated has no effect on an McpTool — see
|
||||||
|
* McpOidcIntegration#compileToolPolicy. Boot must fail with a clear message, not silently no-op. */
|
||||||
|
@Tool(name = "pointless", description = "Exists only to prove @Authenticated alone fails boot")
|
||||||
|
@Authenticated
|
||||||
|
public class PointlessAuthTool extends McpTool {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ToolResponse call(ToolArguments args) {
|
||||||
|
return ToolResponse.success(new TextContent("unreachable"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package dev.relism.flash.ext.mcp.authfixtures.secured;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.mcp.McpTool;
|
||||||
|
import dev.relism.flash.ext.mcp.TextContent;
|
||||||
|
import dev.relism.flash.ext.mcp.Tool;
|
||||||
|
import dev.relism.flash.ext.mcp.ToolArguments;
|
||||||
|
import dev.relism.flash.ext.mcp.ToolResponse;
|
||||||
|
import dev.relism.flash.ext.oidc.RolesAllowed;
|
||||||
|
|
||||||
|
@Tool(name = "admin_only", description = "Only callable with the admin role")
|
||||||
|
@RolesAllowed("admin")
|
||||||
|
public class AdminOnlyTool extends McpTool {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ToolResponse call(ToolArguments args) {
|
||||||
|
return ToolResponse.success(new TextContent("ok"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package dev.relism.flash.ext.mcp.authfixtures.secured;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.mcp.McpTool;
|
||||||
|
import dev.relism.flash.ext.mcp.TextContent;
|
||||||
|
import dev.relism.flash.ext.mcp.Tool;
|
||||||
|
import dev.relism.flash.ext.mcp.ToolArguments;
|
||||||
|
import dev.relism.flash.ext.mcp.ToolResponse;
|
||||||
|
|
||||||
|
/** No role/scope annotation — any authenticated caller, confirms unrelated tools are unaffected. */
|
||||||
|
@Tool(name = "open", description = "Callable by anyone already authenticated")
|
||||||
|
public class OpenTool extends McpTool {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ToolResponse call(ToolArguments args) {
|
||||||
|
return ToolResponse.success(new TextContent("open"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package dev.relism.flash.ext.mcp.authfixtures.secured;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.mcp.McpTool;
|
||||||
|
import dev.relism.flash.ext.mcp.TextContent;
|
||||||
|
import dev.relism.flash.ext.mcp.Tool;
|
||||||
|
import dev.relism.flash.ext.mcp.ToolArguments;
|
||||||
|
import dev.relism.flash.ext.mcp.ToolResponse;
|
||||||
|
import dev.relism.flash.ext.oidc.ScopesAllowed;
|
||||||
|
|
||||||
|
@Tool(name = "write_only", description = "Only callable with the write scope")
|
||||||
|
@ScopesAllowed("write")
|
||||||
|
public class WriteScopeTool extends McpTool {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ToolResponse call(ToolArguments args) {
|
||||||
|
return ToolResponse.success(new TextContent("written"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-7
@@ -7,6 +7,8 @@ import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
|
|||||||
import dev.relism.flash.extension.FlashContext;
|
import dev.relism.flash.extension.FlashContext;
|
||||||
import dev.relism.flash.extension.FlashExtension;
|
import dev.relism.flash.extension.FlashExtension;
|
||||||
import dev.relism.flash.extension.FlashRegistrar;
|
import dev.relism.flash.extension.FlashRegistrar;
|
||||||
|
import dev.relism.flash.routing.MiddlewareKey;
|
||||||
|
import dev.relism.flash.routing.MiddlewareNode;
|
||||||
import dev.relism.flash.models.Request;
|
import dev.relism.flash.models.Request;
|
||||||
|
|
||||||
import javax.net.ssl.SSLContext;
|
import javax.net.ssl.SSLContext;
|
||||||
@@ -54,6 +56,7 @@ import java.util.*;
|
|||||||
* }</pre>
|
* }</pre>
|
||||||
*/
|
*/
|
||||||
public class OidcExtension implements FlashExtension {
|
public class OidcExtension implements FlashExtension {
|
||||||
|
private static final MiddlewareKey POLICY = MiddlewareKey.of("flash.oidc.policy");
|
||||||
|
|
||||||
private final OidcConfig config;
|
private final OidcConfig config;
|
||||||
|
|
||||||
@@ -71,7 +74,7 @@ public class OidcExtension implements FlashExtension {
|
|||||||
// ── Phase 1: services ─────────────────────────────────────────────────────
|
// ── Phase 1: services ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void provide(FlashContext ctx) {
|
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
HttpClient http = buildHttpClient(config);
|
HttpClient http = buildHttpClient(config);
|
||||||
|
|
||||||
// Discover provider endpoints (blocking; fail fast at startup).
|
// Discover provider endpoints (blocking; fail fast at startup).
|
||||||
@@ -91,14 +94,12 @@ public class OidcExtension implements FlashExtension {
|
|||||||
|
|
||||||
ctx.addAnnotationProcessor(handlerClass -> {
|
ctx.addAnnotationProcessor(handlerClass -> {
|
||||||
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
|
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
|
||||||
return policy != null ? List.of(oidcMw.policyMiddleware(policy)) : List.of();
|
return policy != null ? List.of(MiddlewareNode.of(POLICY, oidcMw.policyMiddleware(policy))) : List.of();
|
||||||
});
|
});
|
||||||
|
ctx.onReady(() -> registerRoutes(app, ctx));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Phase 2: routes ───────────────────────────────────────────────────────
|
private void registerRoutes(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
|
|
||||||
@Override
|
|
||||||
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
|
|
||||||
String prefix = config.routePrefix();
|
String prefix = config.routePrefix();
|
||||||
|
|
||||||
// ── GET {prefix}/login ────────────────────────────────────────────────
|
// ── GET {prefix}/login ────────────────────────────────────────────────
|
||||||
@@ -252,7 +253,7 @@ public class OidcExtension implements FlashExtension {
|
|||||||
|
|
||||||
private String absoluteSelf(Request req, String uri) {
|
private String absoluteSelf(Request req, String uri) {
|
||||||
if (!uri.startsWith("/")) return uri;
|
if (!uri.startsWith("/")) return uri;
|
||||||
return config.selfScheme() + "://" + req.header("Host") + uri;
|
return OidcMiddleware.selfOrigin(req, config.selfScheme()) + uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String enc(String v) {
|
private static String enc(String v) {
|
||||||
|
|||||||
+60
-5
@@ -65,8 +65,21 @@ public class OidcMiddleware {
|
|||||||
* to the login page on failure; API clients receive 401.
|
* to the login page on failure; API clients receive 401.
|
||||||
*/
|
*/
|
||||||
public Middleware protect() {
|
public Middleware protect() {
|
||||||
|
return protect(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like {@link #protect()}, but a 401 challenge also carries {@code resource_metadata}
|
||||||
|
* (RFC 9728 §5.1), resolved against this request's own scheme/host exactly like
|
||||||
|
* {@link OidcExtension}'s redirect URIs. {@code resourceMetadataPath} is an absolute path
|
||||||
|
* (e.g. {@code "/.well-known/oauth-protected-resource/mcp"}); pass {@code null} for plain
|
||||||
|
* challenges. Used by {@code flash-ext-mcp} to make its Protected Resource Metadata
|
||||||
|
* document discoverable straight from the {@code WWW-Authenticate} header, per the MCP
|
||||||
|
* Authorization spec.
|
||||||
|
*/
|
||||||
|
public Middleware protect(String resourceMetadataPath) {
|
||||||
return next -> (req, res) -> {
|
return next -> (req, res) -> {
|
||||||
Map<String, Object> claims = resolve(req, res);
|
Map<String, Object> claims = resolve(req, res, resourceMetadataPath);
|
||||||
if (claims == null) return null; // redirect already written
|
if (claims == null) return null; // redirect already written
|
||||||
ClaimsHolder.set(claims);
|
ClaimsHolder.set(claims);
|
||||||
try {
|
try {
|
||||||
@@ -77,6 +90,12 @@ public class OidcMiddleware {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** OIDC issuer this middleware validates tokens against — the {@code iss} claim it enforces. */
|
||||||
|
public String issuer() { return config.issuer(); }
|
||||||
|
|
||||||
|
/** Scheme used to build this app's own absolute URLs — see {@link OidcConfig#selfScheme()}. */
|
||||||
|
public String selfScheme() { return config.selfScheme(); }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Silently populates {@link ClaimsHolder} if a valid bearer token or session cookie
|
* Silently populates {@link ClaimsHolder} if a valid bearer token or session cookie
|
||||||
* is present, but never rejects or redirects unauthenticated requests. Use this on
|
* is present, but never rejects or redirects unauthenticated requests. Use this on
|
||||||
@@ -195,13 +214,17 @@ public class OidcMiddleware {
|
|||||||
* {@code res}. Throws {@link HttpException} 401/403 for API clients.
|
* {@code res}. Throws {@link HttpException} 401/403 for API clients.
|
||||||
*/
|
*/
|
||||||
private Map<String, Object> resolve(Request req, Response res) {
|
private Map<String, Object> resolve(Request req, Response res) {
|
||||||
|
return resolve(req, res, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> resolve(Request req, Response res, String resourceMetadataPath) {
|
||||||
// 1. Bearer token
|
// 1. Bearer token
|
||||||
String bearerToken = extractBearerToken(req.header("Authorization"));
|
String bearerToken = extractBearerToken(req.header("Authorization"));
|
||||||
if (bearerToken != null) {
|
if (bearerToken != null) {
|
||||||
try {
|
try {
|
||||||
return validator.validate(bearerToken);
|
return validator.validate(bearerToken);
|
||||||
} catch (HttpException e) {
|
} catch (HttpException e) {
|
||||||
res.header("WWW-Authenticate", invalidTokenChallenge());
|
res.header("WWW-Authenticate", invalidTokenChallenge(req, resourceMetadataPath));
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,7 +256,7 @@ public class OidcMiddleware {
|
|||||||
// 3. No valid credentials
|
// 3. No valid credentials
|
||||||
String accept = req.header("Accept");
|
String accept = req.header("Accept");
|
||||||
if (accept != null && accept.contains("application/json")) {
|
if (accept != null && accept.contains("application/json")) {
|
||||||
res.header("WWW-Authenticate", bearerChallenge());
|
res.header("WWW-Authenticate", bearerChallenge(req, resourceMetadataPath));
|
||||||
throw HttpException.unauthorized();
|
throw HttpException.unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,11 +323,21 @@ public class OidcMiddleware {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String bearerChallenge() {
|
String bearerChallenge() {
|
||||||
return BEARER + " realm=\"" + quoted(config.schemeName()) + "\"";
|
return bearerChallenge(null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String bearerChallenge(Request req, String resourceMetadataPath) {
|
||||||
|
String base = BEARER + " realm=\"" + quoted(config.schemeName()) + "\"";
|
||||||
|
if (resourceMetadataPath == null) return base;
|
||||||
|
return base + ", resource_metadata=\"" + quoted(absoluteSelf(req, resourceMetadataPath)) + "\"";
|
||||||
}
|
}
|
||||||
|
|
||||||
String invalidTokenChallenge() {
|
String invalidTokenChallenge() {
|
||||||
return bearerChallenge() + ", error=\"invalid_token\"";
|
return invalidTokenChallenge(null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String invalidTokenChallenge(Request req, String resourceMetadataPath) {
|
||||||
|
return bearerChallenge(req, resourceMetadataPath) + ", error=\"invalid_token\"";
|
||||||
}
|
}
|
||||||
|
|
||||||
String insufficientScopeChallenge(String[] requiredScopes) {
|
String insufficientScopeChallenge(String[] requiredScopes) {
|
||||||
@@ -312,6 +345,28 @@ public class OidcMiddleware {
|
|||||||
+ quoted(spaceDelimited(requiredScopes)) + "\"";
|
+ quoted(spaceDelimited(requiredScopes)) + "\"";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String absoluteSelf(Request req, String path) {
|
||||||
|
if (!path.startsWith("/")) return path;
|
||||||
|
return selfOrigin(req, config.selfScheme()) + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code scheme://host} clients actually reach this app on — the basis for every absolute
|
||||||
|
* URL it publishes about itself (OAuth2 {@code redirect_uri}, the RFC 9728 resource
|
||||||
|
* identifier and the {@code resource_metadata} challenge). Behind a reverse proxy the
|
||||||
|
* request's own {@code Host} is the upstream address the proxy dialled, so
|
||||||
|
* {@code X-Forwarded-Host}/{@code -Proto} win whenever present: without them the app would
|
||||||
|
* name an address no client can resolve, and OAuth2 discovery fails with no error anyone
|
||||||
|
* can trace back to here. Trusted unconditionally — a caller able to reach this app without
|
||||||
|
* passing the proxy can do worse than spoof a self URL.
|
||||||
|
*/
|
||||||
|
public static String selfOrigin(Request req, String fallbackScheme) {
|
||||||
|
String forwardedHost = req.header("X-Forwarded-Host");
|
||||||
|
if (forwardedHost == null) return fallbackScheme + "://" + req.header("Host");
|
||||||
|
String forwardedProto = req.header("X-Forwarded-Proto");
|
||||||
|
return (forwardedProto != null ? forwardedProto : fallbackScheme) + "://" + forwardedHost;
|
||||||
|
}
|
||||||
|
|
||||||
private static String spaceDelimited(String[] values) {
|
private static String spaceDelimited(String[] values) {
|
||||||
if (values == null || values.length == 0) return "";
|
if (values == null || values.length == 0) return "";
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
|
|||||||
+1
@@ -104,6 +104,7 @@ class OidcOpenApiInteropTest {
|
|||||||
FlashContext ctx = new FlashContext();
|
FlashContext ctx = new FlashContext();
|
||||||
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
||||||
ctx.provide(OpenApiContributorRegistry.class, registry);
|
ctx.provide(OpenApiContributorRegistry.class, registry);
|
||||||
|
ctx.complete();
|
||||||
|
|
||||||
OidcConfig config = OidcConfig.builder("https://issuer", "c", "s", "/cb").build();
|
OidcConfig config = OidcConfig.builder("https://issuer", "c", "s", "/cb").build();
|
||||||
OidcProviderMetadata meta = new OidcProviderMetadata("a", "t", "u", "j", "e");
|
OidcProviderMetadata meta = new OidcProviderMetadata("a", "t", "u", "j", "e");
|
||||||
|
|||||||
+7
-9
@@ -4,8 +4,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||||
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
|
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
|
||||||
import dev.relism.flash.extension.FlashContext;
|
import dev.relism.flash.extension.FlashContext;
|
||||||
import dev.relism.flash.extension.FlashExtension;
|
|
||||||
import dev.relism.flash.extension.FlashRegistrar;
|
import dev.relism.flash.extension.FlashRegistrar;
|
||||||
|
import dev.relism.flash.extension.FlashExtension;
|
||||||
import dev.relism.flash.extension.RouteEvent;
|
import dev.relism.flash.extension.RouteEvent;
|
||||||
import dev.relism.flash.http.ContentType;
|
import dev.relism.flash.http.ContentType;
|
||||||
import dev.relism.flash.http.HttpMethod;
|
import dev.relism.flash.http.HttpMethod;
|
||||||
@@ -65,7 +65,7 @@ public class OpenApiExtension implements FlashExtension {
|
|||||||
// ── FlashExtension ────────────────────────────────────────────────────────
|
// ── FlashExtension ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void provide(FlashContext ctx) {
|
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
OpenApiBuilder builder = new OpenApiBuilder().title(title).version(version).description(description);
|
OpenApiBuilder builder = new OpenApiBuilder().title(title).version(version).description(description);
|
||||||
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
||||||
|
|
||||||
@@ -76,22 +76,20 @@ public class OpenApiExtension implements FlashExtension {
|
|||||||
// Collect operation metadata from final compiled routes.
|
// Collect operation metadata from final compiled routes.
|
||||||
// This guarantees full runtime paths (namespaces/prefixes/rewrites) in the spec.
|
// This guarantees full runtime paths (namespaces/prefixes/rewrites) in the spec.
|
||||||
ctx.addRouteListener(event -> addOperationFromEvent(builder, event));
|
ctx.addRouteListener(event -> addOperationFromEvent(builder, event));
|
||||||
}
|
ctx.onReady(() -> {
|
||||||
|
|
||||||
@Override
|
|
||||||
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
|
|
||||||
ObjectMapper jsonMapper = ctx.find(ObjectMapper.class).orElseGet(() -> JsonMapper.builder().build());
|
ObjectMapper jsonMapper = ctx.find(ObjectMapper.class).orElseGet(() -> JsonMapper.builder().build());
|
||||||
YAMLMapper yamlMapper = new YAMLMapper();
|
YAMLMapper yamlMapper = new YAMLMapper();
|
||||||
OpenApiBuilder builder = ctx.require(OpenApiBuilder.class);
|
OpenApiBuilder resolvedBuilder = ctx.require(OpenApiBuilder.class);
|
||||||
|
|
||||||
String jsonPath = basePath + ".json";
|
String jsonPath = basePath + ".json";
|
||||||
String yamlPath = basePath + ".yaml";
|
String yamlPath = basePath + ".yaml";
|
||||||
String swaggerPath = basePath + "/swagger";
|
String swaggerPath = basePath + "/swagger";
|
||||||
String swaggerHtml = buildSwaggerHtml(jsonPath);
|
String swaggerHtml = buildSwaggerHtml(jsonPath);
|
||||||
|
|
||||||
app.get(jsonPath, (req, res) -> { res.type(ContentType.JSON); return jsonMapper.writeValueAsString(builder.build()); });
|
app.get(jsonPath, (req, res) -> { res.type(ContentType.JSON); return jsonMapper.writeValueAsString(resolvedBuilder.build()); });
|
||||||
app.get(yamlPath, (req, res) -> { res.type(YAML_CONTENT_TYPE); return yamlMapper.writeValueAsString(builder.build()); });
|
app.get(yamlPath, (req, res) -> { res.type(YAML_CONTENT_TYPE); return yamlMapper.writeValueAsString(resolvedBuilder.build()); });
|
||||||
app.get(swaggerPath, (req, res) -> { res.type(ContentType.TEXT_HTML); return swaggerHtml; });
|
app.get(swaggerPath, (req, res) -> { res.type(ContentType.TEXT_HTML); return swaggerHtml; });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Swagger UI HTML ───────────────────────────────────────────────────────
|
// ── Swagger UI HTML ───────────────────────────────────────────────────────
|
||||||
|
|||||||
+13
-13
@@ -11,7 +11,7 @@ import dev.relism.flash.http.HttpMethod;
|
|||||||
import dev.relism.flash.models.RequestHandler;
|
import dev.relism.flash.models.RequestHandler;
|
||||||
import dev.relism.flash.models.Response;
|
import dev.relism.flash.models.Response;
|
||||||
import dev.relism.flash.routing.GET;
|
import dev.relism.flash.routing.GET;
|
||||||
import dev.relism.flash.routing.Middleware;
|
import dev.relism.flash.routing.MiddlewareNode;
|
||||||
import dev.relism.flash.websocket.WebSocketEndpoint;
|
import dev.relism.flash.websocket.WebSocketEndpoint;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -41,12 +41,10 @@ class OpenApiExtensionTest {
|
|||||||
void provide_collects_operations_and_routes_serve_json_yaml_swagger() throws Exception {
|
void provide_collects_operations_and_routes_serve_json_yaml_swagger() throws Exception {
|
||||||
FlashContext ctx = new FlashContext();
|
FlashContext ctx = new FlashContext();
|
||||||
OpenApiExtension ext = new OpenApiExtension("/docs", "My API", "2.0.0", "desc");
|
OpenApiExtension ext = new OpenApiExtension("/docs", "My API", "2.0.0", "desc");
|
||||||
ext.provide(ctx);
|
|
||||||
|
|
||||||
emitRoute(ctx, HttpMethod.GET, "/health", "/", HealthHandler.class);
|
|
||||||
|
|
||||||
TestRegistrar app = new TestRegistrar(ctx);
|
TestRegistrar app = new TestRegistrar(ctx);
|
||||||
ext.routes(app, ctx);
|
ext.configure(app, ctx);
|
||||||
|
emitRoute(ctx, HttpMethod.GET, "/health", "/", HealthHandler.class);
|
||||||
|
ctx.complete();
|
||||||
|
|
||||||
assertNotNull(app.route(HttpMethod.GET, "/docs.json"));
|
assertNotNull(app.route(HttpMethod.GET, "/docs.json"));
|
||||||
assertNotNull(app.route(HttpMethod.GET, "/docs.yaml"));
|
assertNotNull(app.route(HttpMethod.GET, "/docs.yaml"));
|
||||||
@@ -77,9 +75,9 @@ class OpenApiExtensionTest {
|
|||||||
ObjectMapper mapper = new ObjectMapper();
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
ctx.provide(ObjectMapper.class, mapper);
|
ctx.provide(ObjectMapper.class, mapper);
|
||||||
|
|
||||||
ext.provide(ctx);
|
|
||||||
TestRegistrar app = new TestRegistrar(ctx);
|
TestRegistrar app = new TestRegistrar(ctx);
|
||||||
ext.routes(app, ctx);
|
ext.configure(app, ctx);
|
||||||
|
ctx.complete();
|
||||||
|
|
||||||
Response jsonRes = new Response(200, ContentType.NONE);
|
Response jsonRes = new Response(200, ContentType.NONE);
|
||||||
Object jsonBody = app.route(HttpMethod.GET, "/openapi.json").handle(null, jsonRes);
|
Object jsonBody = app.route(HttpMethod.GET, "/openapi.json").handle(null, jsonRes);
|
||||||
@@ -99,10 +97,11 @@ class OpenApiExtensionTest {
|
|||||||
void collects_full_runtime_path_from_route_event() {
|
void collects_full_runtime_path_from_route_event() {
|
||||||
FlashContext ctx = new FlashContext();
|
FlashContext ctx = new FlashContext();
|
||||||
OpenApiExtension ext = new OpenApiExtension();
|
OpenApiExtension ext = new OpenApiExtension();
|
||||||
ext.provide(ctx);
|
ext.configure(new TestRegistrar(ctx), ctx);
|
||||||
|
|
||||||
emitRoute(ctx, HttpMethod.GET, "/api/v1/users", "/api/v1", ScopedUsersHandler.class);
|
emitRoute(ctx, HttpMethod.GET, "/api/v1/users", "/api/v1", ScopedUsersHandler.class);
|
||||||
|
|
||||||
|
ctx.complete();
|
||||||
OpenApiBuilder builder = ctx.require(OpenApiBuilder.class);
|
OpenApiBuilder builder = ctx.require(OpenApiBuilder.class);
|
||||||
Map<String, Object> spec = builder.build();
|
Map<String, Object> spec = builder.build();
|
||||||
Map<String, Object> paths = cast(spec.get("paths"));
|
Map<String, Object> paths = cast(spec.get("paths"));
|
||||||
@@ -114,10 +113,11 @@ class OpenApiExtensionTest {
|
|||||||
void normalizes_double_slash_paths_from_events() {
|
void normalizes_double_slash_paths_from_events() {
|
||||||
FlashContext ctx = new FlashContext();
|
FlashContext ctx = new FlashContext();
|
||||||
OpenApiExtension ext = new OpenApiExtension();
|
OpenApiExtension ext = new OpenApiExtension();
|
||||||
ext.provide(ctx);
|
ext.configure(new TestRegistrar(ctx), ctx);
|
||||||
|
|
||||||
emitRoute(ctx, HttpMethod.GET, "//blogs", "/", ScopedUsersHandler.class);
|
emitRoute(ctx, HttpMethod.GET, "//blogs", "/", ScopedUsersHandler.class);
|
||||||
|
|
||||||
|
ctx.complete();
|
||||||
OpenApiBuilder builder = ctx.require(OpenApiBuilder.class);
|
OpenApiBuilder builder = ctx.require(OpenApiBuilder.class);
|
||||||
Map<String, Object> spec = builder.build();
|
Map<String, Object> spec = builder.build();
|
||||||
Map<String, Object> paths = cast(spec.get("paths"));
|
Map<String, Object> paths = cast(spec.get("paths"));
|
||||||
@@ -150,7 +150,7 @@ class OpenApiExtensionTest {
|
|||||||
private static final class TestRegistrar extends FlashRegistrar<TestRegistrar> {
|
private static final class TestRegistrar extends FlashRegistrar<TestRegistrar> {
|
||||||
private final FlashContext ctx;
|
private final FlashContext ctx;
|
||||||
private final Map<String, RequestHandler> routes = new HashMap<>();
|
private final Map<String, RequestHandler> routes = new HashMap<>();
|
||||||
private final List<Middleware> middlewares = new ArrayList<>();
|
private final List<MiddlewareNode> middlewares = new ArrayList<>();
|
||||||
|
|
||||||
private TestRegistrar(FlashContext ctx) {
|
private TestRegistrar(FlashContext ctx) {
|
||||||
this.ctx = ctx;
|
this.ctx = ctx;
|
||||||
@@ -162,7 +162,7 @@ class OpenApiExtensionTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@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) {
|
||||||
routes.put(method.name() + " " + path, handler);
|
routes.put(method.name() + " " + path, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +172,7 @@ class OpenApiExtensionTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void addMiddleware(Middleware mw) {
|
protected void addMiddleware(MiddlewareNode mw) {
|
||||||
middlewares.add(mw);
|
middlewares.add(mw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-10
@@ -1,10 +1,9 @@
|
|||||||
package dev.relism.flash.ext.routeviewer;
|
package dev.relism.flash.ext.routeviewer;
|
||||||
|
|
||||||
import dev.relism.flash.ext.routeviewer.model.RouteGraph;
|
import dev.relism.flash.ext.routeviewer.model.RouteGraph;
|
||||||
import dev.relism.flash.extension.ExtensionPhase;
|
|
||||||
import dev.relism.flash.extension.FlashContext;
|
import dev.relism.flash.extension.FlashContext;
|
||||||
import dev.relism.flash.extension.FlashExtension;
|
|
||||||
import dev.relism.flash.extension.FlashRegistrar;
|
import dev.relism.flash.extension.FlashRegistrar;
|
||||||
|
import dev.relism.flash.extension.FlashExtension;
|
||||||
import dev.relism.flash.http.ContentType;
|
import dev.relism.flash.http.ContentType;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,9 +40,6 @@ public class RouteViewerExtension implements FlashExtension {
|
|||||||
private final String path;
|
private final String path;
|
||||||
private final RouteGraph graph = new RouteGraph();
|
private final RouteGraph graph = new RouteGraph();
|
||||||
|
|
||||||
/** Observability — runs last so the viewer sees the complete middleware chain. */
|
|
||||||
@Override public int priority() { return ExtensionPhase.LATE.value; }
|
|
||||||
|
|
||||||
/** Installs the viewer at {@value #DEFAULT_PATH}. */
|
/** Installs the viewer at {@value #DEFAULT_PATH}. */
|
||||||
public RouteViewerExtension() { this(DEFAULT_PATH); }
|
public RouteViewerExtension() { this(DEFAULT_PATH); }
|
||||||
|
|
||||||
@@ -54,16 +50,14 @@ public class RouteViewerExtension implements FlashExtension {
|
|||||||
public RouteViewerExtension(String path) { this.path = path; }
|
public RouteViewerExtension(String path) { this.path = path; }
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void provide(FlashContext ctx) {
|
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
// Register listener before any routes compile — captures everything.
|
// Register listener before any routes compile — captures everything.
|
||||||
ctx.addRouteListener(graph::add);
|
ctx.addRouteListener(graph::add);
|
||||||
}
|
ctx.onReady(() -> {
|
||||||
|
|
||||||
@Override
|
|
||||||
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
|
|
||||||
app.get(path, new RouteViewerHandler()::handle);
|
app.get(path, new RouteViewerHandler()::handle);
|
||||||
app.get(path + "/app.js", new RouteViewerStaticHandler("routeviewer/app.js", ContentType.TEXT_JAVASCRIPT)::handle);
|
app.get(path + "/app.js", new RouteViewerStaticHandler("routeviewer/app.js", ContentType.TEXT_JAVASCRIPT)::handle);
|
||||||
app.get(path + "/app.css", new RouteViewerStaticHandler("routeviewer/app.css", ContentType.TEXT_CSS)::handle);
|
app.get(path + "/app.css", new RouteViewerStaticHandler("routeviewer/app.css", ContentType.TEXT_CSS)::handle);
|
||||||
app.get(path + "/data", new RouteViewerDataHandler(graph)::handle);
|
app.get(path + "/data", new RouteViewerDataHandler(graph)::handle);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -1,9 +1,11 @@
|
|||||||
package dev.relism.flash.ext.view.core;
|
package dev.relism.flash.ext.view.core;
|
||||||
|
|
||||||
import dev.relism.flash.extension.FlashContext;
|
import dev.relism.flash.extension.FlashContext;
|
||||||
|
import dev.relism.flash.extension.FlashRegistrar;
|
||||||
import dev.relism.flash.extension.FlashExtension;
|
import dev.relism.flash.extension.FlashExtension;
|
||||||
import dev.relism.flash.models.Request;
|
import dev.relism.flash.models.Request;
|
||||||
import dev.relism.flash.models.RequestHandler;
|
import dev.relism.flash.models.RequestHandler;
|
||||||
|
import dev.relism.flash.routing.MiddlewareNode;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -30,7 +32,7 @@ public abstract class BaseViewExtension<TTarget> implements FlashExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public final void provide(FlashContext ctx) {
|
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
ViewRuntimeBridge<TTarget> runtime = createRuntime(List.copyOf(globals));
|
ViewRuntimeBridge<TTarget> runtime = createRuntime(List.copyOf(globals));
|
||||||
ctx.provide(ViewRuntimeBridge.class, runtime);
|
ctx.provide(ViewRuntimeBridge.class, runtime);
|
||||||
ctx.addAnnotationProcessor(handlerClass -> {
|
ctx.addAnnotationProcessor(handlerClass -> {
|
||||||
|
|||||||
+4
-2
@@ -57,15 +57,17 @@ public final class JteExtension extends BaseViewExtension<JteTarget> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
|
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
|
super.configure(app, ctx);
|
||||||
|
ctx.onReady(() -> {
|
||||||
if (!settings.serveStatics()) return;
|
if (!settings.serveStatics()) return;
|
||||||
JteStaticServing staticServing = JteStaticServing.load(settings);
|
JteStaticServing staticServing = JteStaticServing.load(settings);
|
||||||
if (staticServing == null) return;
|
if (staticServing == null) return;
|
||||||
|
|
||||||
String wildcard = settings.staticPrefix() + "/**";
|
String wildcard = settings.staticPrefix() + "/**";
|
||||||
StaticJteHandler handler = new StaticJteHandler(staticServing);
|
StaticJteHandler handler = new StaticJteHandler(staticServing);
|
||||||
app.get(wildcard, handler::handle);
|
app.get(wildcard, handler::handle);
|
||||||
app.head(wildcard, (req, res) -> { staticServing.serve(req, res, true); return null; });
|
app.head(wildcard, (req, res) -> { staticServing.serve(req, res, true); return null; });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
+12
-6
@@ -93,7 +93,9 @@ class JteExtensionTest {
|
|||||||
void routes_register_static_wildcard_when_enabled() {
|
void routes_register_static_wildcard_when_enabled() {
|
||||||
JteExtension ext = new JteExtension(cfg -> cfg.staticPrefix("/assets"));
|
JteExtension ext = new JteExtension(cfg -> cfg.staticPrefix("/assets"));
|
||||||
TestRegistrar app = new TestRegistrar();
|
TestRegistrar app = new TestRegistrar();
|
||||||
ext.routes(app, new dev.relism.flash.extension.FlashContext());
|
dev.relism.flash.extension.FlashContext ctx = new dev.relism.flash.extension.FlashContext();
|
||||||
|
ext.configure(app, ctx);
|
||||||
|
ctx.complete();
|
||||||
assertTrue(app.routes.containsKey("GET /assets/**"));
|
assertTrue(app.routes.containsKey("GET /assets/**"));
|
||||||
assertTrue(app.routes.containsKey("HEAD /assets/**"));
|
assertTrue(app.routes.containsKey("HEAD /assets/**"));
|
||||||
}
|
}
|
||||||
@@ -102,7 +104,9 @@ class JteExtensionTest {
|
|||||||
void routes_do_not_register_static_when_disabled() {
|
void routes_do_not_register_static_when_disabled() {
|
||||||
JteExtension ext = new JteExtension().serveStatics(false);
|
JteExtension ext = new JteExtension().serveStatics(false);
|
||||||
TestRegistrar app = new TestRegistrar();
|
TestRegistrar app = new TestRegistrar();
|
||||||
ext.routes(app, new dev.relism.flash.extension.FlashContext());
|
dev.relism.flash.extension.FlashContext ctx = new dev.relism.flash.extension.FlashContext();
|
||||||
|
ext.configure(app, ctx);
|
||||||
|
ctx.complete();
|
||||||
assertTrue(app.routes.isEmpty());
|
assertTrue(app.routes.isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +116,9 @@ class JteExtensionTest {
|
|||||||
.staticPrefix("/assets")
|
.staticPrefix("/assets")
|
||||||
.largeFileThresholdBytes(1));
|
.largeFileThresholdBytes(1));
|
||||||
TestRegistrar app = new TestRegistrar();
|
TestRegistrar app = new TestRegistrar();
|
||||||
ext.routes(app, new dev.relism.flash.extension.FlashContext());
|
dev.relism.flash.extension.FlashContext ctx = new dev.relism.flash.extension.FlashContext();
|
||||||
|
ext.configure(app, ctx);
|
||||||
|
ctx.complete();
|
||||||
|
|
||||||
Request req = request("/assets/sample.css", null, null, null);
|
Request req = request("/assets/sample.css", null, null, null);
|
||||||
Response res = new Response(200, dev.relism.flash.http.ContentType.TEXT_PLAIN);
|
Response res = new Response(200, dev.relism.flash.http.ContentType.TEXT_PLAIN);
|
||||||
@@ -161,7 +167,7 @@ class JteExtensionTest {
|
|||||||
|
|
||||||
private static final class TestRegistrar extends dev.relism.flash.extension.FlashRegistrar<TestRegistrar> {
|
private static final class TestRegistrar extends dev.relism.flash.extension.FlashRegistrar<TestRegistrar> {
|
||||||
private final Map<String, RequestHandler> routes = new HashMap<>();
|
private final Map<String, RequestHandler> routes = new HashMap<>();
|
||||||
private final List<dev.relism.flash.routing.Middleware> mws = new ArrayList<>();
|
private final List<dev.relism.flash.routing.MiddlewareNode> mws = new ArrayList<>();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public dev.relism.flash.extension.FlashContext ctx() {
|
public dev.relism.flash.extension.FlashContext ctx() {
|
||||||
@@ -169,7 +175,7 @@ class JteExtensionTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void addRoute(dev.relism.flash.http.HttpMethod method, String path, RequestHandler handler, List<dev.relism.flash.routing.Middleware> mw) {
|
protected void addRoute(dev.relism.flash.http.HttpMethod method, String path, RequestHandler handler, List<dev.relism.flash.routing.MiddlewareNode> mw) {
|
||||||
routes.put(method.name() + " " + path, handler);
|
routes.put(method.name() + " " + path, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +185,7 @@ class JteExtensionTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void addMiddleware(dev.relism.flash.routing.Middleware mw) {
|
protected void addMiddleware(dev.relism.flash.routing.MiddlewareNode mw) {
|
||||||
mws.add(mw);
|
mws.add(mw);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-12
@@ -1,9 +1,8 @@
|
|||||||
package dev.relism.flash.ext.webbundler;
|
package dev.relism.flash.ext.webbundler;
|
||||||
|
|
||||||
import dev.relism.flash.extension.ExtensionPhase;
|
|
||||||
import dev.relism.flash.extension.FlashContext;
|
import dev.relism.flash.extension.FlashContext;
|
||||||
import dev.relism.flash.extension.FlashExtension;
|
|
||||||
import dev.relism.flash.extension.FlashRegistrar;
|
import dev.relism.flash.extension.FlashRegistrar;
|
||||||
|
import dev.relism.flash.extension.FlashExtension;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
@@ -24,11 +23,6 @@ public final class WebBundlerExtension implements FlashExtension {
|
|||||||
private ScheduledExecutorService watchLoop;
|
private ScheduledExecutorService watchLoop;
|
||||||
private Thread shutdownHook;
|
private Thread shutdownHook;
|
||||||
|
|
||||||
@Override
|
|
||||||
public int priority() {
|
|
||||||
return ExtensionPhase.LATE.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public WebBundlerExtension() {
|
public WebBundlerExtension() {
|
||||||
this(WebBundlerConfig.builder().build());
|
this(WebBundlerConfig.builder().build());
|
||||||
}
|
}
|
||||||
@@ -38,7 +32,7 @@ public final class WebBundlerExtension implements FlashExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void provide(FlashContext ctx) {
|
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
RuntimeEnvironment environment = modeResolver.resolve(config);
|
RuntimeEnvironment environment = modeResolver.resolve(config);
|
||||||
PackageManagerAdapter pmAdapter = new PackageManagerAdapter(config);
|
PackageManagerAdapter pmAdapter = new PackageManagerAdapter(config);
|
||||||
FrontendStrategy strategy = frontendTypeResolver.resolve(config.frontendType());
|
FrontendStrategy strategy = frontendTypeResolver.resolve(config.frontendType());
|
||||||
@@ -74,10 +68,7 @@ public final class WebBundlerExtension implements FlashExtension {
|
|||||||
shutdownResources(orchestrator);
|
shutdownResources(orchestrator);
|
||||||
throw ex;
|
throw ex;
|
||||||
}
|
}
|
||||||
}
|
ctx.onReady(() -> {
|
||||||
|
|
||||||
@Override
|
|
||||||
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
|
|
||||||
WebBundlerRuntime runtime = ctx.require(WebBundlerRuntime.class);
|
WebBundlerRuntime runtime = ctx.require(WebBundlerRuntime.class);
|
||||||
boolean orchestrated = runtime.environment() == RuntimeEnvironment.DEV && config.frontendType().requiresOrchestration();
|
boolean orchestrated = runtime.environment() == RuntimeEnvironment.DEV && config.frontendType().requiresOrchestration();
|
||||||
if (orchestrated || config.operationMode() == OperationMode.ORCHESTRATE_ONLY) {
|
if (orchestrated || config.operationMode() == OperationMode.ORCHESTRATE_ONLY) {
|
||||||
@@ -101,6 +92,7 @@ public final class WebBundlerExtension implements FlashExtension {
|
|||||||
res.body(new byte[0]);
|
res.body(new byte[0]);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void bootstrapDev(
|
private void bootstrapDev(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package dev.relism.flash.extension;
|
package dev.relism.flash.extension;
|
||||||
|
|
||||||
import dev.relism.flash.models.RequestHandler;
|
import dev.relism.flash.models.RequestHandler;
|
||||||
import dev.relism.flash.routing.Middleware;
|
import dev.relism.flash.routing.MiddlewareNode;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -18,5 +18,5 @@ import java.util.List;
|
|||||||
*/
|
*/
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
public interface AnnotationProcessor {
|
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.AbstractRouter;
|
||||||
import dev.relism.flash.routing.AbstractWsRouter;
|
import dev.relism.flash.routing.AbstractWsRouter;
|
||||||
import dev.relism.flash.routing.Middleware;
|
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.FastPathRouterImpl;
|
||||||
import dev.relism.flash.routing.routers.fastpathrouter.FastPathWsRouterImpl;
|
import dev.relism.flash.routing.routers.fastpathrouter.FastPathWsRouterImpl;
|
||||||
import dev.relism.flash.websocket.WebSocketEndpoint;
|
import dev.relism.flash.websocket.WebSocketEndpoint;
|
||||||
@@ -19,7 +21,6 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
@@ -41,10 +42,9 @@ import java.util.function.Consumer;
|
|||||||
*
|
*
|
||||||
* <h3>Startup sequence (both {@link #start()} and {@link #startAndBlock()})</h3>
|
* <h3>Startup sequence (both {@link #start()} and {@link #startAndBlock()})</h3>
|
||||||
* <ol>
|
* <ol>
|
||||||
* <li>Extensions sorted by {@link FlashExtension#priority()} — lower first.</li>
|
* <li>All {@link FlashExtension#configure} declarations.</li>
|
||||||
* <li>All {@link FlashExtension#provide} — register services, processors, listeners.</li>
|
|
||||||
* <li>{@link FlashContext#resolveAll()} — topo-sort, cycle detection.</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>Compile all routes into one flat FSM — zero prefix scanning at runtime.</li>
|
||||||
* <li>Accept loop started.</li>
|
* <li>Accept loop started.</li>
|
||||||
* </ol>
|
* </ol>
|
||||||
@@ -62,7 +62,7 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
|||||||
private final ServerHandle server;
|
private final ServerHandle server;
|
||||||
private final FlashContext ctx = new FlashContext();
|
private final FlashContext ctx = new FlashContext();
|
||||||
private final List<FlashExtension> extensions = new ArrayList<>();
|
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<RouteDefinition> deferredRoutes = new ArrayList<>();
|
||||||
private final List<WsRouteDefinition> deferredWsRoutes = new ArrayList<>();
|
private final List<WsRouteDefinition> deferredWsRoutes = new ArrayList<>();
|
||||||
|
|
||||||
@@ -131,10 +131,7 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
|||||||
// ── Extensions ────────────────────────────────────────────────────────────
|
// ── Extensions ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers an extension for two-phase installation at startup.
|
* Registers a declarative extension contribution.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
public FlashApp install(FlashExtension ext) {
|
public FlashApp install(FlashExtension ext) {
|
||||||
extensions.add(ext);
|
extensions.add(ext);
|
||||||
@@ -174,7 +171,7 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
|||||||
// ── FlashRegistrar impl ───────────────────────────────────────────────────
|
// ── FlashRegistrar impl ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@Override
|
@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(
|
deferredRoutes.add(new RouteDefinition(
|
||||||
method, path, handler, List.of(), mw,
|
method, path, handler, List.of(), mw,
|
||||||
!(handler instanceof SimpleHandler), ctx, "/"));
|
!(handler instanceof SimpleHandler), ctx, "/"));
|
||||||
@@ -186,27 +183,25 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void addMiddleware(Middleware mw) { globalMiddlewares.add(mw); }
|
protected void addMiddleware(MiddlewareNode mw) { globalMiddlewares.add(mw); }
|
||||||
|
|
||||||
// ── Boot ─────────────────────────────────────────────────────────────────
|
// ── Boot ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private void boot() {
|
private void boot() {
|
||||||
extensions.sort(Comparator.comparingInt(FlashExtension::priority));
|
extensions.forEach(e -> e.configure(this, ctx));
|
||||||
extensions.forEach(e -> e.provide(ctx));
|
ctx.complete();
|
||||||
ctx.resolveAll();
|
|
||||||
extensions.forEach(e -> e.routes(this, ctx));
|
|
||||||
compile();
|
compile();
|
||||||
compileWs();
|
compileWs();
|
||||||
|
router.compile();
|
||||||
|
wsRouter.compile();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Compilation ──────────────────────────────────────────────────────────
|
// ── Compilation ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static final Middleware[] EMPTY_MW = new Middleware[0];
|
|
||||||
|
|
||||||
/** Middleware chain order per route: Global → Scope → Annotation → Explicit. */
|
/** Middleware chain order per route: Global → Scope → Annotation → Explicit. */
|
||||||
private void compile() {
|
private void compile() {
|
||||||
for (RouteDefinition def : deferredRoutes) {
|
for (RouteDefinition def : deferredRoutes) {
|
||||||
List<Middleware> injected;
|
List<MiddlewareNode> injected;
|
||||||
if (def.classBasedHandler()) {
|
if (def.classBasedHandler()) {
|
||||||
injected = def.ctx().processors().stream()
|
injected = def.ctx().processors().stream()
|
||||||
.flatMap(p -> p.process(def.handler().getClass()).stream())
|
.flatMap(p -> p.process(def.handler().getClass()).stream())
|
||||||
@@ -215,7 +210,8 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
|||||||
} else {
|
} else {
|
||||||
injected = List.of();
|
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);
|
emitEvent(def, all);
|
||||||
router.doRegister(def.method(), def.path(), def.handler(), 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));
|
listeners.forEach(l -> l.onRoute(event));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Middleware[] concat(List<Middleware> global, List<Middleware> scope,
|
private static List<MiddlewareNode> concat(List<MiddlewareNode> global, List<MiddlewareNode> scope,
|
||||||
List<Middleware> injected, List<Middleware> explicit) {
|
List<MiddlewareNode> injected, List<MiddlewareNode> explicit) {
|
||||||
int total = global.size() + scope.size() + injected.size() + explicit.size();
|
int total = global.size() + scope.size() + injected.size() + explicit.size();
|
||||||
if (total == 0) return EMPTY_MW;
|
if (total == 0) return List.of();
|
||||||
Middleware[] all = new Middleware[total];
|
List<MiddlewareNode> all = new ArrayList<>(total);
|
||||||
int i = 0;
|
all.addAll(global); all.addAll(scope); all.addAll(injected); all.addAll(explicit);
|
||||||
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;
|
|
||||||
return all;
|
return all;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,176 +1,160 @@
|
|||||||
package dev.relism.flash.extension;
|
package dev.relism.flash.extension;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
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. */
|
||||||
* Central service registry and boot-time hook coordinator.
|
public final class FlashContext {
|
||||||
*
|
private enum State { DECLARING, RESOLVING, READY }
|
||||||
* <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 {
|
|
||||||
|
|
||||||
private final FlashContext parent;
|
private final FlashContext parent;
|
||||||
private final Map<Class<?>, Object> registry = new LinkedHashMap<>();
|
private final Map<Class<?>, Binding<?>> bindings = new LinkedHashMap<>();
|
||||||
private final Map<Class<?>, Supplier<?>> pending = new LinkedHashMap<>();
|
|
||||||
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<>();
|
||||||
// Lazy caches — nulled whenever the corresponding list is mutated.
|
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<AnnotationProcessor> cachedProcessors;
|
||||||
private List<RouteListener> cachedListeners;
|
private List<RouteListener> cachedListeners;
|
||||||
|
|
||||||
// DFS stack — tracks in-progress resolutions to detect circular dependencies.
|
public FlashContext() { parent = null; }
|
||||||
private final LinkedHashSet<Class<?>> resolutionStack = new LinkedHashSet<>();
|
|
||||||
|
|
||||||
public FlashContext() { this.parent = null; }
|
|
||||||
private FlashContext(FlashContext parent) { this.parent = parent; }
|
private FlashContext(FlashContext parent) { this.parent = parent; }
|
||||||
|
|
||||||
/** Creates a child context that inherits this context's services and processors. */
|
public FlashContext child() {
|
||||||
public FlashContext child() { return new FlashContext(this); }
|
requireDeclaring();
|
||||||
|
FlashContext child = new FlashContext(this);
|
||||||
|
children.add(child);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Service registry ─────────────────────────────────────────────────────
|
/** Binds an already-created singleton. Duplicate bindings are always an error. */
|
||||||
|
|
||||||
/** Registers an already-constructed {@code instance} under {@code type}. */
|
|
||||||
public <T> void provide(Class<T> type, T instance) {
|
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")));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Declares a no-dependency boot factory. */
|
||||||
* 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>
|
|
||||||
*/
|
|
||||||
public <T> void supply(Class<T> type, Supplier<T> 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()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Declares a boot factory and its complete dependency set. */
|
||||||
* Returns the service for {@code type}. Checks own scope first, then parent chain.
|
public <T> void supply(Class<T> type, ServiceFactory<T> factory, Class<?>... dependencies) {
|
||||||
* Lazy-registered types are resolved on first access. Circular dependencies throw
|
Objects.requireNonNull(factory, "factory");
|
||||||
* {@link IllegalStateException} with the full cycle path.
|
declare(type, new Binding<>(type, List.of(dependencies), factory));
|
||||||
*
|
}
|
||||||
* @throws IllegalStateException if the service is not found anywhere in the context chain
|
|
||||||
*/
|
/** 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")
|
@SuppressWarnings("unchecked")
|
||||||
public <T> T require(Class<T> type) {
|
public <T> T require(Class<T> type) {
|
||||||
Object val = registry.get(type);
|
if (state == State.DECLARING)
|
||||||
if (val != null) return (T) val;
|
throw new IllegalStateException("Service graph is still being declared; use FlashContext.onReady(...)");
|
||||||
if (pending.containsKey(type)) return resolve(type);
|
Binding<?> binding = bindings.get(type);
|
||||||
|
if (binding != null) {
|
||||||
|
verifyDeclaredDependency(type);
|
||||||
|
return (T) resolve((Binding<Object>) binding);
|
||||||
|
}
|
||||||
if (parent != null) return parent.require(type);
|
if (parent != null) return parent.require(type);
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException("No provider declared for " + type.getName() + dependencyTrace());
|
||||||
"Service not found: " + type.getSimpleName() +
|
|
||||||
" — register it via FlashContext.provide()/supply() or install the required extension");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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) {
|
public <T> Optional<T> find(Class<T> type) {
|
||||||
Object val = registry.get(type);
|
if (state == State.DECLARING)
|
||||||
if (val != null) return Optional.of((T) val);
|
throw new IllegalStateException("Service graph is still being declared; use FlashContext.onReady(...)");
|
||||||
if (pending.containsKey(type)) return Optional.of(resolve(type));
|
if (bindings.containsKey(type)) return Optional.of(require(type));
|
||||||
return parent != null ? parent.find(type) : Optional.empty();
|
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); }
|
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) {
|
public void addAnnotationProcessor(AnnotationProcessor processor) {
|
||||||
processors.add(processor);
|
requireDeclaring(); processors.add(Objects.requireNonNull(processor)); cachedProcessors = null;
|
||||||
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() {
|
List<AnnotationProcessor> processors() {
|
||||||
if (cachedProcessors != null) return cachedProcessors;
|
if (cachedProcessors != null) return cachedProcessors;
|
||||||
if (parent == null) return cachedProcessors = List.copyOf(processors);
|
List<AnnotationProcessor> all = parent == null ? new ArrayList<>() : new ArrayList<>(parent.processors());
|
||||||
List<AnnotationProcessor> p = parent.processors();
|
all.addAll(processors); return cachedProcessors = List.copyOf(all);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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() {
|
List<RouteListener> routeListeners() {
|
||||||
if (cachedListeners != null) return cachedListeners;
|
if (cachedListeners != null) return cachedListeners;
|
||||||
if (parent == null) return cachedListeners = List.copyOf(routeListeners);
|
List<RouteListener> all = parent == null ? new ArrayList<>() : new ArrayList<>(parent.routeListeners());
|
||||||
List<RouteListener> p = parent.routeListeners();
|
all.addAll(routeListeners); return cachedListeners = List.copyOf(all);
|
||||||
if (routeListeners.isEmpty()) return cachedListeners = p;
|
}
|
||||||
List<RouteListener> merged = new ArrayList<>(p.size() + routeListeners.size());
|
|
||||||
merged.addAll(p);
|
void resolveAll() {
|
||||||
merged.addAll(routeListeners);
|
if (state != State.DECLARING) throw new IllegalStateException("Service graph has already been closed");
|
||||||
return cachedListeners = List.copyOf(merged);
|
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;
|
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()}:
|
* <p>Extensions never control lifecycle ordering. During {@link #configure}, they declare
|
||||||
* <ol>
|
* services, processors, listeners and ready callbacks. Flash closes declarations, validates and
|
||||||
* <li>Extensions are sorted by {@link #priority()} — lower value runs first.</li>
|
* resolves the complete service graph, then executes ready callbacks to materialise routes.
|
||||||
* <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>
|
|
||||||
*/
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
public interface FlashExtension {
|
public interface FlashExtension {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 1 — register services and processors.
|
* Declares this extension's contribution. {@code ctx.require(...)} is intentionally illegal
|
||||||
* Safe: {@link FlashContext#provide}, {@link FlashContext#supply},
|
* here: work needing resolved services belongs in {@link FlashContext#onReady(Runnable)}.
|
||||||
* {@link FlashContext#addAnnotationProcessor}, {@link FlashContext#addRouteListener}.
|
|
||||||
* Unsafe: {@link FlashContext#require} (services not yet resolved).
|
|
||||||
*/
|
*/
|
||||||
default void provide(FlashContext ctx) {}
|
void configure(FlashRegistrar<?> app, 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; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ import dev.relism.flash.models.RequestHandler;
|
|||||||
import dev.relism.flash.models.SimpleHandler;
|
import dev.relism.flash.models.SimpleHandler;
|
||||||
import dev.relism.flash.websocket.WebSocketEndpoint;
|
import dev.relism.flash.websocket.WebSocketEndpoint;
|
||||||
import dev.relism.flash.routing.Middleware;
|
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.Route;
|
||||||
import dev.relism.flash.routing.Routes;
|
import dev.relism.flash.routing.Routes;
|
||||||
import dev.relism.flash.routing.Ws;
|
import dev.relism.flash.routing.Ws;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Common route-registration surface shared by {@link FlashApp} and {@link FlashScope}.
|
* 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)
|
* app.get("/admin", handler, oidc.requireRole("admin"), rateLimiter)
|
||||||
* }</pre>
|
* }</pre>
|
||||||
*
|
*
|
||||||
* <p>{@link FlashExtension#routes} receives a {@code FlashRegistrar<?>} for route
|
* <p>Extensions receive a {@link FlashApp} during their single configure declaration.
|
||||||
* registration. Extension installation ({@code install()}) is only available on
|
* Extension installation ({@code install()}) is only available on
|
||||||
* {@link FlashApp} — scoped install is intentionally unsupported.
|
* {@link FlashApp} — scoped install is intentionally unsupported.
|
||||||
*
|
*
|
||||||
* @param <SELF> concrete registrar type — enables fluent chaining without casting
|
* @param <SELF> concrete registrar type — enables fluent chaining without casting
|
||||||
*/
|
*/
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public abstract class FlashRegistrar<SELF extends FlashRegistrar<SELF>> {
|
public abstract class FlashRegistrar<SELF extends FlashRegistrar<SELF>> {
|
||||||
|
private static final AtomicLong INLINE_KEYS = new AtomicLong();
|
||||||
|
|
||||||
// ── HTTP method registration ──────────────────────────────────────────────
|
// ── 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 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 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 ────────────────────────────────────────────────────────────
|
// ── Middleware ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,7 +72,13 @@ public abstract class FlashRegistrar<SELF extends FlashRegistrar<SELF>> {
|
|||||||
* Order-independent: middleware is resolved at {@link FlashApp#start()}.
|
* Order-independent: middleware is resolved at {@link FlashApp#start()}.
|
||||||
*/
|
*/
|
||||||
public final SELF use(Middleware... middlewares) {
|
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;
|
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.
|
* Subclasses may prepend a namespace prefix and inject scope middlewares before storing.
|
||||||
*/
|
*/
|
||||||
protected abstract void addRoute(HttpMethod method, String path,
|
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);
|
protected abstract void addWsRoute(String path, WebSocketEndpoint endpoint);
|
||||||
|
|
||||||
/** Registers a middleware in this registrar's own scope (global or scope-level). */
|
/** 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) {
|
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));
|
addRoute(method, path, new SimpleHandler(h), mw.length == 0 ? List.of() : List.of(mw));
|
||||||
return (SELF) this;
|
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) {
|
protected static RequestHandler instantiate(Class<?> cls) {
|
||||||
try { return (RequestHandler) cls.getDeclaredConstructor().newInstance(); }
|
try { return (RequestHandler) cls.getDeclaredConstructor().newInstance(); }
|
||||||
catch (Exception e) {
|
catch (Exception e) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package dev.relism.flash.extension;
|
|||||||
import dev.relism.flash.http.HttpMethod;
|
import dev.relism.flash.http.HttpMethod;
|
||||||
import dev.relism.flash.models.RequestHandler;
|
import dev.relism.flash.models.RequestHandler;
|
||||||
import dev.relism.flash.models.SimpleHandler;
|
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.routing.PathUtils;
|
||||||
import dev.relism.flash.websocket.WebSocketEndpoint;
|
import dev.relism.flash.websocket.WebSocketEndpoint;
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ public final class FlashScope extends FlashRegistrar<FlashScope> {
|
|||||||
|
|
||||||
private final String namespace;
|
private final String namespace;
|
||||||
private final FlashContext ctx;
|
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<RouteDefinition> deferredRoutes = new ArrayList<>();
|
||||||
private final List<WsRouteDefinition> deferredWsRoutes = new ArrayList<>();
|
private final List<WsRouteDefinition> deferredWsRoutes = new ArrayList<>();
|
||||||
|
|
||||||
@@ -44,8 +44,8 @@ public final class FlashScope extends FlashRegistrar<FlashScope> {
|
|||||||
// ── FlashRegistrar impl ───────────────────────────────────────────────────
|
// ── FlashRegistrar impl ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@Override
|
@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) {
|
||||||
List<Middleware> scopeMw = scopeMiddlewares.isEmpty() ? List.of() : List.copyOf(scopeMiddlewares);
|
List<MiddlewareNode> scopeMw = scopeMiddlewares.isEmpty() ? List.of() : List.copyOf(scopeMiddlewares);
|
||||||
deferredRoutes.add(new RouteDefinition(
|
deferredRoutes.add(new RouteDefinition(
|
||||||
method, ns(path), handler, scopeMw, mw,
|
method, ns(path), handler, scopeMw, mw,
|
||||||
!(handler instanceof SimpleHandler), ctx, namespace));
|
!(handler instanceof SimpleHandler), ctx, namespace));
|
||||||
@@ -57,7 +57,7 @@ public final class FlashScope extends FlashRegistrar<FlashScope> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void addMiddleware(Middleware mw) { scopeMiddlewares.add(mw); }
|
protected void addMiddleware(MiddlewareNode mw) { scopeMiddlewares.add(mw); }
|
||||||
|
|
||||||
// ── Internal (called by FlashApp.mount) ───────────────────────────────────
|
// ── Internal (called by FlashApp.mount) ───────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package dev.relism.flash.extension;
|
|||||||
|
|
||||||
import dev.relism.flash.http.HttpMethod;
|
import dev.relism.flash.http.HttpMethod;
|
||||||
import dev.relism.flash.models.RequestHandler;
|
import dev.relism.flash.models.RequestHandler;
|
||||||
import dev.relism.flash.routing.Middleware;
|
import dev.relism.flash.routing.MiddlewareNode;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -26,8 +26,8 @@ record RouteDefinition(
|
|||||||
HttpMethod method,
|
HttpMethod method,
|
||||||
String path,
|
String path,
|
||||||
RequestHandler handler,
|
RequestHandler handler,
|
||||||
List<Middleware> scopeMiddlewares,
|
List<MiddlewareNode> scopeMiddlewares,
|
||||||
List<Middleware> explicitMiddlewares,
|
List<MiddlewareNode> explicitMiddlewares,
|
||||||
boolean classBasedHandler,
|
boolean classBasedHandler,
|
||||||
FlashContext ctx,
|
FlashContext ctx,
|
||||||
String namespace
|
String namespace
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ import java.nio.charset.StandardCharsets;
|
|||||||
*/
|
*/
|
||||||
public abstract class AbstractRouter {
|
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.
|
// Pre-encoded prod JSON error bodies — zero allocation on error paths.
|
||||||
private static final byte[] JSON_404 = "{\"error\":\"Not Found\",\"status\":404}"
|
private static final byte[] JSON_404 = "{\"error\":\"Not Found\",\"status\":404}"
|
||||||
.getBytes(StandardCharsets.UTF_8);
|
.getBytes(StandardCharsets.UTF_8);
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import dev.relism.flash.websocket.WebSocketHandler;
|
|||||||
|
|
||||||
public abstract class AbstractWsRouter {
|
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) {
|
public final AbstractWsRouter register(HttpMethod method, String path, WebSocketHandler handler) {
|
||||||
return addRoute(method, PathUtils.sanitize(path), 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"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -92,4 +92,7 @@ public class FastPathRouterImpl extends AbstractRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void compile() { ensureCompiled(); }
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -56,6 +56,9 @@ public final class FastPathWsRouterImpl extends AbstractWsRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void compile() { ensureCompiled(); }
|
||||||
|
|
||||||
private static final class Context {
|
private static final class Context {
|
||||||
private static final ThreadLocal<MatchResult<WebSocketHandler>> RESULT =
|
private static final ThreadLocal<MatchResult<WebSocketHandler>> RESULT =
|
||||||
ThreadLocal.withInitial(() -> new MatchResult<>(32, 128));
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user