diff --git a/AGENTS.md b/AGENTS.md index 2362849..cb99dad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,8 @@ Format: `(): ` Allowed scopes: `core`, `testing`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`, `ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-web-bundler`, -`ext-mcp`, `ext-scheduler`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`, `release`, `deps`, `ci`. +`ext-mcp`, `ext-validation`, `ext-scheduler`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`, +`ext-cache-core`, `ext-cache-caffeine`, `release`, `deps`, `ci`. Examples: ``` diff --git a/README.md b/README.md index 84cf184..10ac1e4 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,10 @@ a zero-allocation FSM router, bounded protocol state, and one shared request/res | `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives | | `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension | | `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension | +| `flash-extensions/flash-ext-validation` | Request validation — jakarta constraints, compiled once per type | +| `flash-extensions/flash-ext-scheduler` | Interval and cron background jobs on virtual threads | +| `flash-extensions/flash-ext-cache-core` | Caching contract — `Cache`, `CacheManager`, `CacheSpec` | +| `flash-extensions/flash-ext-cache-caffeine` | In-process cache backed by Caffeine | ## Requirements @@ -146,6 +150,9 @@ See extension-specific READMEs for full details: - [`flash-ext-mcp`](flash-extensions/flash-ext-mcp/docs/README.md) - [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/README.md) - [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md) +- [`flash-ext-validation`](flash-extensions/flash-ext-validation/docs/README.md) +- [`flash-ext-scheduler`](flash-extensions/flash-ext-scheduler/docs/README.md) +- [`flash-ext-cache-caffeine`](flash-extensions/flash-ext-cache-caffeine/docs/README.md) - [`flash-testing`](flash-testing/docs/README.md) ## Error handlers diff --git a/flash-extensions/flash-ext-cache-caffeine/docs/README.md b/flash-extensions/flash-ext-cache-caffeine/docs/README.md new file mode 100644 index 0000000..757ed16 --- /dev/null +++ b/flash-extensions/flash-ext-cache-caffeine/docs/README.md @@ -0,0 +1,85 @@ +# flash-ext-cache-caffeine + +In-process caching backed by [Caffeine](https://github.com/ben-manes/caffeine). Implements +[`flash-ext-cache-core`](../../flash-ext-cache-core/docs/README.md). + +## Dependency + +```xml + + dev.relism + flash-ext-cache-caffeine + ${flash.version} + +``` + +## Quick start + +```java +FlashApp.create(8080) + .install(new CaffeineCacheExtension()) + .scan("dev.example.api"); +``` + +```java +@GET("/api/users/{id}") +public final class GetUser extends RequestHandler { + + private Cache users; + private UserRepository repo; + + @Override protected void onInit() { + repo = require(UserRepository.class); + users = require(CacheManager.class).build("users", spec -> spec + .maxSize(10_000) + .ttl(Duration.ofMinutes(10))); + } + + @Override public Object handle(Request req, Response res) { + return users.get(req.param("id"), repo::findById); + } +} +``` + +The extension takes no configuration. Each cache declares its own size and TTL where it is built. + +## Why Caffeine and not a `LinkedHashMap` + +An LRU on top of `LinkedHashMap` is about sixty lines, and for a cache that is genuinely +low-traffic it is the right answer — `ConcurrentHashMap::computeIfAbsent` is one line and has no +hit rate to get wrong. + +This module exists for the case where that stops being true. Caffeine's W-TinyLFU admission, +striped frequency counters and amortised eviction are not a weekend's work to reproduce, and the +failure mode of getting them wrong is a cache that is *slower* than no cache — lock contention on +every lookup, or an eviction policy that throws away exactly the entries you were about to want. + +## Lifecycle + +Caches are released through `FlashContext.onClose`, so `app.stop()` drops every entry. That is +invisible in production with one app per process and matters immediately under test, where many +apps start and stop in one JVM. + +## Statistics + +```java +CacheStats stats = users.stats(); +stats.hitRate(); // 0.0 until something is looked up +``` + +Requires `recordStats()` on the spec. Without it you get `CacheStats.DISABLED`, which is honest +about being unmeasured rather than reporting zeroes that look like a cold cache. + +`manager.names()` lists every cache built so far, for an ops endpoint. + +## What this is not + +**HTTP caching.** If what you want is for the *client* to stop asking — `Cache-Control`, `ETag`, +`304 Not Modified` — that is a middleware, not an object cache, and it saves the whole request +rather than the lookup inside it. Reach for that first: it is cheaper, and the two solve different +problems. + +**A shared cache.** Every replica has its own. Two instances will hold different values for the +same key, and an invalidation on one does not reach the other. When that becomes a problem the +answer is a networked backend — see the note on `flash-ext-cache-redis` — and the semantics change +with it: a cache that can fail is no longer transparent. diff --git a/flash-extensions/flash-ext-cache-caffeine/pom.xml b/flash-extensions/flash-ext-cache-caffeine/pom.xml new file mode 100644 index 0000000..0141f3d --- /dev/null +++ b/flash-extensions/flash-ext-cache-caffeine/pom.xml @@ -0,0 +1,39 @@ + + + 4.0.0 + + + dev.relism + flash-extensions + 2.1.0-SNAPSHOT + + + flash-ext-cache-caffeine + + + + dev.relism + flash-ext-cache-core + + + + com.github.ben-manes.caffeine + caffeine + + + org.junit.jupiter + junit-jupiter + + + dev.relism + flash-testing + test + + + diff --git a/flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCache.java b/flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCache.java new file mode 100644 index 0000000..b884194 --- /dev/null +++ b/flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCache.java @@ -0,0 +1,41 @@ +package dev.relism.flash.ext.cache.caffeine; + +import dev.relism.flash.ext.cache.Cache; +import dev.relism.flash.ext.cache.CacheStats; + +import java.util.function.Function; + +/** + * {@link Cache} over a Caffeine cache. A thin adapter by design: every method delegates directly, + * adding no wrapper object, no copy and no synchronisation of its own. + */ +final class CaffeineCache implements Cache { + + private final com.github.benmanes.caffeine.cache.Cache delegate; + private final boolean statsRecorded; + + CaffeineCache(com.github.benmanes.caffeine.cache.Cache delegate, boolean statsRecorded) { + this.delegate = delegate; + this.statsRecorded = statsRecorded; + } + + @Override + public V get(K key, Function loader) { + // Caffeine's own get(key, mappingFunction) already guarantees the loader runs once per key + // across concurrent callers; wrapping it in anything of ours would only add a race. + return delegate.get(key, loader); + } + + @Override public V getIfPresent(K key) { return delegate.getIfPresent(key); } + @Override public void put(K key, V value) { delegate.put(key, value); } + @Override public void invalidate(K key) { delegate.invalidate(key); } + @Override public void invalidateAll() { delegate.invalidateAll(); } + @Override public long estimatedSize() { return delegate.estimatedSize(); } + + @Override + public CacheStats stats() { + if (!statsRecorded) return CacheStats.DISABLED; + com.github.benmanes.caffeine.cache.stats.CacheStats snapshot = delegate.stats(); + return new CacheStats(snapshot.hitCount(), snapshot.missCount(), snapshot.evictionCount()); + } +} diff --git a/flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheExtension.java b/flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheExtension.java new file mode 100644 index 0000000..f7b39f3 --- /dev/null +++ b/flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheExtension.java @@ -0,0 +1,33 @@ +package dev.relism.flash.ext.cache.caffeine; + +import dev.relism.flash.ext.cache.CacheManager; +import dev.relism.flash.extension.FlashContext; +import dev.relism.flash.extension.FlashExtension; +import dev.relism.flash.extension.FlashRegistrar; + +/** + * Installs an in-process {@link CacheManager} backed by Caffeine. + * + *
{@code
+ * FlashApp.create(8080)
+ *     .install(new CaffeineCacheExtension())
+ *     .scan("dev.example.api");
+ * }
+ * + *

No configuration. Each cache declares its own size and TTL where it is built, because those + * are properties of what is being cached, not of the process caching it. + * + *

Caches are dropped through {@link FlashContext#onClose}, so a stopped app does not keep its + * values alive — which matters when many apps start and stop in one JVM, as they do under test. + */ +public final class CaffeineCacheExtension implements FlashExtension { + + @Override + public void configure(FlashRegistrar app, FlashContext ctx) { + ctx.supply(CacheManager.class, services -> { + CaffeineCacheManager manager = new CaffeineCacheManager(); + services.onClose(manager::clear); + return manager; + }); + } +} diff --git a/flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheManager.java b/flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheManager.java new file mode 100644 index 0000000..7f6332c --- /dev/null +++ b/flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheManager.java @@ -0,0 +1,66 @@ +package dev.relism.flash.ext.cache.caffeine; + +import com.github.benmanes.caffeine.cache.Caffeine; +import dev.relism.flash.ext.cache.Cache; +import dev.relism.flash.ext.cache.CacheManager; +import dev.relism.flash.ext.cache.CacheSpec; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** In-process {@link CacheManager} backed by Caffeine. */ +final class CaffeineCacheManager implements CacheManager { + + private final Map caches = new ConcurrentHashMap<>(); + + @Override + @SuppressWarnings("unchecked") + public Cache build(String name, java.util.function.Consumer configure) { + CacheSpec spec = CacheSpec.of(); + configure.accept(spec); + + Entry entry = caches.computeIfAbsent(name, key -> new Entry(describe(spec), create(spec))); + // Two handlers sharing a cache is the point; two handlers disagreeing about its size or + // TTL is a bug that would otherwise resolve to whichever one ran first. + String requested = describe(spec); + if (!entry.signature.equals(requested)) + throw new IllegalStateException("Cache '" + name + "' already exists as " + entry.signature + + " but was requested as " + requested); + return (Cache) entry.cache; + } + + @Override + @SuppressWarnings("unchecked") + public Cache cache(String name) { + Entry entry = caches.get(name); + return entry == null ? null : (Cache) entry.cache; + } + + @Override + public Set names() { + return Set.copyOf(caches.keySet()); + } + + /** Releases every entry so a stopped app does not keep its values alive. */ + void clear() { + caches.values().forEach(entry -> entry.cache.invalidateAll()); + caches.clear(); + } + + private static CaffeineCache create(CacheSpec spec) { + Caffeine builder = Caffeine.newBuilder(); + if (spec.bounded()) builder.maximumSize(spec.maxSize()); + if (spec.ttl() != null) builder.expireAfterWrite(spec.ttl()); + if (spec.ttlAfterAccess() != null) builder.expireAfterAccess(spec.ttlAfterAccess()); + if (spec.statsRecorded()) builder.recordStats(); + return new CaffeineCache<>(builder.build(), spec.statsRecorded()); + } + + private static String describe(CacheSpec spec) { + return "maxSize=" + spec.maxSize() + " ttl=" + spec.ttl() + + " ttlAfterAccess=" + spec.ttlAfterAccess() + " stats=" + spec.statsRecorded(); + } + + private record Entry(String signature, CaffeineCache cache) {} +} diff --git a/flash-extensions/flash-ext-cache-caffeine/src/test/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheTest.java b/flash-extensions/flash-ext-cache-caffeine/src/test/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheTest.java new file mode 100644 index 0000000..8c85a42 --- /dev/null +++ b/flash-extensions/flash-ext-cache-caffeine/src/test/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheTest.java @@ -0,0 +1,160 @@ +package dev.relism.flash.ext.cache.caffeine; + +import dev.relism.flash.ext.cache.Cache; +import dev.relism.flash.ext.cache.CacheManager; +import dev.relism.flash.ext.cache.CacheStats; +import dev.relism.flash.extension.FlashApp; +import dev.relism.flash.extension.FlashConfiguration; +import dev.relism.flash.testing.FlashTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +class CaffeineCacheTest { + + private static final AtomicInteger loads = new AtomicInteger(); + + @RegisterExtension + static FlashTest app = FlashTest.of(configured -> { + configured.install(new CaffeineCacheExtension()); + configured.ctx().onReady(() -> { + Cache users = configured.ctx().require(CacheManager.class) + .build("users", spec -> spec.maxSize(100).ttl(Duration.ofMinutes(5)).recordStats()); + configured.get("/users/{id}", (req, res) -> + users.get(req.param("id"), id -> "loaded:" + id + ":" + loads.incrementAndGet())); + }); + }); + + private static CacheManager manager() { + return app.app().ctx().require(CacheManager.class); + } + + @Test + void aRepeatedRequestIsServedFromCache() { + String first = app.get("/users/alice").expectStatus(200).body(); + String second = app.get("/users/alice").expectStatus(200).body(); + + assertEquals(first, second); + assertTrue(first.startsWith("loaded:alice:")); + } + + @Test + void distinctKeysLoadSeparately() { + assertNotEquals(app.get("/users/bob").body(), app.get("/users/carol").body()); + } + + @Test + void statsCountHitsAndMisses() { + Cache cache = manager().build("stats-probe", spec -> spec.maxSize(10).recordStats()); + cache.get("k", key -> "v"); + cache.get("k", key -> "v"); + + CacheStats stats = cache.stats(); + assertEquals(1, stats.misses()); + assertEquals(1, stats.hits()); + assertEquals(0.5, stats.hitRate()); + } + + @Test + void statsAreDisabledUnlessAskedFor() { + Cache cache = manager().build("no-stats", spec -> spec.maxSize(10)); + cache.get("k", key -> "v"); + + assertEquals(CacheStats.DISABLED, cache.stats()); + } + + @Test + void theLoaderRunsOncePerKeyUnderConcurrency() throws Exception { + Cache cache = manager().build("single-flight", spec -> spec.maxSize(10)); + AtomicInteger invocations = new AtomicInteger(); + int threads = 16; + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threads); + + for (int i = 0; i < threads; i++) { + Thread.ofVirtual().start(() -> { + try { + start.await(); + cache.get("hot", key -> { + invocations.incrementAndGet(); + return "value"; + }); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + done.countDown(); + } + }); + } + start.countDown(); + assertTrue(done.await(5, java.util.concurrent.TimeUnit.SECONDS)); + + assertEquals(1, invocations.get(), "concurrent callers must share one load, not race"); + } + + @Test + void aNullLoaderResultStoresNothing() { + Cache cache = manager().build("nulls", spec -> spec.maxSize(10)); + + assertNull(cache.get("missing", key -> null)); + assertNull(cache.getIfPresent("missing")); + } + + @Test + void invalidateDropsOneKeyAndInvalidateAllDropsEverything() { + Cache cache = manager().build("invalidation", spec -> spec.maxSize(10)); + cache.put("a", "1"); + cache.put("b", "2"); + + cache.invalidate("a"); + assertNull(cache.getIfPresent("a")); + assertEquals("2", cache.getIfPresent("b")); + + cache.invalidateAll(); + assertNull(cache.getIfPresent("b")); + } + + @Test + void buildIsIdempotentPerName() { + Cache first = manager().build("shared", spec -> spec.maxSize(10)); + Cache second = manager().build("shared", spec -> spec.maxSize(10)); + + assertSame(first, second, "two handlers asking for one cache must get one cache"); + assertSame(first, manager().cache("shared")); + } + + @Test + void disagreeingOnASharedCacheIsARejectedMistakeNotASilentWinner() { + manager().build("contested", spec -> spec.maxSize(10)); + + IllegalStateException conflict = assertThrows(IllegalStateException.class, + () -> manager().build("contested", spec -> spec.maxSize(999))); + assertTrue(conflict.getMessage().contains("contested"), conflict.getMessage()); + } + + @Test + void unknownNameReturnsNullRatherThanBuildingOne() { + assertNull(manager().cache("never-built")); + } + + /** Why CaffeineCacheExtension registers onClose: values must not outlive the app holding them. */ + @Test + void stoppingTheAppReleasesEveryCache() { + FlashApp standalone = FlashApp.create(FlashConfiguration.builder() + .port(0).host("127.0.0.1").shutdownDrainTimeoutMs(250).build()) + .install(new CaffeineCacheExtension()); + standalone.start(); + CacheManager manager = standalone.ctx().require(CacheManager.class); + manager.build("scoped", spec -> spec.maxSize(10)).put("k", "v"); + assertEquals(1, manager.names().size()); + + standalone.stop().join(); + + assertTrue(manager.names().isEmpty(), "caches must be released when the app stops"); + } +} diff --git a/flash-extensions/flash-ext-cache-core/docs/README.md b/flash-extensions/flash-ext-cache-core/docs/README.md new file mode 100644 index 0000000..ffdc3d2 --- /dev/null +++ b/flash-extensions/flash-ext-cache-core/docs/README.md @@ -0,0 +1,64 @@ +# flash-ext-cache-core + +The caching contract, shared across backends. Like `flash-ext-data-core`, this module talks to +nothing: it defines the abstractions and a backend implements them. + +## Components + +- `Cache` — a named cache. `get(key, loader)` is the method that matters. +- `CacheManager` — creates and hands back named caches. +- `CacheSpec` — size and expiry for one cache. +- `CacheStats` — hit/miss/eviction counters. + +Install a backend, not this module: [`flash-ext-cache-caffeine`](../../flash-ext-cache-caffeine/docs/README.md) +for in-process caching. + +## The one shape that matters + +```java +User user = users.get(id, repo::findById); +``` + +Compute-if-absent is the only cache operation most code needs, and the only one that is hard to +get right — the loader runs **once per key** across concurrent callers, and the rest wait rather +than each computing their own. `getIfPresent`, `put`, `invalidate` and `invalidateAll` exist for +what it cannot express. + +A loader returning `null` stores nothing and returns `null`. Caching absence is a decision, not a +default; wrap it in an `Optional` or a sentinel if you want it. + +## Naming and sharing + +`CacheManager.build(name, spec)` is idempotent per name: two handlers asking for `"users"` get one +cache, not two, so nobody has to coordinate who creates it first. + +If they disagree about the spec, that throws. The alternative is a cache whose size depends on +which handler happened to initialise first, which is the kind of bug that only shows up under +load. + +## Specs + +```java +CacheSpec.of() + .maxSize(10_000) + .ttl(Duration.ofMinutes(10)) + .recordStats(); +``` + +Every field is optional, but a spec that sets neither `maxSize` nor `ttl` is an unbounded cache +that never expires — a memory leak wearing a hat. Set at least one. + +`recordStats()` is off by default: counting costs a pair of atomic increments on every lookup, and +a cache nobody is measuring should not pay for numbers nobody reads. Without it, `stats()` returns +`CacheStats.DISABLED` rather than silently zero. + +## Where the spec lives + +On the cache, at the point it is built — not in application config. Size and TTL are properties of +*what is being cached*, not of the process doing the caching, and a TTL in a config file is a TTL +nobody can relate back to the data it governs. + +## Writing a backend + +Implement `CacheManager` and `Cache`, provide the manager from a `FlashExtension`, and register +cleanup with `FlashContext.onClose` so a stopped app does not keep its values alive. diff --git a/flash-extensions/flash-ext-cache-core/pom.xml b/flash-extensions/flash-ext-cache-core/pom.xml new file mode 100644 index 0000000..0a825be --- /dev/null +++ b/flash-extensions/flash-ext-cache-core/pom.xml @@ -0,0 +1,25 @@ + + + 4.0.0 + + + dev.relism + flash-extensions + 2.1.0-SNAPSHOT + + + flash-ext-cache-core + + + + dev.relism + flash + + + org.junit.jupiter + junit-jupiter + + + diff --git a/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/Cache.java b/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/Cache.java new file mode 100644 index 0000000..eb27faf --- /dev/null +++ b/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/Cache.java @@ -0,0 +1,50 @@ +package dev.relism.flash.ext.cache; + +import java.util.function.Function; + +/** + * A named cache. Obtained from a {@link CacheManager}, safe to hold in a handler field and share + * across threads. + * + *

{@code
+ * User user = users.get(id, repo::findById);
+ * }
+ * + *

{@link #get} is the only method most code needs. The rest exist for the cases it cannot + * express: reading without populating, writing a value computed elsewhere, and invalidating. + * + * @param key type — must have a stable {@code hashCode}/{@code equals} + * @param value type + */ +public interface Cache { + + /** + * Returns the cached value, computing and storing it with {@code loader} if absent. + * + *

The loader runs at most once per key across concurrent callers; the others wait for it + * rather than each computing their own. A loader returning {@code null} stores nothing and + * {@code null} is returned. + */ + V get(K key, Function loader); + + /** The cached value, or {@code null} if absent. Never invokes a loader. */ + V getIfPresent(K key); + + /** Stores {@code value}, replacing any existing entry. */ + void put(K key, V value); + + /** Drops {@code key}. Does nothing if it was absent. */ + void invalidate(K key); + + /** Drops every entry. */ + void invalidateAll(); + + /** Approximate entry count. Approximate because eviction is asynchronous in most backends. */ + long estimatedSize(); + + /** + * Hit/miss counters since this cache was built, or {@link CacheStats#DISABLED} when the + * backend was not asked to record them. + */ + CacheStats stats(); +} diff --git a/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheManager.java b/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheManager.java new file mode 100644 index 0000000..26adb07 --- /dev/null +++ b/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheManager.java @@ -0,0 +1,31 @@ +package dev.relism.flash.ext.cache; + +import java.util.function.Consumer; + +/** + * Creates and hands back named caches. Resolve it with {@code require(CacheManager.class)}; a + * backend extension such as {@code flash-ext-cache-caffeine} provides the implementation. + * + *

{@code
+ * @Override protected void onInit() {
+ *     users = require(CacheManager.class).build("users", spec -> spec
+ *             .maxSize(10_000)
+ *             .ttl(Duration.ofMinutes(10)));
+ * }
+ * }
+ * + *

{@link #build} is idempotent per name: calling it twice returns the same cache rather than + * two, so several handlers can share one without coordinating who creates it. The spec of the + * first call wins; a later call with a different spec is a configuration mistake and throws. + */ +public interface CacheManager { + + /** Creates the named cache, or returns the existing one. */ + Cache build(String name, Consumer spec); + + /** The named cache, or {@code null} if {@link #build} has not been called for it. */ + Cache cache(String name); + + /** Every cache name built so far, for an ops endpoint. */ + java.util.Set names(); +} diff --git a/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheSpec.java b/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheSpec.java new file mode 100644 index 0000000..3897ce6 --- /dev/null +++ b/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheSpec.java @@ -0,0 +1,64 @@ +package dev.relism.flash.ext.cache; + +import java.time.Duration; + +/** + * How one cache should behave. Every field is optional — a spec that sets nothing gives an + * unbounded cache that never expires, which is a memory leak wearing a hat, so set at least one + * of {@link #maxSize} or {@link #ttl}. + * + *

{@code
+ * CacheSpec.of().maxSize(10_000).ttl(Duration.ofMinutes(10))
+ * }
+ * + *

Mutable builder rather than a record with {@code withX} copies: it is constructed once at + * boot inside a lambda and never shared. + */ +public final class CacheSpec { + + private long maxSize = -1; + private Duration ttl; + private Duration ttlAfterAccess; + private boolean recordStats; + + private CacheSpec() {} + + public static CacheSpec of() { + return new CacheSpec(); + } + + /** Maximum entries before the backend starts evicting. Negative means unbounded. */ + public CacheSpec maxSize(long maxSize) { + this.maxSize = maxSize; + return this; + } + + /** Entries expire this long after they were written. */ + public CacheSpec ttl(Duration ttl) { + this.ttl = ttl; + return this; + } + + /** Entries expire this long after they were last read or written. */ + public CacheSpec ttlAfterAccess(Duration ttlAfterAccess) { + this.ttlAfterAccess = ttlAfterAccess; + return this; + } + + /** + * Records hit/miss counters for {@link Cache#stats()}. + * + *

Off by default: counting costs a pair of atomic increments on every lookup, and a cache + * nobody is measuring should not pay for numbers nobody reads. + */ + public CacheSpec recordStats() { + this.recordStats = true; + return this; + } + + public long maxSize() { return maxSize; } + public Duration ttl() { return ttl; } + public Duration ttlAfterAccess() { return ttlAfterAccess; } + public boolean statsRecorded() { return recordStats; } + public boolean bounded() { return maxSize >= 0; } +} diff --git a/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheStats.java b/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheStats.java new file mode 100644 index 0000000..a48276c --- /dev/null +++ b/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheStats.java @@ -0,0 +1,20 @@ +package dev.relism.flash.ext.cache; + +/** + * Hit/miss counters for one cache. + * + * @param hits lookups that found a value + * @param misses lookups that had to load + * @param evictions entries dropped to respect {@link CacheSpec#maxSize()} + */ +public record CacheStats(long hits, long misses, long evictions) { + + /** Returned when {@link CacheSpec#recordStats()} was not set — all zero, and says so. */ + public static final CacheStats DISABLED = new CacheStats(0, 0, 0); + + /** Hits divided by lookups, or 0 when nothing has been looked up yet. */ + public double hitRate() { + long total = hits + misses; + return total == 0 ? 0 : (double) hits / total; + } +} diff --git a/flash-extensions/flash-ext-cache-redis/docs/README.md b/flash-extensions/flash-ext-cache-redis/docs/README.md new file mode 100644 index 0000000..3723782 --- /dev/null +++ b/flash-extensions/flash-ext-cache-redis/docs/README.md @@ -0,0 +1,39 @@ +# flash-ext-cache-redis — planned + +Not implemented. This directory holds the design so the decision is written down rather than +rediscovered; there is deliberately **no module, no pom and no source**, because an empty module +that builds an empty jar is dead weight in the reactor and in everyone's dependency tree. + +Add it when there is a second replica that actually needs shared state. + +## What it would implement + +`CacheManager` and `Cache` from [`flash-ext-cache-core`](../../flash-ext-cache-core/docs/README.md), +so switching backend is an install-line change: + +```java +.install(new RedisCacheExtension(RedisConfig.of("redis://localhost:6379"))) +``` + +## The part that is not a drop-in + +`flash-ext-cache-caffeine` cannot fail. A networked cache can, and that changes the contract in +ways an adapter cannot hide: + +- **`get(key, loader)` can fail before reaching the loader.** The honest default is to fall + through to the loader and serve the value uncached, so Redis being down degrades throughput + rather than taking the application with it. That has to be a decision, not an accident. +- **Values must be serialized.** Caffeine stores references. A `byte[]` codec belongs in the spec, + and the natural default is whatever `flash-ext-jackson` is already configured with. +- **`invalidateAll()` is not free.** Against a shared keyspace it is either a scan or a key + prefix per cache name. The prefix is the right answer, and it means cache names become part of + the wire contract. +- **Stats are per-client, not per-cache.** Hit rate stays meaningful; eviction count does not, + because Redis evicts on its own policy. + +## Why it is not built yet + +Nothing in the codebase has two replicas sharing cache state. Building it now would mean choosing +a client library, a serialization format and a failure policy with no real usage to check them +against — and the failure policy in particular is the kind of decision that is wrong until a +production incident tells you otherwise. diff --git a/flash-extensions/flash-ext-openapi/pom.xml b/flash-extensions/flash-ext-openapi/pom.xml index a3ba1fe..2aae4e7 100644 --- a/flash-extensions/flash-ext-openapi/pom.xml +++ b/flash-extensions/flash-ext-openapi/pom.xml @@ -29,6 +29,15 @@ org.projectlombok lombok + + + jakarta.validation + jakarta.validation-api + true + org.junit.jupiter junit-jupiter diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ConstraintHints.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ConstraintHints.java new file mode 100644 index 0000000..a92a8aa --- /dev/null +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ConstraintHints.java @@ -0,0 +1,84 @@ +package dev.relism.flash.ext.openapi; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; + +/** + * Mirrors {@code jakarta.validation} constraints into the generated schema, so a type carries its + * rules once and both the validator and the published contract read them. + * + *

Loaded reflectively by {@link OpenApiBuilder} and used only when the annotations are on the + * classpath — this class is never touched otherwise, so {@code flash-ext-openapi} keeps working + * with no validation dependency at all. Nothing to install and nothing to configure: if the + * annotations are there, the schema gains {@code minLength}, {@code maximum}, {@code format} and + * {@code required} on its own. + */ +final class ConstraintHints { + + private ConstraintHints() {} + + /** True when jakarta.validation is resolvable, so the caller may use this class. */ + static boolean available() { + try { + Class.forName("jakarta.validation.constraints.NotNull", false, ConstraintHints.class.getClassLoader()); + return true; + } catch (Throwable absent) { + return false; + } + } + + /** + * Merges {@code field}'s constraints into {@code property}, and reports whether the field is + * required. Never overwrites a key an explicit {@code @Schema} already set. + */ + static boolean apply(Field field, Map property) { + boolean isString = "string".equals(property.get("type")); + + Size size = field.getAnnotation(Size.class); + if (size != null) { + if (isString) { + if (size.min() > 0) property.putIfAbsent("minLength", size.min()); + if (size.max() != Integer.MAX_VALUE) property.putIfAbsent("maxLength", size.max()); + } else if ("array".equals(property.get("type"))) { + if (size.min() > 0) property.putIfAbsent("minItems", size.min()); + if (size.max() != Integer.MAX_VALUE) property.putIfAbsent("maxItems", size.max()); + } + } + + Min min = field.getAnnotation(Min.class); + if (min != null) property.putIfAbsent("minimum", min.value()); + + Max max = field.getAnnotation(Max.class); + if (max != null) property.putIfAbsent("maximum", max.value()); + + if (field.isAnnotationPresent(Email.class)) property.putIfAbsent("format", "email"); + + Pattern pattern = field.getAnnotation(Pattern.class); + if (pattern != null) property.putIfAbsent("pattern", pattern.regexp()); + + if (field.isAnnotationPresent(NotBlank.class) && isString) property.putIfAbsent("minLength", 1); + if (field.isAnnotationPresent(NotEmpty.class)) { + if (isString) property.putIfAbsent("minLength", 1); + else if ("array".equals(property.get("type"))) property.putIfAbsent("minItems", 1); + } + + return field.isAnnotationPresent(NotNull.class) + || field.isAnnotationPresent(NotBlank.class) + || field.isAnnotationPresent(NotEmpty.class); + } + + /** Constraint annotations this bridge understands, for documentation and tests. */ + static List supported() { + return List.of("@NotNull", "@NotBlank", "@NotEmpty", "@Size", "@Min", "@Max", "@Email", "@Pattern"); + } +} diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java index 07a52b1..a420587 100644 --- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java @@ -365,6 +365,9 @@ public final class OpenApiBuilder { return null; } + /** Resolved once: jakarta.validation is an optional dependency of this module. */ + private static final boolean CONSTRAINTS_PRESENT = ConstraintHints.available(); + private static final class SchemaRegistry { private static final Set> SIMPLE = Set.of( String.class, CharSequence.class, @@ -476,8 +479,14 @@ public final class OpenApiBuilder { if (jp.access() == Access.WRITE_ONLY) property.put("writeOnly", true); } + // Constraints declared for flash-ext-validation also describe the contract, so + // mirror them here rather than making callers restate every rule as @Schema. + boolean constrainedRequired = CONSTRAINTS_PRESENT && ConstraintHints.apply(f, property); + properties.put(name, property); - if ((ps != null && ps.required()) || (sp != null && sp.required()) || (jp != null && jp.required())) required.add(name); + if (constrainedRequired + || (ps != null && ps.required()) || (sp != null && sp.required()) || (jp != null && jp.required())) + required.add(name); } if (!properties.isEmpty()) out.put("properties", properties); diff --git a/flash-extensions/flash-ext-validation/docs/README.md b/flash-extensions/flash-ext-validation/docs/README.md new file mode 100644 index 0000000..09f5076 --- /dev/null +++ b/flash-extensions/flash-ext-validation/docs/README.md @@ -0,0 +1,143 @@ +# flash-ext-validation + +Request validation for Flash. Standard `jakarta.validation` annotations, compiled once per type +into a flat check table, with zero allocation on the passing path. + +## What it provides + +| Component | Description | +|---|---| +| `Validation` | The service — `body(req, type)` parses and verifies, `validate(value)` verifies | +| `Validator` | One type's compiled constraints; reusable and thread-safe | +| `ValidationException` | 422 carrying every violation, not just the first | + +## Dependency + +```xml + + dev.relism + flash-ext-validation + ${flash.version} + +``` + +## Quick start + +```java +FlashApp.create(8080) + .install(new JacksonExtension()) + .install(new ValidationExtension()) + .scan("dev.example.api"); +``` + +```java +public record CreateUser( + @NotBlank @Size(max = 80) String name, + @Email String email, + @Min(18) int age) {} +``` + +```java +@POST("/api/users") +public final class CreateUserHandler extends RequestHandler { + + private Validation validation; + private UserService users; + + @Override protected void onInit() { + validation = require(Validation.class); + users = require(UserService.class); + } + + @Override public Object handle(Request req, Response res) throws Exception { + CreateUser dto = validation.body(req, CreateUser.class); + return res.status(201).body(users.create(dto)); + } +} +``` + +There is nothing to configure. Constraints come from the annotations already on your types, and +failures reach the client as `422` on their own — see [Error responses](#error-responses). + +## Supported constraints + +`@NotNull` · `@NotBlank` · `@NotEmpty` · `@Size` · `@Min` · `@Max` · `@Email` · `@Pattern` + +Jakarta null semantics are honoured exactly: **only `@NotNull` rejects null**. Every other +constraint passes a null value, so `@Email String email` means "if present, must look like an +email" — combine with `@NotNull` when it is mandatory. + +`@Size` applies to `CharSequence`, `Collection`, `Map` and object arrays. `@Min`/`@Max` apply to +primitive integrals and to `Number` subtypes. + +An unsupported annotation is ignored rather than rejected, so adding one is never a boot failure. + +## Records and classes + +Constraints are read from **declared fields**. A constraint on a record component propagates to +its backing field, so records and plain classes take the same path with no extra configuration: + +```java +record CreateUser(@NotBlank String name) {} // works +class CreateUser { @NotBlank private String name; } // works +``` + +## Error responses + +`ValidationException` extends Flash's `HttpException` with status 422, so the default exception +handler renders it. Nothing is registered, and your own `onException` still wins if you set one. + +```json +{"error":"name must not be blank; age must be at least 18","status":422} +``` + +Malformed JSON is a different failure and comes back as `400` from the codec, before any +constraint runs. + +## OpenAPI + +Install `flash-ext-openapi` alongside and the generated schema mirrors the same annotations — +`minLength`, `maxLength`, `minItems`, `minimum`, `maximum`, `pattern`, `format: email`, and +`required`. Declared once, enforced and published. + +Nothing registers this. `flash-ext-openapi` carries `jakarta.validation-api` as an optional +dependency and detects it at boot; without it the bridge class is never loaded. + +An explicit `@Schema` always wins — the bridge only fills keys nobody set. + +## Without Jackson + +`flash-ext-jackson` is optional. Without it `validate(value)` still works on values you construct +or parse yourself; only `body(req, type)` needs a codec and says so if one is missing. + +## Performance + +The passing path is the one that runs on every request, so it allocates nothing: + +- **Compiled once per type.** Constraints resolve to an opcode plus operands at first use, cached + in a `ClassValue` — stored beside the class by the JVM, so no map lookup, no lock, and the entry + is collected with the class rather than pinning it. +- **No reflection per request.** Fields are read through `MethodHandle`s adapted to an exact + signature: `(Object)Object` for references, `(Object)long` for primitive integrals. `invokeExact` + neither boxes nor builds the argument array that `Field.get` and `Method.invoke` allocate. +- **No megamorphic dispatch.** Checks are a flat array walked by a `tableswitch` on an opcode, not + a class hierarchy behind a virtual call. +- **No copies.** `@Size` reads a length the object already knows; `@Email` scans with `indexOf` + rather than a regex, because `Pattern.matcher` allocates a matcher and two int arrays per call. +- **Messages pre-rendered at compile time**, so even a failure formats nothing. + +The list, the violations and the exception exist only once something fails. + +`@Pattern` is the deliberate exception: its regex is compiled once, but `matcher()` allocates per +call. It is marked in the source. Prefer `@Size`/`@Email` on hot routes, or validate the shape +structurally. + +## Pre-warming + +Compilation happens on a type's first request. To pay it at boot instead: + +```java +ctx.onReady(() -> ctx.require(Validation.class).forType(CreateUser.class)); +``` + +Worth it only for a route that must not pay first-call cost. Everything else warms itself. diff --git a/flash-extensions/flash-ext-validation/pom.xml b/flash-extensions/flash-ext-validation/pom.xml new file mode 100644 index 0000000..c7fa868 --- /dev/null +++ b/flash-extensions/flash-ext-validation/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + + dev.relism + flash-extensions + 2.1.0-SNAPSHOT + + + flash-ext-validation + + + + dev.relism + flash + + + + jakarta.validation + jakarta.validation-api + + + + dev.relism + flash-ext-jackson + true + + + org.junit.jupiter + junit-jupiter + + + dev.relism + flash-testing + test + + + + dev.relism + flash-ext-openapi + test + + + diff --git a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Check.java b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Check.java new file mode 100644 index 0000000..eda228f --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Check.java @@ -0,0 +1,76 @@ +package dev.relism.flash.ext.validation; + +import java.lang.invoke.MethodHandle; +import java.util.regex.Pattern; + +/** + * One constraint, compiled. Flattened into an opcode plus its operands rather than a class per + * constraint type: the check loop becomes a {@code tableswitch} over a monomorphic array instead + * of a megamorphic virtual call, and a passing check touches no allocation at all. + * + *

Field access goes through a {@link MethodHandle} adapted at compile time to an exact + * signature — {@code (Object)Object} for reference fields, {@code (Object)long} for primitive + * integrals — so {@code invokeExact} neither boxes nor allocates an argument array the way + * {@code Field.get} and {@code Method.invoke} do. + */ +final class Check { + + static final int NOT_NULL = 0; + static final int NOT_BLANK = 1; + static final int NOT_EMPTY = 2; + static final int SIZE = 3; + static final int RANGE_PRIMITIVE = 4; + static final int RANGE_BOXED = 5; + static final int EMAIL = 6; + static final int PATTERN = 7; + + final int op; + final String field; + /** Pre-rendered at compile time, so even the failure path formats nothing. */ + final String message; + + /** {@code (Object)Object} — set for every op except {@link #RANGE_PRIMITIVE}. */ + final MethodHandle ref; + /** {@code (Object)long} — set only for {@link #RANGE_PRIMITIVE}. */ + final MethodHandle num; + + final int min; + final int max; + final long lo; + final long hi; + final Pattern pattern; + + private Check(int op, String field, String message, MethodHandle ref, MethodHandle num, + int min, int max, long lo, long hi, Pattern pattern) { + this.op = op; + this.field = field; + this.message = message; + this.ref = ref; + this.num = num; + this.min = min; + this.max = max; + this.lo = lo; + this.hi = hi; + this.pattern = pattern; + } + + static Check reference(int op, String field, String message, MethodHandle ref) { + return new Check(op, field, message, ref, null, 0, 0, 0, 0, null); + } + + static Check size(String field, String message, MethodHandle ref, int min, int max) { + return new Check(SIZE, field, message, ref, null, min, max, 0, 0, null); + } + + static Check rangePrimitive(String field, String message, MethodHandle num, long lo, long hi) { + return new Check(RANGE_PRIMITIVE, field, message, null, num, 0, 0, lo, hi, null); + } + + static Check rangeBoxed(String field, String message, MethodHandle ref, long lo, long hi) { + return new Check(RANGE_BOXED, field, message, ref, null, 0, 0, lo, hi, null); + } + + static Check pattern(String field, String message, MethodHandle ref, Pattern pattern) { + return new Check(PATTERN, field, message, ref, null, 0, 0, 0, 0, pattern); + } +} diff --git a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validation.java b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validation.java new file mode 100644 index 0000000..df692c2 --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validation.java @@ -0,0 +1,69 @@ +package dev.relism.flash.ext.validation; + +import dev.relism.flash.ext.jackson.Json; +import dev.relism.flash.models.Request; + +/** + * The validation service. Resolve it with {@code require(Validation.class)}. + * + *

{@code
+ * CreateUser dto = validation.body(req, CreateUser.class);   // parse + verify
+ * }
+ * + *

Constraints are compiled the first time a type is seen and cached in a {@link ClassValue}, + * which the JVM stores beside the class itself — no map lookup, no lock, and the entry is + * collected with the class rather than pinning it. Every later request walks the compiled table. + */ +public final class Validation { + + private final ClassValue validators = new ClassValue<>() { + @Override protected Validator computeValue(Class type) { + return Validator.compile(type); + } + }; + + /** Null when flash-ext-jackson is absent; only {@link #body} needs it. */ + private Json json; + + Validation() {} + + /** Called once at boot by {@link ValidationExtension}, after the service graph resolves. */ + void bindCodec(Json json) { + this.json = json; + } + + /** + * Deserializes the request body into {@code type} and verifies its constraints. + * + * @throws dev.relism.flash.exceptions.HttpException 400 if the body is not valid JSON + * @throws ValidationException 422 if it parses but violates a constraint + */ + public T body(Request request, Class type) throws Exception { + if (json == null) + throw new IllegalStateException( + "Validation.body(...) needs a JSON codec — install JacksonExtension, " + + "or parse yourself and call validate(...)"); + T value = json.body(request, type); + validators.get(type).verify(value); + return value; + } + + /** + * Verifies an already-constructed value. + * + * @return {@code value}, so it can be used inline + * @throws ValidationException 422 on the first type's worth of failures + */ + public T validate(T value) { + validators.get(value.getClass()).verify(value); + return value; + } + + /** + * The compiled constraints of {@code type}. Useful to pre-warm a hot DTO at boot, or to + * check whether a type declares constraints at all. + */ + public Validator forType(Class type) { + return validators.get(type); + } +} diff --git a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationException.java b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationException.java new file mode 100644 index 0000000..dac202e --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationException.java @@ -0,0 +1,39 @@ +package dev.relism.flash.ext.validation; + +import dev.relism.flash.exceptions.HttpException; + +import java.util.List; + +/** + * Raised when a value fails its constraints. Extends {@link HttpException} with status 422, so + * Flash's default exception handler renders it without this extension registering anything. + * + *

Allocated only on failure — a passing validation constructs nothing. + */ +public final class ValidationException extends HttpException { + + private final transient List violations; + + ValidationException(List violations) { + super(422, describe(violations)); + this.violations = List.copyOf(violations); + } + + /** The individual failures, in field declaration order. */ + public List violations() { + return violations; + } + + private static String describe(List violations) { + StringBuilder out = new StringBuilder(32 * violations.size()); + for (int i = 0; i < violations.size(); i++) { + if (i > 0) out.append("; "); + Violation v = violations.get(i); + out.append(v.field()).append(' ').append(v.message()); + } + return out.toString(); + } + + /** One failed constraint. */ + public record Violation(String field, String message) {} +} diff --git a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationExtension.java b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationExtension.java new file mode 100644 index 0000000..029594b --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationExtension.java @@ -0,0 +1,37 @@ +package dev.relism.flash.ext.validation; + +import dev.relism.flash.ext.jackson.Json; +import dev.relism.flash.extension.FlashContext; +import dev.relism.flash.extension.FlashExtension; +import dev.relism.flash.extension.FlashRegistrar; + +/** + * Installs request validation. + * + *

{@code
+ * FlashApp.create(8080)
+ *     .install(new JacksonExtension())
+ *     .install(new ValidationExtension())
+ *     .scan("dev.example.api");
+ * }
+ * + *

No configuration. There is nothing to tune: constraints come from the annotations already on + * your types, failures come back as 422 through Flash's default exception handler because + * {@link ValidationException} carries its own status, and the JSON codec is picked up if + * {@code flash-ext-jackson} is installed. + * + *

Install order does not matter — Flash resolves the whole service graph before any handler + * initialises. + */ +public final class ValidationExtension implements FlashExtension { + + @Override + public void configure(FlashRegistrar app, FlashContext ctx) { + ctx.supply(Validation.class, Validation::new); + + // Resolved here rather than declared as a dependency: jackson is optional, and a declared + // dependency would make it mandatory. By the time ready callbacks run the graph is + // complete, so find() sees whatever was actually installed. + ctx.onReady(() -> ctx.require(Validation.class).bindCodec(ctx.find(Json.class).orElse(null))); + } +} diff --git a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validator.java b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validator.java new file mode 100644 index 0000000..153a639 --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validator.java @@ -0,0 +1,216 @@ +package dev.relism.flash.ext.validation; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * The compiled constraints of one type. Built once per class and reused for every request. + * + *

{@link #verify} allocates nothing when a value passes: the loop walks an array (no iterator), + * reads fields through exact-signature {@link MethodHandle}s (no boxing, no argument array), and + * compares against operands resolved at compile time. The violation list and the exception are + * constructed only once something actually fails. + */ +public final class Validator { + + private static final Check[] NONE = new Check[0]; + + private final Check[] checks; + + private Validator(Check[] checks) { + this.checks = checks; + } + + /** True when the type declares no constraints at all — {@link #verify} is then a no-op. */ + public boolean isEmpty() { + return checks.length == 0; + } + + /** + * Verifies every constraint on {@code target}. + * + * @throws ValidationException with all failures, never just the first + */ + public void verify(Object target) { + List failures = null; + for (Check check : checks) { + if (passes(check, target)) continue; + if (failures == null) failures = new ArrayList<>(4); + failures.add(new ValidationException.Violation(check.field, check.message)); + } + if (failures != null) throw new ValidationException(failures); + } + + private static boolean passes(Check check, Object target) { + try { + if (check.op == Check.RANGE_PRIMITIVE) { + long value = (long) check.num.invokeExact(target); + return value >= check.lo && value <= check.hi; + } + Object value = (Object) check.ref.invokeExact(target); + // Jakarta semantics: only @NotNull rejects null; every other constraint passes it. + return switch (check.op) { + case Check.NOT_NULL -> value != null; + case Check.NOT_BLANK -> value instanceof String text && !text.isBlank(); + case Check.NOT_EMPTY -> value != null && sizeOf(value) > 0; + case Check.SIZE -> value == null || withinSize(check, value); + case Check.RANGE_BOXED -> value == null || withinRange(check, (Number) value); + case Check.EMAIL -> value == null || (value instanceof String text && isEmail(text)); + case Check.PATTERN -> value == null + || (value instanceof String text && check.pattern.matcher(text).matches()); + default -> true; + }; + } catch (Throwable failure) { + throw new IllegalStateException("Could not read " + check.field + " for validation", failure); + } + } + + private static boolean withinSize(Check check, Object value) { + int size = sizeOf(value); + return size >= check.min && size <= check.max; + } + + private static boolean withinRange(Check check, Number value) { + long asLong = value.longValue(); + return asLong >= check.lo && asLong <= check.hi; + } + + /** No copies: every branch reads a length the object already knows. */ + private static int sizeOf(Object value) { + if (value instanceof CharSequence text) return text.length(); + if (value instanceof Collection items) return items.size(); + if (value instanceof Map entries) return entries.size(); + if (value instanceof Object[] array) return array.length; + return 1; + } + + /** + * Structural check rather than a regex: {@code Pattern.matcher} allocates a matcher, an int + * array and a group array on every call, which is exactly the per-request cost this module + * exists to avoid. {@code indexOf} allocates nothing. + * + *

Accepts what a mail server would plausibly route and rejects the shapes people actually + * typo. Deliverability is the confirmation mail's job, not a validator's. + */ + private static boolean isEmail(String value) { + int at = value.indexOf('@'); + if (at <= 0 || at == value.length() - 1) return false; + if (value.indexOf('@', at + 1) >= 0) return false; + int dot = value.indexOf('.', at + 2); + return dot > 0 && dot < value.length() - 1 && value.indexOf(' ') < 0; + } + + // ── Compilation ────────────────────────────────────────────────────────── + + /** + * Compiles {@code type}'s constraints once. + * + *

Reads declared fields rather than record accessors: a constraint on a record component + * propagates to the backing field, so records and plain classes need one code path, not two. + */ + static Validator compile(Class type) { + MethodHandles.Lookup lookup; + try { + lookup = MethodHandles.privateLookupIn(type, MethodHandles.lookup()); + } catch (IllegalAccessException denied) { + throw new IllegalStateException( + "Cannot read " + type.getName() + " for validation — open its module or package", denied); + } + + List checks = new ArrayList<>(); + for (Field field : type.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers())) continue; + MethodHandle getter; + try { + getter = lookup.unreflectGetter(field); + } catch (IllegalAccessException denied) { + continue; + } + compileField(field, getter, checks); + } + return new Validator(checks.isEmpty() ? NONE : checks.toArray(new Check[0])); + } + + private static void compileField(Field field, MethodHandle getter, List checks) { + String name = field.getName(); + Class type = field.getType(); + MethodHandle ref = type.isPrimitive() ? null : asReference(getter); + + if (field.isAnnotationPresent(NotNull.class) && ref != null) + checks.add(Check.reference(Check.NOT_NULL, name, "must not be null", ref)); + + if (field.isAnnotationPresent(NotBlank.class) && ref != null) + checks.add(Check.reference(Check.NOT_BLANK, name, "must not be blank", ref)); + + if (field.isAnnotationPresent(NotEmpty.class) && ref != null) + checks.add(Check.reference(Check.NOT_EMPTY, name, "must not be empty", ref)); + + Size size = field.getAnnotation(Size.class); + if (size != null && ref != null) + checks.add(Check.size(name, sizeMessage(size), ref, size.min(), size.max())); + + Min min = field.getAnnotation(Min.class); + Max max = field.getAnnotation(Max.class); + if (min != null || max != null) { + long lo = min != null ? min.value() : Long.MIN_VALUE; + long hi = max != null ? max.value() : Long.MAX_VALUE; + String message = rangeMessage(min, max); + if (isIntegralPrimitive(type)) { + checks.add(Check.rangePrimitive(name, message, asLong(getter), lo, hi)); + } else if (Number.class.isAssignableFrom(type) && ref != null) { + checks.add(Check.rangeBoxed(name, message, ref, lo, hi)); + } + } + + if (field.isAnnotationPresent(Email.class) && ref != null) + checks.add(Check.reference(Check.EMAIL, name, "must be a well-formed email address", ref)); + + Pattern pattern = field.getAnnotation(Pattern.class); + if (pattern != null && ref != null) { + // ponytail: the one allocating check — Pattern.matcher() per call. The regex itself is + // compiled once here; swap for a structural check if a hot route ever needs it. + checks.add(Check.pattern(name, "must match " + pattern.regexp(), ref, + java.util.regex.Pattern.compile(pattern.regexp()))); + } + } + + private static boolean isIntegralPrimitive(Class type) { + return type == int.class || type == long.class || type == short.class || type == byte.class; + } + + private static MethodHandle asReference(MethodHandle getter) { + return getter.asType(MethodType.methodType(Object.class, Object.class)); + } + + private static MethodHandle asLong(MethodHandle getter) { + return getter.asType(MethodType.methodType(long.class, Object.class)); + } + + private static String sizeMessage(Size size) { + if (size.min() == 0) return "size must be at most " + size.max(); + if (size.max() == Integer.MAX_VALUE) return "size must be at least " + size.min(); + return "size must be between " + size.min() + " and " + size.max(); + } + + private static String rangeMessage(Min min, Max max) { + if (min == null) return "must be at most " + max.value(); + if (max == null) return "must be at least " + min.value(); + return "must be between " + min.value() + " and " + max.value(); + } +} diff --git a/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationOpenApiInteropTest.java b/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationOpenApiInteropTest.java new file mode 100644 index 0000000..11868e6 --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationOpenApiInteropTest.java @@ -0,0 +1,68 @@ +package dev.relism.flash.ext.validation; + +import dev.relism.flash.ext.jackson.JacksonExtension; +import dev.relism.flash.ext.openapi.APIResponse; +import dev.relism.flash.ext.openapi.ApiOperation; +import dev.relism.flash.ext.openapi.Content; +import dev.relism.flash.ext.openapi.OpenApiExtension; +import dev.relism.flash.http.ContentType; +import dev.relism.flash.models.Request; +import dev.relism.flash.models.RequestHandler; +import dev.relism.flash.models.Response; +import dev.relism.flash.routing.GET; +import dev.relism.flash.testing.FlashTest; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * Constraints are declared once and read twice: the validator enforces them, the published schema + * describes them. Nothing registers this bridge — flash-ext-openapi picks the annotations up on + * its own when they are on the classpath. + */ +class ValidationOpenApiInteropTest { + + record Account( + @NotBlank @Size(max = 40) String name, + @Email String email, + @Min(18) @Max(120) int age) {} + + @GET("/accounts") + @ApiOperation(summary = "List accounts") + @APIResponse(responseCode = "200", content = @Content(contentType = ContentType.JSON, schema = Account.class)) + public static class ListAccounts extends RequestHandler { + @Override public Object handle(Request request, Response response) { + return new Account("alice", "a@b.com", 30); + } + } + + @RegisterExtension + static FlashTest app = FlashTest.of(configured -> { + configured.install(new JacksonExtension()); + configured.install(new ValidationExtension()); + configured.install(new OpenApiExtension("/openapi", "Accounts", "1.0.0")); + configured.scan("dev.relism.flash.ext.validation"); + }); + + @Test + void constraintsAppearInTheGeneratedSchema() { + app.get("/openapi.json") + .expectStatus(200) + .expectBodyContains("\"maxLength\":40") + .expectBodyContains("\"format\":\"email\"") + .expectBodyContains("\"minimum\":18") + .expectBodyContains("\"maximum\":120"); + } + + @Test + void notBlankMarksThePropertyRequiredAndNonEmpty() { + app.get("/openapi.json") + .expectStatus(200) + .expectBodyContains("\"minLength\":1") + .expectBodyContains("\"required\":[\"name\"]"); + } +} diff --git a/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationRoutesTest.java b/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationRoutesTest.java new file mode 100644 index 0000000..f32b5e2 --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationRoutesTest.java @@ -0,0 +1,69 @@ +package dev.relism.flash.ext.validation; + +import dev.relism.flash.ext.jackson.JacksonExtension; +import dev.relism.flash.testing.FlashTest; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** The whole path: JSON in, constraints checked, status out — with no error handling wired up. */ +class ValidationRoutesTest { + + record CreateUser(@NotBlank @Size(max = 8) String name, @Email String email, @Min(18) int age) {} + + @RegisterExtension + static FlashTest app = FlashTest.of(configured -> { + configured.install(new JacksonExtension()); + configured.install(new ValidationExtension()); + + configured.ctx().onReady(() -> { + Validation validation = configured.ctx().require(Validation.class); + configured.post("/users", (req, res) -> + res.status(201).body("created:" + validation.body(req, CreateUser.class).name())); + }); + }); + + @Test + void validBodyReachesTheHandler() { + app.request().json("{\"name\":\"alice\",\"email\":\"a@b.com\",\"age\":30}").post("/users") + .expectStatus(201) + .expectBody("created:alice"); + } + + @Test + void constraintViolationBecomes422WithEveryFailureListed() { + app.request().json("{\"name\":\"\",\"email\":\"nope\",\"age\":5}").post("/users") + .expectStatus(422) + .expectHeader("Content-Type", "application/json") + .expectBodyContains("name must not be blank") + .expectBodyContains("email must be a well-formed email address") + .expectBodyContains("age must be at least 18"); + } + + @Test + void malformedJsonBecomes400NotAValidationFailure() { + app.request().json("not json").post("/users") + .expectStatus(400) + .expectBodyContains("Invalid request body"); + } + + /** Regression guard: HttpException used to reach the catch-all and come back as 500. */ + @Test + void statusCarriedByTheExceptionSurvivesToTheWire() { + assertEquals(422, app.request().json("{\"name\":\"x\",\"email\":\"a@b.com\",\"age\":1}") + .post("/users").status()); + } + + @Test + void errorBodyIsValidJsonEvenWhenTheMessageContainsQuotes() { + app.request().json("{\"name\":\"waaaaaaaaaay-too-long\",\"email\":\"a@b.com\",\"age\":30}").post("/users") + .expectStatus(422) + .expectBodyContains("\"status\":422") + .expectBodyContains("size must be at most 8"); + } +} diff --git a/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidatorTest.java b/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidatorTest.java new file mode 100644 index 0000000..bd8e1d2 --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidatorTest.java @@ -0,0 +1,117 @@ +package dev.relism.flash.ext.validation; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class ValidatorTest { + + record CreateUser( + @NotBlank @Size(max = 8) String name, + @Email String email, + @Min(18) @Max(120) int age, + @NotNull String role) {} + + record Boxed(@Min(1) Integer count) {} + + record Sized(@NotEmpty List tags, @Size(min = 2, max = 4) String code) {} + + record Patterned(@Pattern(regexp = "[a-z]+") String slug) {} + + record Plain(String anything) {} + + private static ValidationException failureOf(Object value) { + return assertThrows(ValidationException.class, () -> Validator.compile(value.getClass()).verify(value)); + } + + @Test + void aValidValuePasses() { + assertDoesNotThrow(() -> + Validator.compile(CreateUser.class).verify(new CreateUser("alice", "a@b.com", 30, "admin"))); + } + + @Test + void reportsEveryViolationNotJustTheFirst() { + ValidationException failure = failureOf(new CreateUser(" ", "nope", 5, null)); + + assertEquals(List.of("name", "email", "age", "role"), + failure.violations().stream().map(ValidationException.Violation::field).toList()); + } + + @Test + void violationsCarryFieldAndMessage() { + ValidationException failure = failureOf(new CreateUser("alice", "a@b.com", 5, "admin")); + + assertEquals(1, failure.violations().size()); + assertEquals("age", failure.violations().get(0).field()); + assertEquals("must be between 18 and 120", failure.violations().get(0).message()); + assertEquals(422, failure.status()); + assertEquals("age must be between 18 and 120", failure.getMessage()); + } + + @Test + void sizeCountsCharactersWithoutCopying() { + assertEquals("name", failureOf(new CreateUser("far-too-long", "a@b.com", 30, "x")) + .violations().get(0).field()); + } + + @Test + void onlyNotNullRejectsNull() { + // @Email, @Size and @Min all accept null per Jakarta semantics; @NotNull is the one that does not. + ValidationException failure = failureOf(new CreateUser("alice", null, 30, null)); + + assertEquals(List.of("role"), + failure.violations().stream().map(ValidationException.Violation::field).toList()); + } + + @Test + void boxedNumbersUseTheReferencePathAndTolerateNull() { + assertDoesNotThrow(() -> Validator.compile(Boxed.class).verify(new Boxed(null))); + assertEquals("count", failureOf(new Boxed(0)).violations().get(0).field()); + } + + @Test + void sizeAppliesToCollectionsAndStrings() { + assertDoesNotThrow(() -> Validator.compile(Sized.class).verify(new Sized(List.of("a"), "abc"))); + + ValidationException failure = failureOf(new Sized(List.of(), "x")); + assertEquals(List.of("tags", "code"), + failure.violations().stream().map(ValidationException.Violation::field).toList()); + } + + @Test + void patternIsAnchoredLikeJakarta() { + assertDoesNotThrow(() -> Validator.compile(Patterned.class).verify(new Patterned("abc"))); + assertEquals("slug", failureOf(new Patterned("Abc1")).violations().get(0).field()); + } + + @Test + void emailAcceptsPlausibleAddressesAndRejectsTypos() { + assertDoesNotThrow(() -> + Validator.compile(CreateUser.class).verify(new CreateUser("a", "first.last@sub.example.co", 20, "x"))); + + for (String bad : List.of("no-at", "@leading.com", "trailing@", "two@@at.com", "no dots@x", "a@b")) { + assertThrows(ValidationException.class, + () -> Validator.compile(CreateUser.class).verify(new CreateUser("a", bad, 20, "x")), + bad); + } + } + + @Test + void aTypeWithNoConstraintsCompilesToANoOp() { + Validator validator = Validator.compile(Plain.class); + + assertTrue(validator.isEmpty()); + assertDoesNotThrow(() -> validator.verify(new Plain(null))); + } +} diff --git a/flash-extensions/pom.xml b/flash-extensions/pom.xml index 0d53953..aaa38c3 100644 --- a/flash-extensions/pom.xml +++ b/flash-extensions/pom.xml @@ -24,19 +24,42 @@ flash-ext-limiter flash-ext-web-bundler flash-ext-mcp + flash-ext-validation flash-ext-scheduler flash-ext-data-core flash-ext-data-jdbc flash-ext-data-hibernate + flash-ext-cache-core + flash-ext-cache-caffeine + + dev.relism + flash-ext-validation + ${project.version} + dev.relism flash-ext-scheduler ${project.version} + + dev.relism + flash-ext-cache-core + ${project.version} + + + dev.relism + flash-ext-cache-caffeine + ${project.version} + + + com.github.ben-manes.caffeine + caffeine + ${caffeine.version} + dev.relism flash-testing diff --git a/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java b/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java index 4473359..dc4e5ae 100644 --- a/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java +++ b/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java @@ -4,6 +4,7 @@ import dev.relism.flash.extension.FlashApp; import dev.relism.flash.models.*; import dev.relism.flash.routing.routers.fastpathrouter.FastPathRouterImpl; import dev.relism.flash.Flash; +import dev.relism.flash.exceptions.HttpException; import dev.relism.flash.http.ContentType; import dev.relism.flash.http.HttpMethod; import dev.relism.flash.template.ErrorPages; @@ -56,17 +57,56 @@ public abstract class AbstractRouter { protected ExceptionHandler exceptionHandler = Flash.DEV ? (ex, req, res) -> { + if (ex instanceof HttpException http) return renderHttpException(http, res); res.status(500); res.type(ContentType.TEXT_HTML); return ErrorPages.renderException(req, ex); } : (ex, req, res) -> { + if (ex instanceof HttpException http) return renderHttpException(http, res); log.error("Unhandled exception in {} {}", req.method(), req.path(), ex); res.status(500); res.type(ContentType.JSON); return JSON_500; }; + /** + * {@link HttpException} carries the status the caller meant; without this it reached the + * catch-all above and every one of them came back as 500 — including the 400s + * {@code RequestHelper} and {@code flash-ext-jackson} raise for malformed input. + * + *

Deliberately not pre-encoded like {@link #JSON_404}: the message is per-exception, and + * an error path that already unwound a stack does not need the allocation shaved. + */ + private static byte[] renderHttpException(HttpException failure, Response res) { + res.status(failure.status()); + res.type(ContentType.JSON); + String message = failure.getMessage(); + StringBuilder out = new StringBuilder(48 + (message == null ? 0 : message.length())); + out.append("{\"error\":\""); + escapeJson(message == null ? "" : message, out); + out.append("\",\"status\":").append(failure.status()).append('}'); + return out.toString().getBytes(StandardCharsets.UTF_8); + } + + /** Minimal RFC 8259 string escaping — enough for an exception message. */ + private static void escapeJson(String text, StringBuilder out) { + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + switch (c) { + case '"' -> out.append("\\\""); + case '\\' -> out.append("\\\\"); + case '\n' -> out.append("\\n"); + case '\r' -> out.append("\\r"); + case '\t' -> out.append("\\t"); + default -> { + if (c < 0x20) out.append(String.format("\\u%04x", (int) c)); + else out.append(c); + } + } + } + } + public SimpleHandler getNotFoundHandler() { return notFoundHandler; } public ExceptionHandler getExceptionHandler() { return exceptionHandler; } diff --git a/flash/src/test/java/dev/relism/flash/routing/AbstractRouterTest.java b/flash/src/test/java/dev/relism/flash/routing/AbstractRouterTest.java index ede5000..9871486 100644 --- a/flash/src/test/java/dev/relism/flash/routing/AbstractRouterTest.java +++ b/flash/src/test/java/dev/relism/flash/routing/AbstractRouterTest.java @@ -1,5 +1,7 @@ package dev.relism.flash.routing; +import dev.relism.flash.exceptions.HttpException; +import dev.relism.flash.http.ContentType; import dev.relism.flash.http.HttpMethod; import dev.relism.flash.models.Request; import dev.relism.flash.models.RequestHandler; @@ -7,6 +9,8 @@ import dev.relism.flash.models.Response; import dev.relism.flash.models.SimpleHandler; import org.junit.jupiter.api.Test; +import java.nio.charset.StandardCharsets; + import static org.junit.jupiter.api.Assertions.*; class AbstractRouterTest { @@ -77,4 +81,23 @@ class AbstractRouterTest { router.onException((ex, req, res) -> "Caught"); assertEquals("Caught", router.getExceptionHandler().handle(new RuntimeException(), null, null)); } + + + /** + * HttpException carries the status the caller meant. Before this was honoured every one of + * them came back as 500, including the 400s RequestHelper and flash-ext-jackson raise. + */ + @Test + void defaultExceptionHandlerHonoursHttpExceptionStatus() throws Exception { + DummyRouter router = new DummyRouter(); + Response res = new Response(200, ContentType.TEXT_PLAIN); + + Object body = router.getExceptionHandler() + .handle(HttpException.badRequest("bad \"input\""), null, res); + + assertEquals(400, res.getStatusCode()); + String rendered = new String((byte[]) body, StandardCharsets.UTF_8); + assertTrue(rendered.contains("\\\"input\\\""), "message must be JSON-escaped: " + rendered); + assertTrue(rendered.contains("\"status\":400"), rendered); + } } diff --git a/pom.xml b/pom.xml index d3743e1..3f35318 100644 --- a/pom.xml +++ b/pom.xml @@ -38,6 +38,8 @@ 2.18.0 1.37 5.11.0 + 3.1.8 + 3.1.1 3.6.0 @@ -67,11 +69,31 @@ flash-testing ${project.version} + + dev.relism + flash-ext-validation + ${project.version} + + + jakarta.validation + jakarta.validation-api + ${jakarta.validation.version} + dev.relism flash-ext-scheduler ${project.version} + + dev.relism + flash-ext-cache-core + ${project.version} + + + dev.relism + flash-ext-cache-caffeine + ${project.version} + dev.relism flash-ext-jackson