What this installs: + *
{@code
+ * FlashApp.of(new HttpServer(config))
+ * .install(new JacksonExtension());
+ *
+ * // Custom mapper:
+ * ObjectMapper mapper = JsonMapper.builder()
+ * .addModule(new JavaTimeModule())
+ * .build();
+ * .install(new JacksonExtension(mapper));
+ * }
+ */
+public class JacksonExtension implements FlashExtension {
+
+ private final ObjectMapper mapper;
+
+ public JacksonExtension() {
+ this(JsonMapper.builder().build());
+ }
+
+ public JacksonExtension(ObjectMapper mapper) {
+ this.mapper = mapper;
+ }
+
+ @Override
+ public void install(FlashApp app, ExtensionContext ctx) {
+ ctx.provide(ObjectMapper.class, mapper);
+ JacksonHandler.mapper = mapper;
+
+ app.onException((ex, req, res) -> {
+ if (ex instanceof HttpException e) {
+ res.setStatusCode(e.status());
+ res.setContentType(ContentType.JSON);
+ return "{\"error\":\"" + escapeJson(e.getMessage()) + "\"}";
+ }
+ res.setStatusCode(500);
+ res.setContentType(ContentType.JSON);
+ return "{\"error\":\"Internal Server Error\"}";
+ });
+ }
+
+ private static String escapeJson(String s) {
+ if (s == null) return "";
+ return s.replace("\\", "\\\\")
+ .replace("\"", "\\\"")
+ .replace("\n", "\\n")
+ .replace("\r", "\\r")
+ .replace("\t", "\\t");
+ }
+}
diff --git a/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/JacksonHandler.java b/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/JacksonHandler.java
new file mode 100644
index 0000000..31c693d
--- /dev/null
+++ b/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/JacksonHandler.java
@@ -0,0 +1,77 @@
+package dev.relism.ext.jackson;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.relism.exceptions.HttpException;
+import dev.relism.http.ContentType;
+import dev.relism.models.Request;
+import dev.relism.models.RequestHandler;
+import dev.relism.models.Response;
+
+/**
+ * Base class for handlers that need JSON I/O via Jackson.
+ *
+ * The {@link ObjectMapper} is injected by {@link JacksonExtension#install} once at + * startup — all subclasses share the same instance. If no {@code JacksonExtension} is + * installed the field remains {@code null} and the first call to {@link #bodyAs} or + * {@link #json} will throw an {@link IllegalStateException}. + * + *
{@code
+ * @Route(method = HttpMethod.POST, path = "/api/blogs")
+ * public class CreateBlog extends JacksonHandler {
+ * public Object handle(Request req, Response res) throws Exception {
+ * CreateBlogRequest body = bodyAs(req, CreateBlogRequest.class);
+ * Blog created = service.create(body);
+ * res.setStatusCode(201);
+ * return json(res, created);
+ * }
+ * }
+ * }
+ */
+public abstract class JacksonHandler extends RequestHandler {
+
+ /**
+ * Shared mapper set by {@link JacksonExtension}. Package-visible so the extension
+ * can assign it; {@code volatile} ensures visibility across virtual threads.
+ */
+ static volatile ObjectMapper mapper;
+
+ /**
+ * Deserializes the request body bytes into {@code type}.
+ * Wraps Jackson parse errors as {@link HttpException} 400.
+ */
+ protected For role-based access use {@link RolesAllowed} instead (it implies authentication). + * + *
{@code
+ * @Route(method = HttpMethod.GET, path = "/api/profile")
+ * @Authenticated
+ * public class GetProfile extends JacksonHandler { ... }
+ * }
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+public @interface Authenticated {
+}
diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/ClaimsHolder.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/ClaimsHolder.java
new file mode 100644
index 0000000..d5debdb
--- /dev/null
+++ b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/ClaimsHolder.java
@@ -0,0 +1,71 @@
+package dev.relism.ext.oidc;
+
+import java.util.Map;
+
+/**
+ * Thread-local store for JWT claims, populated by the OIDC middleware before
+ * the handler runs and cleared in the {@code finally} block afterward.
+ *
+ * Safe with virtual threads: each request gets its own virtual thread, so + * {@link ThreadLocal} values are naturally isolated per request. + * + *
{@code
+ * // Inside any handler protected by @Authenticated or @RolesAllowed:
+ *
+ * // Preferred — typed wrapper:
+ * OidcUser user = ClaimsHolder.user();
+ * String email = user.email();
+ * List roles = user.roles("realm_access.roles");
+ *
+ * // Raw escape hatch:
+ * Map all = ClaimsHolder.get();
+ * }
+ */
+public final class ClaimsHolder {
+
+ private static final ThreadLocalThis is the preferred entry point for both lambda and class-based handlers.
+ */
+ public static OidcUser user() {
+ Map Sessions are lost on restart and not shared across instances. For
+ * production deployments with multiple nodes or restart-persistence requirements,
+ * supply a custom implementation via {@link OidcConfig.Builder#sessionStore}.
+ */
+public final class InMemoryOidcSessionStore implements OidcSessionStore {
+
+ private final ConcurrentHashMap Use only for tokens received directly from the provider over a trusted TLS
+ * connection (e.g. {@code id_token} from the token endpoint). Bearer tokens on
+ * incoming requests must go through {@link JwtValidator#validate(String)} instead.
+ */
+final class JwtUtils {
+
+ private JwtUtils() {}
+
+ /**
+ * Base64URL-decodes the JWT payload and returns the claims as a map.
+ * Signature, expiry, and issuer are NOT checked.
+ */
+ @SuppressWarnings("unchecked")
+ static Map Two validation modes:
+ * JWKS handling: the shared {@link JWKSource} uses caching + rate-limiting + automatic
+ * retry-on-key-miss (key rotation). Both processors share the same source — one JWKS
+ * fetch serves both token types.
+ */
+public class JwtValidator {
+
+ private final JWKSource Checks: signature (JWKS), {@code iss}, {@code aud} == clientId,
+ * {@code exp}, {@code iat}, {@code sub}, and {@code nonce} if provided.
+ *
+ * @param idToken Raw ID token string
+ * @param nonce Nonce sent in the authorization request; {@code null} to skip check
+ * @throws OidcValidationException on any validation failure
+ */
+ public Map Required fields: {@code issuer}, {@code clientId}, {@code clientSecret},
+ * {@code redirectUri}. Everything else has a sensible default.
+ *
+ * If {@code redirectUri} starts with {@code /} it is treated as server-relative:
+ * the absolute URL is resolved at request time using {@link #selfScheme()} and the
+ * incoming {@code Host} header. Use {@link Builder#https()} when behind TLS.
+ *
+ * Examples:
+ * On {@link #install}, the extension:
+ * Resolution order on each request:
+ * {@link #endSessionEndpoint()} may be {@code null} — not all providers expose it
+ * (e.g. some Authelia configurations omit it).
+ */
+public record OidcProviderMetadata(
+ String authorizationEndpoint,
+ String tokenEndpoint,
+ String userinfoEndpoint,
+ String jwksUri,
+ String endSessionEndpoint // nullable
+) {}
diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcSession.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcSession.java
new file mode 100644
index 0000000..fefdb17
--- /dev/null
+++ b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcSession.java
@@ -0,0 +1,47 @@
+package dev.relism.ext.oidc;
+
+import java.time.Instant;
+import java.util.Map;
+
+/**
+ * An authenticated user's OIDC session — persisted in {@link OidcSessionStore} and
+ * looked up via the {@code oidc_session} cookie on every request.
+ *
+ * Sessions are immutable; a refreshed access token produces a new instance
+ * that replaces the old one in the store (same {@link #id()}).
+ */
+public final class OidcSession {
+
+ private final String id;
+ private final String accessToken;
+ private final String idToken;
+ private final String refreshToken; // may be null
+ private final Instant accessTokenExpiresAt;
+ private final Map Entries expire after {@value #TTL_SECONDS} seconds. Cleanup runs on every
+ * access to prevent unbounded growth without needing a background thread.
+ */
+final class OidcStateStore {
+
+ static final int TTL_SECONDS = 600; // 10 minutes
+
+ record Entry(String originalUrl, String codeVerifier, String nonce, Instant expiresAt) {}
+
+ private final ConcurrentHashMap Obtainable from any protected context via {@link ClaimsHolder#user()}.
+ * Class-based handlers that extend the {@code SessionHandler} hierarchy already
+ * have a provisioned DB user in {@code currentUser}; {@code OidcUser} complements
+ * that by giving access to the raw OIDC claims when needed, and is the primary
+ * API for lambda routes.
+ *
+ * Example paths:
+ * Roles are read from the claim configured in {@link OidcConfig#rolesClaimPath()}
+ * (default: {@code "roles"}). Nested paths like {@code "realm_access.roles"} are
+ * supported with dot notation.
+ *
+ * Supports two client authentication methods (RFC 6749 §2.3):
+ * Given {@code basePath = "/openapi"} (the default), three routes are registered:
+ * Requires {@code flash-ext-jackson} to be installed first (shares its
+ * {@link ObjectMapper}). The YAML endpoint uses its own {@link YAMLMapper} instance.
+ *
+ * Operations are collected automatically from handlers annotated with
+ * {@link ApiOperation} as they are registered via {@link FlashApp#register}.
+ *
+ * Extensions that enforce authentication (e.g. {@code flash-ext-oidc}) implement
+ * this interface and register an instance into {@link OpenApiSecurityRegistry} via the
+ * {@link dev.relism.extension.ExtensionContext}. {@link OpenApiExtension} picks it up
+ * at spec-generation time — no coupling between the two extensions at install time.
+ *
+ * Multi-tenant: multiple contributors may coexist. For handlers secured by
+ * {@code @Authenticated}/{@code @RolesAllowed}, each matching contributor adds its
+ * own entry to the operation's {@code security} array (OpenAPI OR semantics).
+ */
+public interface OpenApiSecurityContributor {
+
+ /**
+ * Unique scheme name used as a key in {@code components.securitySchemes}
+ * and referenced from each operation's {@code security} array.
+ */
+ String schemeName();
+
+ /**
+ * The OpenAPI security scheme definition object placed under
+ * {@code components.securitySchemes. Example for OIDC:
+ * Created and provided to the {@link dev.relism.extension.ExtensionContext} by
+ * {@link OpenApiExtension} at install time. Other extensions (e.g. {@code flash-ext-oidc})
+ * retrieve it via {@code ctx.find(OpenApiSecurityRegistry.class)} and register their
+ * contributor — the OpenAPI extension then picks it up lazily at spec-generation time.
+ *
+ * Thread-safe: {@link CopyOnWriteArrayList} allows concurrent reads during spec
+ * generation without blocking registration.
+ */
+public final class OpenApiSecurityRegistry {
+
+ private final List Processors are called once per {@link FlashApp#register} call, before
+ * the handler is compiled into the router. Returning an empty list is always
+ * valid — processors may also use the call purely for side effects
+ * (e.g. collecting OpenAPI metadata).
+ *
+ * Register processors via {@link ExtensionContext#addAnnotationProcessor}.
+ */
+@FunctionalInterface
+public interface AnnotationProcessor {
+ List The key addition over raw {@link HttpServer} is the annotation-aware
+ * {@link #register} override: before delegating to Flash it runs all registered
+ * {@link AnnotationProcessor}s and prepends any injected middlewares
+ * (e.g. from {@code @RolesAllowed}) to the explicit ones.
+ *
+ * Route registration is lazy: calling {@code get()}, {@code post()}, or
+ * {@code register()} returns a {@link RouteHandle} that is not yet registered.
+ * The route is registered automatically before the next operation or at
+ * {@link #start()}. Call {@link RouteHandle#with} explicitly to add middleware:
+ *
+ * RouteHandle pending(RouteHandle handle) {
+ flushPending();
+ pending = handle;
+ return handle;
+ }
+
+ // ── Extension installation ────────────────────────────────────────────────
+
+ public FlashApp install(FlashExtension ext) {
+ flushPending();
+ ext.install(this, ctx);
+ return this;
+ }
+
+ /** Exposes the context so callers can retrieve services installed by extensions. */
+ public ExtensionContext ctx() {
+ return ctx;
+ }
+
+ // ── Handler registration (annotation-aware) ───────────────────────────────
+
+ /**
+ * Begins registration of a class-based handler. Before registering, all
+ * {@link AnnotationProcessor}s are called (e.g. to inject OIDC middleware from
+ * {@code @Authenticated} / {@code @RolesAllowed}). Their middlewares are prepended
+ * outermost to any extra middlewares supplied via {@link RouteHandle#with}.
+ *
+ * Calling {@link RouteHandle#with} is optional when no extra middleware is needed —
+ * the route is registered automatically before the next operation or at {@link #start()}.
+ *
+ * Middleware execution order: injected (from annotations) → explicit (.with()) → handler.
+ */
+ public RouteHandle The route is not registered until {@link #with} is called.
+ * This separates the handler declaration from the middleware declaration,
+ * keeping the registration methods clean.
+ *
+ * the parent type returned by {@link #with} for further chaining
+ * ({@link dev.relism.extension.FlashApp} or {@link AbstractRouter})
+ */
+public final class RouteHandle {
+
+ private final P parent;
+ private final Consumer Calling without arguments is equivalent to registering with no middleware.
+ * When using {@link dev.relism.extension.FlashApp}, calling {@code .with()} is
+ * optional for routes that need no middleware — the route is registered
+ * automatically before the next operation or at {@code start()}.
+ *
+ * @param middlewares zero or more middlewares; first element executes outermost
+ * @return the parent ({@link dev.relism.extension.FlashApp} or
+ * {@link AbstractRouter}) for further method chaining
+ */
+ public P with(Middleware... middlewares) {
+ if (!registered) {
+ registerFn.accept(middlewares);
+ registered = true;
+ }
+ return parent;
+ }
+
+ /**
+ * Registers the route with no middleware if it has not been registered yet.
+ * Called automatically by {@link dev.relism.extension.FlashApp} before each
+ * new route registration and at {@code start()}.
+ */
+ public void ensureRegistered() {
+ with();
+ }
+}
diff --git a/flash/src/test/java/dev/relism/HttpServerConcurrencyTest.java b/flash/src/test/java/dev/relism/HttpServerConcurrencyTest.java
index b49b5d0..5d502e6 100644
--- a/flash/src/test/java/dev/relism/HttpServerConcurrencyTest.java
+++ b/flash/src/test/java/dev/relism/HttpServerConcurrencyTest.java
@@ -38,11 +38,11 @@ class HttpServerConcurrencyTest {
.build();
server = new HttpServer(config);
- server.get("/ping", (req, res) -> "pong");
+ server.get("/ping", (req, res) -> "pong").with();
server.post("/echo", (req, res) -> {
byte[] body = req.body().bytes();
return new Response(200, body, ContentType.TEXT_PLAIN);
- });
+ }).with();
server.start().get(5, TimeUnit.SECONDS);
@@ -187,7 +187,7 @@ class HttpServerConcurrencyTest {
for (int i = 0; i < 10; i++) {
final int idx = i;
- freshServer.get("/route" + idx, (req, res) -> "handler" + idx);
+ freshServer.get("/route" + idx, (req, res) -> "handler" + idx).with();
}
freshServer.start().get(5, TimeUnit.SECONDS);
diff --git a/flash/src/test/java/dev/relism/HttpServerTest.java b/flash/src/test/java/dev/relism/HttpServerTest.java
index ccbd298..3f40e1b 100644
--- a/flash/src/test/java/dev/relism/HttpServerTest.java
+++ b/flash/src/test/java/dev/relism/HttpServerTest.java
@@ -37,31 +37,31 @@ class HttpServerTest {
server = new HttpServer(config);
// Setup some routes
- server.get("/api/ping", (req, res) -> "pong");
-
+ server.get("/api/ping", (req, res) -> "pong").with();
+
server.post("/api/echo", (req, res) -> {
byte[] body = req.body().bytes();
return res.status(201).body(body); // echo body & change status
- });
+ }).with();
server.get("/api/crash", (req, res) -> {
throw new RuntimeException("Simulated Crash");
- });
+ }).with();
byte[] streamData = "streaming response body".getBytes(StandardCharsets.UTF_8);
server.get("/api/stream", (req, res) ->
- res.stream(new ByteArrayInputStream(streamData), streamData.length));
+ res.stream(new ByteArrayInputStream(streamData), streamData.length)).with();
server.get("/api/chunked-out", (req, res) ->
- res.chunked(new ByteArrayInputStream(streamData)));
+ res.chunked(new ByteArrayInputStream(streamData))).with();
server.get("/api/custom-header", (req, res) ->
- res.body("ok").header("X-Flash", "works"));
+ res.body("ok").header("X-Flash", "works")).with();
server.post("/api/chunked-in", (req, res) -> {
byte[] body = req.body().bytes();
return res.body(body);
- });
+ }).with();
server.start().get(5, TimeUnit.SECONDS);
}
diff --git a/flash/src/test/java/dev/relism/routing/AbstractRouterTest.java b/flash/src/test/java/dev/relism/routing/AbstractRouterTest.java
index 0821722..3ef8e61 100644
--- a/flash/src/test/java/dev/relism/routing/AbstractRouterTest.java
+++ b/flash/src/test/java/dev/relism/routing/AbstractRouterTest.java
@@ -34,7 +34,7 @@ class AbstractRouterTest {
}
}
- @Route(method = "POST", path = "/profile")
+ @Route(method = dev.relism.http.HttpMethod.POST, path = "/profile")
static class ProfileHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
@@ -68,18 +68,18 @@ class AbstractRouterTest {
DummyRouter router = new DummyRouter();
SimpleHandler.FunctionalHandler func = (req, res) -> "OK";
- router.get("users/", func);
+ router.get("users/", func).with();
assertEquals(HttpMethod.GET, router.lastAddedMethod);
assertEquals("/users", router.lastAddedPath);
assertTrue(router.lastAddedHandler instanceof SimpleHandler);
- router.post("/items", func);
+ router.post("/items", func).with();
assertEquals(HttpMethod.POST, router.lastAddedMethod);
- router.put("update", func);
+ router.put("update", func).with();
assertEquals(HttpMethod.PUT, router.lastAddedMethod);
- router.delete("//delete//", func);
+ router.delete("//delete//", func).with();
assertEquals(HttpMethod.DELETE, router.lastAddedMethod);
assertEquals("/delete", router.lastAddedPath);
}
@@ -90,9 +90,9 @@ class AbstractRouterTest {
void register_annotatedHandler_addsRoute() {
DummyRouter router = new DummyRouter();
ProfileHandler handler = new ProfileHandler();
-
- router.register(handler);
-
+
+ router.register(handler).with();
+
assertEquals(HttpMethod.POST, router.lastAddedMethod);
assertEquals("/profile", router.lastAddedPath);
assertEquals(handler, router.lastAddedHandler);
@@ -101,7 +101,7 @@ class AbstractRouterTest {
@Test
void register_unannotatedHandler_doesNothing() {
DummyRouter router = new DummyRouter();
- router.register(new UnannotatedHandler());
+ router.register(new UnannotatedHandler()).with();
assertNull(router.lastAddedMethod); // Nothing added
}
diff --git a/flash/src/test/java/dev/relism/routing/GlobalRouterTest.java b/flash/src/test/java/dev/relism/routing/GlobalRouterTest.java
index 3309270..976fe3b 100644
--- a/flash/src/test/java/dev/relism/routing/GlobalRouterTest.java
+++ b/flash/src/test/java/dev/relism/routing/GlobalRouterTest.java
@@ -75,7 +75,7 @@ class GlobalRouterTest {
GlobalRouter global = new GlobalRouter();
RequestHandler internalHandler = new SimpleHandler((req, res) -> "internal");
- global.get("/hello", (req, res) -> "internal");
+ global.get("/hello", (req, res) -> "internal").with();
// We know it routes to internal. Let's send a request.
RequestHandler resolved = global.route(mockRequest("/hello"));
assertNotNull(resolved);
diff --git a/flash/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImplTest.java b/flash/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImplTest.java
index 5f0e226..56a7d5e 100644
--- a/flash/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImplTest.java
+++ b/flash/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImplTest.java
@@ -34,8 +34,8 @@ class FastPathRouterImplTest {
void route_lazyCompilationAndMatch() throws Exception {
FastPathRouterImpl router = new FastPathRouterImpl();
- router.get("/a", (req, res) -> "A");
- router.post("/b", (req, res) -> "B");
+ router.get("/a", (req, res) -> "A").with();
+ router.post("/b", (req, res) -> "B").with();
RequestHandler res1 = router.route(mockRequest(HttpMethod.GET, "/a"));
assertNotNull(res1);
@@ -49,7 +49,7 @@ class FastPathRouterImplTest {
@Test
void route_noMatch_returnsNull() {
FastPathRouterImpl router = new FastPathRouterImpl();
- router.get("/a", (req, res) -> "A");
+ router.get("/a", (req, res) -> "A").with();
assertNull(router.route(mockRequest(HttpMethod.GET, "/b")));
// Wrong method
@@ -60,7 +60,7 @@ class FastPathRouterImplTest {
void route_extractsPathParams() throws Exception {
FastPathRouterImpl router = new FastPathRouterImpl();
- router.get("/users/{id}/items/{itemId}", (req, res) -> "Extract");
+ router.get("/users/{id}/items/{itemId}", (req, res) -> "Extract").with();
Request request = mockRequest(HttpMethod.GET, "/users/123/items/456");
RequestHandler handler = router.route(request);
diff --git a/pom.xml b/pom.xml
index 61f2318..ab5a12d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -11,6 +11,8 @@
+ *
+ */
+public enum ClientAuthMethod {
+ /** {@code client_secret_post} — credentials in the request body. */
+ POST,
+ /** {@code client_secret_basic} — credentials in the {@code Authorization} header. */
+ BASIC
+}
diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/DiscoveryClient.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/DiscoveryClient.java
new file mode 100644
index 0000000..8349f7f
--- /dev/null
+++ b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/DiscoveryClient.java
@@ -0,0 +1,50 @@
+package dev.relism.ext.oidc;
+
+import net.minidev.json.JSONValue;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.Map;
+
+/**
+ * Fetches and parses the OIDC provider discovery document at
+ * {@code {issuer}/.well-known/openid-configuration}.
+ */
+final class DiscoveryClient {
+
+ private DiscoveryClient() {}
+
+ static OidcProviderMetadata fetch(String issuer, HttpClient http) throws Exception {
+ String url = issuer.endsWith("/")
+ ? issuer + ".well-known/openid-configuration"
+ : issuer + "/.well-known/openid-configuration";
+
+ HttpResponse
+ *
+ *
+ * {@code
+ * // Keycloak
+ * OidcConfig.builder(
+ * "https://keycloak.example.com/realms/myrealm",
+ * "my-app", "secret", "/auth/callback")
+ * .rolesClaimPath("realm_access.roles") // Keycloak default
+ * .build();
+ *
+ * // Authelia
+ * OidcConfig.builder(
+ * "https://auth.example.com",
+ * "my-app", "secret", "/auth/callback")
+ * .rolesClaimPath("groups")
+ * .build();
+ *
+ * // Two tenants on one server
+ * OidcConfig tenantA = OidcConfig.builder("https://idp/realms/a", ..., "/tenantA/auth/callback")
+ * .routePrefix("/tenantA/auth").build();
+ * OidcConfig tenantB = OidcConfig.builder("https://idp/realms/b", ..., "/tenantB/auth/callback")
+ * .routePrefix("/tenantB/auth").build();
+ * app.install(new OidcExtension(tenantA))
+ * .install(new OidcExtension(tenantB));
+ * }
+ */
+public final class OidcConfig {
+
+ private final String issuer;
+ private final String clientId;
+ private final String clientSecret;
+ private final String redirectUri;
+ private final String scopes;
+ private final String routePrefix;
+ private final String selfScheme;
+ private final String rolesClaimPath;
+ private final String algorithm;
+ private final String postLogoutRedirectUri;
+ private final OidcSessionStore sessionStore;
+ private final boolean insecureTls;
+ private final ClientAuthMethod clientAuthMethod;
+ private final String schemeName;
+
+ private OidcConfig(Builder b) {
+ this.issuer = require(b.issuer, "issuer");
+ this.clientId = require(b.clientId, "clientId");
+ this.clientSecret = require(b.clientSecret, "clientSecret");
+ this.redirectUri = require(b.redirectUri, "redirectUri");
+ this.scopes = b.scopes;
+ this.routePrefix = b.routePrefix;
+ this.selfScheme = b.selfScheme;
+ this.rolesClaimPath = b.rolesClaimPath;
+ this.algorithm = b.algorithm;
+ this.postLogoutRedirectUri = b.postLogoutRedirectUri;
+ this.sessionStore = b.sessionStore != null ? b.sessionStore
+ : new InMemoryOidcSessionStore();
+ this.insecureTls = b.insecureTls;
+ this.clientAuthMethod = b.clientAuthMethod;
+ this.schemeName = b.schemeName != null ? b.schemeName : deriveScheme(this.issuer);
+ }
+
+ // -- Getters --------------------------------------------------------------
+
+ public String issuer() { return issuer; }
+ public String clientId() { return clientId; }
+ public String clientSecret() { return clientSecret; }
+ public String redirectUri() { return redirectUri; }
+ public String scopes() { return scopes; }
+ public String routePrefix() { return routePrefix; }
+ public String selfScheme() { return selfScheme; }
+ public String rolesClaimPath() { return rolesClaimPath; }
+ public String algorithm() { return algorithm; }
+ public String postLogoutRedirectUri() { return postLogoutRedirectUri; }
+ public OidcSessionStore sessionStore() { return sessionStore; }
+ /** If {@code true}, TLS certificate validation is skipped. Never use in production. */
+ public boolean insecureTls() { return insecureTls; }
+ public ClientAuthMethod clientAuthMethod() { return clientAuthMethod; }
+ /** OpenAPI security scheme name (derived from issuer if not set explicitly). */
+ public String schemeName() { return schemeName; }
+
+ // -- Factory --------------------------------------------------------------
+
+ /**
+ * Reads configuration from environment variables:
+ *
+ * OIDC_ISSUER required
+ * OIDC_CLIENT_ID required
+ * OIDC_CLIENT_SECRET required
+ * OIDC_REDIRECT_URI required (e.g. /auth/callback)
+ * OIDC_SCOPES default: openid profile email
+ * OIDC_ROUTE_PREFIX default: /auth
+ * OIDC_SELF_SCHEME default: http
+ * OIDC_ROLES_CLAIM default: realm_access.roles
+ * OIDC_ALGORITHM default: RS256
+ * OIDC_POST_LOGOUT_REDIRECT default: /
+ *
+ */
+ public static OidcConfig fromEnv() {
+ return builder(env("OIDC_ISSUER"), env("OIDC_CLIENT_ID"),
+ env("OIDC_CLIENT_SECRET"), env("OIDC_REDIRECT_URI"))
+ .scopes (envOr("OIDC_SCOPES", "openid profile email"))
+ .routePrefix (envOr("OIDC_ROUTE_PREFIX", "/auth"))
+ .selfScheme (envOr("OIDC_SELF_SCHEME", "http"))
+ .rolesClaimPath (envOr("OIDC_ROLES_CLAIM", "realm_access.roles"))
+ .algorithm (envOr("OIDC_ALGORITHM", "RS256"))
+ .postLogoutRedirectUri(envOr("OIDC_POST_LOGOUT_REDIRECT", "/"))
+ .clientAuthMethod(ClientAuthMethod.valueOf(
+ envOr("OIDC_CLIENT_AUTH_METHOD", "POST").toUpperCase()))
+ .build();
+ }
+
+ public static Builder builder(String issuer, String clientId,
+ String clientSecret, String redirectUri) {
+ return new Builder(issuer, clientId, clientSecret, redirectUri);
+ }
+
+ /**
+ * Convenience factory for Keycloak: constructs the issuer as
+ * {@code {serverUrl}/realms/{realm}} automatically.
+ *
+ * {@code
+ * OidcConfig.keycloak(
+ * "https://keycloak.example.com", "flashboard",
+ * "my-app", "secret", "/auth/callback")
+ * .https()
+ * .build();
+ * }
+ */
+ public static Builder keycloak(String serverUrl, String realm,
+ String clientId, String clientSecret,
+ String redirectUri) {
+ String base = serverUrl.endsWith("/") ? serverUrl.substring(0, serverUrl.length() - 1) : serverUrl;
+ String issuer = base + "/realms/" + realm;
+ return new Builder(issuer, clientId, clientSecret, redirectUri)
+ .rolesClaimPath("realm_access.roles"); // Keycloak default
+ }
+
+ // -- Helpers --------------------------------------------------------------
+
+ private static String require(String v, String name) {
+ if (v == null || v.isBlank())
+ throw new IllegalArgumentException("OidcConfig: " + name + " is required");
+ return v;
+ }
+
+ private static String env(String key) {
+ String v = System.getenv(key);
+ if (v == null || v.isBlank())
+ throw new IllegalArgumentException("Missing required env var: " + key);
+ return v;
+ }
+
+ private static String envOr(String key, String def) {
+ String v = System.getenv(key);
+ return (v != null && !v.isBlank()) ? v : def;
+ }
+
+ // -- Builder --------------------------------------------------------------
+
+ public static final class Builder {
+
+ private final String issuer;
+ private final String clientId;
+ private final String clientSecret;
+ private final String redirectUri;
+
+ private String scopes = "openid profile email";
+ private String routePrefix = "/auth";
+ private String selfScheme = "http";
+ private String rolesClaimPath = "realm_access.roles";
+ private String algorithm = "RS256";
+ private String postLogoutRedirectUri = "/";
+ private OidcSessionStore sessionStore;
+ private boolean insecureTls = false;
+ private ClientAuthMethod clientAuthMethod = ClientAuthMethod.POST;
+ private String schemeName = null;
+
+ private Builder(String issuer, String clientId, String clientSecret, String redirectUri) {
+ this.issuer = issuer;
+ this.clientId = clientId;
+ this.clientSecret = clientSecret;
+ this.redirectUri = redirectUri;
+ }
+
+ /** Override requested scopes (default: {@code openid profile email}). */
+ public Builder scopes(String scopes) { this.scopes = scopes; return this; }
+ /** Route prefix for login/callback/logout (default: {@code /auth}). */
+ public Builder routePrefix(String prefix) { this.routePrefix = prefix; return this; }
+ /** Scheme used when resolving self-relative redirect URIs (default: {@code http}). */
+ public Builder selfScheme(String scheme) { this.selfScheme = scheme; return this; }
+ /** Shorthand for {@code selfScheme("https")}. */
+ public Builder https() { return selfScheme("https"); }
+ /** Dot-separated path to the roles array in JWT claims (default: {@code realm_access.roles}). */
+ public Builder rolesClaimPath(String path) { this.rolesClaimPath = path; return this; }
+ /** JWS algorithm (default: {@code RS256}). */
+ public Builder algorithm(String algorithm) { this.algorithm = algorithm; return this; }
+ /** Where to redirect after logout (default: {@code /}). */
+ public Builder postLogoutRedirectUri(String uri) { this.postLogoutRedirectUri = uri; return this; }
+ /** Custom session store (default: {@link InMemoryOidcSessionStore}). */
+ public Builder sessionStore(OidcSessionStore store) { this.sessionStore = store; return this; }
+ /**
+ * Disables TLS certificate verification for all HTTP calls made by this extension.
+ * Only use in development with self-signed certificates — never in production.
+ */
+ public Builder insecureTls() { this.insecureTls = true; return this; }
+ /** Token endpoint client authentication method (default: {@link ClientAuthMethod#POST}). */
+ public Builder clientAuthMethod(ClientAuthMethod method) { this.clientAuthMethod = method; return this; }
+ /** Override the OpenAPI security scheme name (default: derived from the issuer URI). */
+ public Builder schemeName(String name) { this.schemeName = name; return this; }
+
+ public OidcConfig build() { return new OidcConfig(this); }
+ }
+
+ /**
+ * Derives a short, human-readable scheme name from the issuer URI.
+ * Takes the last non-empty path segment; falls back to the host.
+ *
+ *
+ *
+ */
+ private static String deriveScheme(String issuer) {
+ try {
+ java.net.URI uri = new java.net.URI(issuer);
+ String path = uri.getPath();
+ if (path != null && !path.isEmpty()) {
+ String[] parts = path.split("/");
+ for (int i = parts.length - 1; i >= 0; i--) {
+ if (!parts[i].isEmpty()) return parts[i];
+ }
+ }
+ return uri.getHost();
+ } catch (Exception e) {
+ return "oidc";
+ }
+ }
+}
diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcExtension.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcExtension.java
new file mode 100644
index 0000000..743047c
--- /dev/null
+++ b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcExtension.java
@@ -0,0 +1,340 @@
+package dev.relism.ext.oidc;
+
+import dev.relism.extension.ExtensionContext;
+import dev.relism.extension.FlashApp;
+import dev.relism.extension.FlashExtension;
+
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.X509TrustManager;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import java.security.cert.X509Certificate;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * Full OIDC Authorization Code + PKCE flow for Flash.
+ *
+ *
+ *
+ *
+ *
+ *
+ * {@code
+ * // Keycloak
+ * app.install(new OidcExtension(
+ * OidcConfig.builder(
+ * "https://keycloak.example.com/realms/myrealm",
+ * "my-app", "secret", "/auth/callback")
+ * .rolesClaimPath("realm_access.roles")
+ * .build()));
+ *
+ * // Two providers / tenants on one server
+ * app.install(new OidcExtension(tenantAConfig))
+ * .install(new OidcExtension(tenantBConfig));
+ * }
+ */
+public class OidcExtension implements FlashExtension {
+
+ private final OidcConfig config;
+
+ public OidcExtension(OidcConfig config) {
+ this.config = config;
+ }
+
+ @Override
+ public void install(FlashApp app, ExtensionContext ctx) {
+
+ // 1. Build the shared HttpClient (optionally with TLS verification disabled)
+ HttpClient http = buildHttpClient(config);
+
+ // 2. Discover provider endpoints (blocking; fail fast at startup)
+ OidcProviderMetadata meta;
+ try {
+ meta = DiscoveryClient.fetch(config.issuer(), http);
+ } catch (Exception e) {
+ throw new IllegalStateException(
+ "OIDC discovery failed for issuer: " + config.issuer(), e);
+ }
+
+ // 3. JWKS-backed access-token validator
+ JwtValidator validator = new JwtValidator(
+ meta.jwksUri(), config.issuer(), config.clientId(),
+ config.algorithm(), http);
+
+ // 4. PKCE state store (per extension instance — safe for multi-tenant)
+ OidcStateStore stateStore = new OidcStateStore();
+
+ // 5. Shared token client (injected into middleware for refresh)
+ TokenClient tokenClient = new TokenClient(http, config);
+
+ // 6. Middleware (also exposed in context for manual lambda-route protection)
+ OidcMiddleware oidcMw = new OidcMiddleware(validator, config, meta, tokenClient);
+ ctx.provide(OidcMiddleware.class, oidcMw);
+ ctx.provide(JwtValidator.class, validator);
+
+ String prefix = config.routePrefix();
+
+ // ── GET {prefix}/login ────────────────────────────────────────────────
+ // Builds the provider authorization URL with PKCE + state and redirects.
+ // Optional query param: ?redirect={relative-url} (default: /)
+ app.get(prefix + "/login", (req, res) -> {
+ String verifier = PkceUtils.generateVerifier();
+ String challenge = PkceUtils.computeChallenge(verifier);
+ String state = UUID.randomUUID().toString(); // CSRF protection
+ String nonce = UUID.randomUUID().toString(); // ID token replay protection
+
+ String redirect = req.query("redirect");
+ // Only allow relative paths — prevents open-redirect attacks
+ if (redirect == null || !redirect.startsWith("/")) redirect = "/";
+
+ stateStore.put(state, redirect, verifier, nonce);
+
+ String authUrl = meta.authorizationEndpoint()
+ + "?response_type=code"
+ + "&client_id=" + enc(config.clientId())
+ + "&redirect_uri=" + enc(absoluteRedirectUri(req))
+ + "&scope=" + enc(config.scopes())
+ + "&state=" + state
+ + "&nonce=" + enc(nonce)
+ + "&code_challenge=" + challenge
+ + "&code_challenge_method=S256";
+
+ res.status(302).header("Location", authUrl);
+ return null;
+ }).with();
+
+ // ── GET {prefix}/callback ─────────────────────────────────────────────
+ // Validates state, exchanges code for tokens, creates session, redirects.
+ app.get(prefix + "/callback", (req, res) -> {
+ String error = req.query("error");
+ if (error != null) {
+ res.status(400);
+ return "Authentication error: " + error
+ + (req.query("error_description") != null
+ ? " — " + req.query("error_description") : "");
+ }
+
+ String code = req.query("code");
+ String state = req.query("state");
+
+ OidcStateStore.Entry entry = stateStore.consumeAndRemove(state).orElse(null);
+ if (entry == null) {
+ res.status(400);
+ return "Invalid or expired state parameter";
+ }
+
+ OidcTokenResponse tokens = tokenClient.exchangeCode(
+ meta.tokenEndpoint(), code, absoluteRedirectUri(req), entry.codeVerifier());
+
+ // Validate ID token: signature + iss + aud + exp + iat + sub + nonce (OIDC Core §3.1.3.7)
+ if (tokens.idToken() != null) {
+ try {
+ validator.validateIdToken(tokens.idToken(), entry.nonce());
+ } catch (OidcValidationException e) {
+ res.status(400);
+ return "ID token validation failed: " + e.getMessage();
+ }
+ }
+
+ Map
+ *
+ *
+ * {@code
+ * // Manual use on a lambda route:
+ * OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class);
+ * app.get("/api/me", (req, res) -> ClaimsHolder.claim("sub"), oidc.protect());
+ * app.delete("/admin/users/{id}", handler, oidc.requireRole("admin"));
+ * }
+ */
+public class OidcMiddleware {
+
+ private final JwtValidator validator;
+ private final OidcConfig config;
+ private final OidcProviderMetadata meta;
+ private final TokenClient tokenClient;
+
+ OidcMiddleware(JwtValidator validator, OidcConfig config,
+ OidcProviderMetadata meta, TokenClient tokenClient) {
+ this.validator = validator;
+ this.config = config;
+ this.meta = meta;
+ this.tokenClient = tokenClient;
+ }
+
+ // -- Public API -----------------------------------------------------------
+
+ /**
+ * Validates the bearer token or session cookie. Browser clients are redirected
+ * to the login page on failure; API clients receive 401.
+ */
+ public Middleware protect() {
+ return next -> (req, res) -> {
+ Map{@code
+ * // Lambda route (OidcMiddleware injected):
+ * app.get("/api/whoami", (req, res) -> {
+ * OidcUser u = ClaimsHolder.user();
+ * return Map.of("sub", u.sub(), "email", u.email(), "roles", u.roles());
+ * }, oidcMw.protect());
+ *
+ * // Class-based handler (currentUser is the DB entity; oidcUser() for raw claims):
+ * protected Object handleAuthenticated(Request req, Response res) throws Exception {
+ * OidcUser u = oidcUser(); // same as ClaimsHolder.user()
+ * return json(res, currentUser); // DB entity — provisioned from OIDC sub
+ * }
+ * }
+ */
+public final class OidcUser {
+
+ private final Map
+ *
+ *
+ * @return list of role strings, or an empty list if the path doesn't exist
+ */
+ @SuppressWarnings("unchecked")
+ public List{@code
+ * @Route(method = HttpMethod.DELETE, path = "/api/admin/blogs/{id}")
+ * @RolesAllowed("admin")
+ * public class DeleteBlog extends JacksonHandler { ... }
+ *
+ * // Multiple accepted roles (OR semantics — any one role is sufficient):
+ * @RolesAllowed({"admin", "editor"})
+ * public class UpdateBlog extends JacksonHandler { ... }
+ * }
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+public @interface RolesAllowed {
+ /** One or more role names. Access is granted if the caller has any of them. */
+ String[] value();
+}
diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/TokenClient.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/TokenClient.java
new file mode 100644
index 0000000..88f949c
--- /dev/null
+++ b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/TokenClient.java
@@ -0,0 +1,112 @@
+package dev.relism.ext.oidc;
+
+import net.minidev.json.JSONValue;
+
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * HTTP client for OAuth2 token endpoint operations (pure HTTP, no SDK).
+ *
+ *
+ *
+ */
+final class TokenClient {
+
+ private final HttpClient http;
+ private final String clientId;
+ private final String clientSecret;
+ private final ClientAuthMethod authMethod;
+
+ TokenClient(HttpClient http, OidcConfig config) {
+ this.http = http;
+ this.clientId = config.clientId();
+ this.clientSecret = config.clientSecret();
+ this.authMethod = config.clientAuthMethod();
+ }
+
+ /** Authorization Code + PKCE exchange. */
+ OidcTokenResponse exchangeCode(String tokenEndpoint,
+ String code, String redirectUri,
+ String codeVerifier) throws Exception {
+ Map{@code
+ * @Route(method = HttpMethod.GET, path = "/api/blogs")
+ * @ApiOperation(summary = "List all blogs", tags = {"blogs"})
+ * public class ListBlogs extends JacksonHandler { ... }
+ * }
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+public @interface ApiOperation {
+ String summary() default "";
+ String description() default "";
+ String[] tags() default {};
+ boolean deprecated() default false;
+ String operationId() default "";
+}
diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiParam.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiParam.java
new file mode 100644
index 0000000..e6618f9
--- /dev/null
+++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiParam.java
@@ -0,0 +1,27 @@
+package dev.relism.ext.openapi;
+
+import java.lang.annotation.*;
+
+/**
+ * Declares a single parameter (query, path, header, or cookie) for an operation.
+ * Repeatable — place multiple annotations on the same handler class.
+ *
+ * {@code
+ * @ApiParam(name = "limit", in = "query", type = "integer", description = "Max results (default 20)")
+ * @ApiParam(name = "offset", in = "query", type = "integer", description = "Pagination offset")
+ * public class ListBlogs extends JacksonHandler { ... }
+ * }
+ */
+@Repeatable(ApiParams.class)
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+public @interface ApiParam {
+ String name();
+ /** "query", "path", "header", or "cookie". */
+ String in() default "query";
+ /** OpenAPI primitive type: "string", "integer", "number", "boolean". */
+ String type() default "string";
+ String description() default "";
+ boolean required() default false;
+ String example() default "";
+}
diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiParams.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiParams.java
new file mode 100644
index 0000000..8aac0e3
--- /dev/null
+++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiParams.java
@@ -0,0 +1,13 @@
+package dev.relism.ext.openapi;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/** Container for repeated {@link ApiParam} annotations. */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+public @interface ApiParams {
+ ApiParam[] value();
+}
diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiResponse.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiResponse.java
new file mode 100644
index 0000000..282e1b6
--- /dev/null
+++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiResponse.java
@@ -0,0 +1,23 @@
+package dev.relism.ext.openapi;
+
+import java.lang.annotation.*;
+
+/**
+ * Declares a single response for an operation. Repeatable — use multiple
+ * {@code @ApiResponse} annotations on the same handler to document several status codes.
+ *
+ * {@code
+ * @ApiResponse(status = 200, description = "Blog created", schema = Blog.class)
+ * @ApiResponse(status = 400, description = "Invalid input")
+ * @ApiResponse(status = 409, description = "Slug already exists")
+ * public class CreateBlog extends JacksonHandler { ... }
+ * }
+ */
+@Repeatable(ApiResponses.class)
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+public @interface ApiResponse {
+ int status();
+ String description() default "";
+ Class> schema() default Void.class;
+}
diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiResponses.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiResponses.java
new file mode 100644
index 0000000..6182e1b
--- /dev/null
+++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiResponses.java
@@ -0,0 +1,13 @@
+package dev.relism.ext.openapi;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/** Container for repeated {@link ApiResponse} annotations. */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+public @interface ApiResponses {
+ ApiResponse[] value();
+}
diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiBuilder.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiBuilder.java
new file mode 100644
index 0000000..499602e
--- /dev/null
+++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiBuilder.java
@@ -0,0 +1,204 @@
+package dev.relism.ext.openapi;
+
+import dev.relism.routing.Route;
+
+import java.util.*;
+
+/**
+ * Accumulates OpenAPI 3.0 operations and builds the spec document as a plain
+ * {@code Map} for Jackson to serialize. Operations are added at registration time
+ * via {@link OpenApiExtension}'s {@link dev.relism.AnnotationProcessor}.
+ */
+public class OpenApiBuilder {
+
+ private String title = "API";
+ private String version = "1.0.0";
+ private String description = "";
+
+ private final Map
+ *
+ *
+ * {@code
+ * FlashApp.of(new HttpServer(config))
+ * .install(new JacksonExtension())
+ * .install(new OpenApiExtension("/openapi", "My API", "2.0.0"))
+ * .register(new BlogHandlers.Index())
+ * .start();
+ * }
+ */
+public class OpenApiExtension implements FlashExtension {
+
+ private static final String YAML_CONTENT_TYPE = "application/yaml";
+
+ private final String basePath;
+ private final String title;
+ private final String version;
+ private final String description;
+
+ public OpenApiExtension() {
+ this("/openapi", "API", "1.0.0", "");
+ }
+
+ public OpenApiExtension(String basePath) {
+ this(basePath, "API", "1.0.0", "");
+ }
+
+ public OpenApiExtension(String basePath, String title, String version) {
+ this(basePath, title, version, "");
+ }
+
+ public OpenApiExtension(String basePath, String title, String version, String description) {
+ this.basePath = basePath.endsWith("/") ? basePath.substring(0, basePath.length() - 1) : basePath;
+ this.title = title;
+ this.version = version;
+ this.description = description;
+ }
+
+ @Override
+ public void install(FlashApp app, ExtensionContext ctx) {
+ ObjectMapper jsonMapper = ctx.require(ObjectMapper.class);
+ YAMLMapper yamlMapper = new YAMLMapper();
+
+ OpenApiBuilder builder = new OpenApiBuilder()
+ .title(title)
+ .version(version)
+ .description(description);
+
+ OpenApiSecurityRegistry secRegistry = new OpenApiSecurityRegistry();
+ ctx.provide(OpenApiSecurityRegistry.class, secRegistry);
+ builder.setSecurityRegistry(secRegistry);
+
+ ctx.provide(OpenApiBuilder.class, builder);
+
+ // Collect operation metadata at handler-registration time (no middleware injected)
+ ctx.addAnnotationProcessor(handlerClass -> {
+ ApiOperation op = handlerClass.getAnnotation(ApiOperation.class);
+ Route route = handlerClass.getAnnotation(Route.class);
+ if (op != null && route != null) {
+ builder.addOperation(route, op, handlerClass);
+ }
+ return java.util.List.of();
+ });
+
+ String jsonPath = basePath + ".json";
+ String yamlPath = basePath + ".yaml";
+ String swaggerPath = basePath + "/swagger";
+
+ // JSON spec
+ app.get(jsonPath, (req, res) -> {
+ res.setContentType(ContentType.JSON);
+ return jsonMapper.writeValueAsString(builder.build());
+ }).with();
+
+ // YAML spec
+ app.get(yamlPath, (req, res) -> {
+ res.type(YAML_CONTENT_TYPE);
+ return yamlMapper.writeValueAsString(builder.build());
+ }).with();
+
+ // Swagger UI — loads from CDN, points at the JSON spec
+ String swaggerHtml = buildSwaggerHtml(jsonPath);
+ app.get(swaggerPath, (req, res) -> {
+ res.setContentType(ContentType.TEXT_HTML);
+ return swaggerHtml;
+ }).with();
+ }
+
+ // ── Swagger UI HTML ───────────────────────────────────────────────────────
+
+ private static String buildSwaggerHtml(String specJsonPath) {
+ return "\n" +
+ "\n" +
+ "\n" +
+ " \n" +
+ " \n" +
+ " {@code
+ * Map.of("type", "openIdConnect",
+ * "openIdConnectUrl", "https://idp.example.com/.well-known/openid-configuration")
+ * }
+ */
+ Map
+ *
+ */
+ List{@code
+ * throw HttpException.notFound("Blog");
+ * throw new HttpException(422, "Validation failed: title is required");
+ * }
+ */
+public class HttpException extends RuntimeException {
+
+ private final int status;
+
+ public HttpException(int status, String message) {
+ super(message);
+ this.status = status;
+ }
+
+ public int status() { return status; }
+
+ // ── Factory methods ───────────────────────────────────────────────────────
+
+ public static HttpException badRequest(String message) {
+ return new HttpException(400, message);
+ }
+
+ public static HttpException unauthorized() {
+ return new HttpException(401, "Unauthorized");
+ }
+
+ public static HttpException forbidden() {
+ return new HttpException(403, "Forbidden");
+ }
+
+ public static HttpException notFound(String what) {
+ return new HttpException(404, what + " not found");
+ }
+
+ public static HttpException conflict(String message) {
+ return new HttpException(409, message);
+ }
+
+ public static HttpException unprocessable(String message) {
+ return new HttpException(422, message);
+ }
+
+ public static HttpException internal(String message) {
+ return new HttpException(500, message);
+ }
+}
diff --git a/flash/src/main/java/dev/relism/extension/AnnotationProcessor.java b/flash/src/main/java/dev/relism/extension/AnnotationProcessor.java
new file mode 100644
index 0000000..2744c71
--- /dev/null
+++ b/flash/src/main/java/dev/relism/extension/AnnotationProcessor.java
@@ -0,0 +1,22 @@
+package dev.relism.extension;
+
+import dev.relism.models.RequestHandler;
+import dev.relism.routing.Middleware;
+
+import java.util.List;
+
+/**
+ * Inspects a handler class at registration time and returns zero or more
+ * {@link Middleware middlewares} to inject automatically.
+ *
+ *
+ *
+ */
+public class ExtensionContext {
+
+ private final Map{@code
+ * FlashApp.of(new HttpServer(config))
+ * .install(new JacksonExtension())
+ * .install(new OidcExtension(OidcConfig.fromEnv()))
+ * .install(new OpenApiExtension("/openapi"))
+ * .start();
+ *
+ * // No middleware — .with() is optional
+ * app.get("/ping", (req, res) -> "pong");
+ * app.get("/hello", (req, res) -> "Hello");
+ *
+ * // With middleware
+ * app.get("/me", (req, res) -> ClaimsHolder.user().email()).with(oidc.protect());
+ * app.get("/admin", (req, res) -> "secret").with(oidc.requireRole("admin"));
+ *
+ * // Class-based — @Authenticated / @RolesAllowed auto-injected, .with() optional
+ * app.register(new HomePage());
+ * app.register(new MePage()); // @Authenticated handled by annotation processor
+ * app.register(new AdminPage()); // @RolesAllowed("admin") handled automatically
+ *
+ * app.start();
+ * }
+ */
+public class FlashApp {
+
+ private final HttpServer server;
+ private final ExtensionContext ctx = new ExtensionContext();
+
+ /** The last returned RouteHandle that has not yet been registered. */
+ private RouteHandle> pending;
+
+ private FlashApp(HttpServer server) {
+ this.server = server;
+ }
+
+ public static FlashApp of(HttpServer server) {
+ return new FlashApp(server);
+ }
+
+ // ── Pending flush ─────────────────────────────────────────────────────────
+
+ /**
+ * Registers any pending route (returned by the previous {@code get/post/register}
+ * call) with no middleware, if it has not already been registered via
+ * {@link RouteHandle#with}.
+ */
+ private void flushPending() {
+ if (pending != null) {
+ pending.ensureRegistered();
+ pending = null;
+ }
+ }
+
+ private {@code
+ * app.get("/ping", (req, res) -> "pong"); // no middleware
+ * app.get("/me", (req, res) -> ClaimsHolder.user().email())
+ * .with(oidc.protect()); // with middleware
+ * }
+ */
+ public RouteHandle{@code
+ * public class OpenApiExtension implements FlashExtension {
+ * public void install(FlashApp app, ExtensionContext ctx) {
+ * ObjectMapper mapper = ctx.require(ObjectMapper.class);
+ * app.get("/openapi.json", (req, res) -> mapper.writeValueAsString(spec));
+ * }
+ * }
+ *
+ * FlashApp.of(new HttpServer(config))
+ * .install(new JacksonExtension())
+ * .install(new OpenApiExtension("/openapi.json"))
+ * .install(new OidcExtension(OidcConfig.fromEnv()));
+ * }
+ */
+@FunctionalInterface
+public interface FlashExtension {
+ void install(FlashApp app, ExtensionContext ctx);
+}
diff --git a/flash/src/main/java/dev/relism/models/RequestHelper.java b/flash/src/main/java/dev/relism/models/RequestHelper.java
new file mode 100644
index 0000000..0e71541
--- /dev/null
+++ b/flash/src/main/java/dev/relism/models/RequestHelper.java
@@ -0,0 +1,106 @@
+package dev.relism.models;
+
+import dev.relism.exceptions.HttpException;
+
+/**
+ * Typed accessors for request parameters. All methods throw
+ * {@link HttpException} (400) on missing or malformed input, so handlers
+ * do not need to write per-field validation boilerplate.
+ *
+ * {@code
+ * int page = RequestHelper.queryInt(req, "page", 1, 100);
+ * long id = RequestHelper.paramLong(req, "id");
+ * String q = RequestHelper.queryRequired(req, "q");
+ * }
+ */
+public final class RequestHelper {
+
+ private RequestHelper() {}
+
+ // ── Query parameters ──────────────────────────────────────────────────────
+
+ /**
+ * Returns the query param as an int, or {@code defaultValue} if absent.
+ * Caps the result at {@code max}. Throws 400 on non-integer input.
+ */
+ public static int queryInt(Request req, String name, int defaultValue, int max) {
+ String v = req.query(name);
+ if (v == null || v.isEmpty()) return defaultValue;
+ try {
+ return Math.min(Integer.parseInt(v), max);
+ } catch (NumberFormatException e) {
+ throw HttpException.badRequest("Invalid integer for query param '" + name + "': " + v);
+ }
+ }
+
+ /**
+ * Returns the query param value. Throws 400 if absent or blank.
+ */
+ public static String queryRequired(Request req, String name) {
+ String v = req.query(name);
+ if (v == null || v.isBlank())
+ throw HttpException.badRequest("Missing required query param: " + name);
+ return v;
+ }
+
+ /**
+ * Returns the query param, or {@code null} if absent.
+ */
+ public static String queryOptional(Request req, String name) {
+ return req.query(name);
+ }
+
+ /**
+ * Returns the query param as a long, or {@code defaultValue} if absent.
+ * Throws 400 on non-long input.
+ */
+ public static long queryLong(Request req, String name, long defaultValue) {
+ String v = req.query(name);
+ if (v == null || v.isEmpty()) return defaultValue;
+ try {
+ return Long.parseLong(v);
+ } catch (NumberFormatException e) {
+ throw HttpException.badRequest("Invalid long for query param '" + name + "': " + v);
+ }
+ }
+
+ // ── Path parameters ───────────────────────────────────────────────────────
+
+ /**
+ * Returns the path parameter as a long. Throws 400 if absent or non-long.
+ */
+ public static long paramLong(Request req, String name) {
+ String v = req.param(name);
+ if (v == null)
+ throw HttpException.badRequest("Missing path param: " + name);
+ try {
+ return Long.parseLong(v);
+ } catch (NumberFormatException e) {
+ throw HttpException.badRequest("Invalid long for path param '" + name + "': " + v);
+ }
+ }
+
+ /**
+ * Returns the path parameter as an int. Throws 400 if absent or non-int.
+ */
+ public static int paramInt(Request req, String name) {
+ String v = req.param(name);
+ if (v == null)
+ throw HttpException.badRequest("Missing path param: " + name);
+ try {
+ return Integer.parseInt(v);
+ } catch (NumberFormatException e) {
+ throw HttpException.badRequest("Invalid int for path param '" + name + "': " + v);
+ }
+ }
+
+ /**
+ * Returns the path parameter as a String. Throws 400 if absent.
+ */
+ public static String paramRequired(Request req, String name) {
+ String v = req.param(name);
+ if (v == null)
+ throw HttpException.badRequest("Missing path param: " + name);
+ return v;
+ }
+}
diff --git a/flash/src/main/java/dev/relism/routing/AbstractRouter.java b/flash/src/main/java/dev/relism/routing/AbstractRouter.java
index dd75354..c3f61c7 100644
--- a/flash/src/main/java/dev/relism/routing/AbstractRouter.java
+++ b/flash/src/main/java/dev/relism/routing/AbstractRouter.java
@@ -90,8 +90,6 @@ public abstract class AbstractRouter {
return compiled;
}
- private static final Middleware[] NO_MIDDLEWARES = new Middleware[0];
-
// ── Error handler configuration ───────────────────────────────────────────
public AbstractRouter onNotFound(SimpleHandler.FunctionalHandler handler) {
@@ -104,38 +102,23 @@ public abstract class AbstractRouter {
return this;
}
- // ── Lambda handler registration ───────────────────────────────────────────
+ // ── Internal registration (package-private) ───────────────────────────────
/**
- * Registers a lambda handler with optional handler-level middlewares.
- * Router-level middlewares are applied automatically on top.
+ * Registers a lambda handler immediately with a pre-built middleware array.
+ * Called by {@link RouteHandle#with} and by {@link dev.relism.extension.FlashApp}.
*/
- public AbstractRouter register(HttpMethod method, String path,
- SimpleHandler.FunctionalHandler handler,
- Middleware... middlewares) {
+ public AbstractRouter doRegister(HttpMethod method, String path,
+ SimpleHandler.FunctionalHandler handler, Middleware[] middlewares) {
return addRoute(method, PathUtils.sanitize(path),
compile(new SimpleHandler(handler), middlewares));
}
- public AbstractRouter get (String path, SimpleHandler.FunctionalHandler h, Middleware... m) { return register(HttpMethod.GET, path, h, m); }
- public AbstractRouter post (String path, SimpleHandler.FunctionalHandler h, Middleware... m) { return register(HttpMethod.POST, path, h, m); }
- public AbstractRouter put (String path, SimpleHandler.FunctionalHandler h, Middleware... m) { return register(HttpMethod.PUT, path, h, m); }
- public AbstractRouter delete (String path, SimpleHandler.FunctionalHandler h, Middleware... m) { return register(HttpMethod.DELETE, path, h, m); }
- public AbstractRouter patch (String path, SimpleHandler.FunctionalHandler h, Middleware... m) { return register(HttpMethod.PATCH, path, h, m); }
- public AbstractRouter options(String path, SimpleHandler.FunctionalHandler h, Middleware... m) { return register(HttpMethod.OPTIONS, path, h, m); }
- public AbstractRouter head (String path, SimpleHandler.FunctionalHandler h, Middleware... m) { return register(HttpMethod.HEAD, path, h, m); }
- public AbstractRouter trace (String path, SimpleHandler.FunctionalHandler h, Middleware... m) { return register(HttpMethod.TRACE, path, h, m); }
- public AbstractRouter connect(String path, SimpleHandler.FunctionalHandler h, Middleware... m) { return register(HttpMethod.CONNECT, path, h, m); }
- public AbstractRouter purge (String path, SimpleHandler.FunctionalHandler h, Middleware... m) { return register(HttpMethod.PURGE, path, h, m); }
-
- // ── Class-based handler registration ─────────────────────────────────────
-
/**
- * Registers a class-based handler with optional handler-level middlewares.
- * The class must carry a {@link Route @Route} annotation declaring method and path.
- * Router-level middlewares are applied automatically on top.
+ * Registers a class-based handler immediately with a pre-built middleware array.
+ * Called by {@link RouteHandle#with} and by {@link dev.relism.extension.FlashApp}.
*/
- public AbstractRouter register(RequestHandler handler, Middleware... middlewares) {
+ public AbstractRouter doRegister(RequestHandler handler, Middleware[] middlewares) {
Route annotation = handler.getClass().getAnnotation(Route.class);
if (annotation != null)
addRoute(annotation.method(), annotation.path(),
@@ -143,6 +126,44 @@ public abstract class AbstractRouter {
return this;
}
+ // ── Lambda handler registration ───────────────────────────────────────────
+
+ /**
+ * Begins registration of a lambda handler. Call {@link RouteHandle#with} on the
+ * returned handle to supply middlewares (if any) and complete the registration.
+ *
+ * {@code
+ * router.get("/ping", (req, res) -> "pong").with();
+ * router.get("/admin", adminHandler).with(auth, logging);
+ * }
+ */
+ public RouteHandle{@code
+ * router.register(new BlogHandler()).with();
+ * router.register(new AdminHandler()).with(logging);
+ * }
+ */
+ public RouteHandle{@code
+ * // No middleware
+ * app.get("/ping", (req, res) -> "pong").with();
+ *
+ * // With middleware
+ * app.get("/me", (req, res) -> ClaimsHolder.user().email()).with(oidc.protect());
+ * app.get("/admin", (req, res) -> "admin").with(oidc.requireRole("admin"));
+ *
+ * // Class-based handler — annotations (@Authenticated, @RolesAllowed) are
+ * // processed automatically; .with() can add extra middleware on top.
+ * app.register(new MyHandler()).with();
+ * app.register(new SecuredHandler()).with(logging);
+ * }
+ *
+ * @param