Merge branch 'master' into feature/ext-scheduler/cron-and-interval-jobs
Resolve conflicts in AGENTS.md, pom.xml and flash-extensions/pom.xml — master had already picked up ext-validation and ext-cache-core/caffeine (merged after this branch was cut); union kept alongside this branch's ext-scheduler entries. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -38,7 +38,8 @@ Format: `<type>(<scope>): <short description>`
|
|||||||
|
|
||||||
Allowed scopes: `core`, `testing`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`,
|
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-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:
|
Examples:
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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-core` | Minimal shared SSR runtime primitives |
|
||||||
| `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension |
|
| `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension |
|
||||||
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf 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
|
## 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-mcp`](flash-extensions/flash-ext-mcp/docs/README.md)
|
||||||
- [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/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-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)
|
- [`flash-testing`](flash-testing/docs/README.md)
|
||||||
|
|
||||||
## Error handlers
|
## Error handlers
|
||||||
|
|||||||
@@ -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
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-cache-caffeine</artifactId>
|
||||||
|
<version>${flash.version}</version>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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<String, User> 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.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-extensions</artifactId>
|
||||||
|
<version>2.1.0-SNAPSHOT</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>flash-ext-cache-caffeine</artifactId>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-cache-core</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!--
|
||||||
|
Caffeine rather than a hand-rolled LRU: W-TinyLFU admission, striped counters and
|
||||||
|
amortised eviction are not a weekend's work to get right, and getting them wrong is a
|
||||||
|
cache that is slower than no cache.
|
||||||
|
-->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||||
|
<artifactId>caffeine</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-testing</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
+41
@@ -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<K, V> implements Cache<K, V> {
|
||||||
|
|
||||||
|
private final com.github.benmanes.caffeine.cache.Cache<K, V> delegate;
|
||||||
|
private final boolean statsRecorded;
|
||||||
|
|
||||||
|
CaffeineCache(com.github.benmanes.caffeine.cache.Cache<K, V> delegate, boolean statsRecorded) {
|
||||||
|
this.delegate = delegate;
|
||||||
|
this.statsRecorded = statsRecorded;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public V get(K key, Function<? super K, ? extends V> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -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.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* FlashApp.create(8080)
|
||||||
|
* .install(new CaffeineCacheExtension())
|
||||||
|
* .scan("dev.example.api");
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -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<String, Entry> caches = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public <K, V> Cache<K, V> build(String name, java.util.function.Consumer<CacheSpec> 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<K, V>) entry.cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public <K, V> Cache<K, V> cache(String name) {
|
||||||
|
Entry entry = caches.get(name);
|
||||||
|
return entry == null ? null : (Cache<K, V>) entry.cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Set<String> 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<Object, Object> create(CacheSpec spec) {
|
||||||
|
Caffeine<Object, Object> 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<Object, Object> cache) {}
|
||||||
|
}
|
||||||
+160
@@ -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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> cache = manager().build("nulls", spec -> spec.maxSize(10));
|
||||||
|
|
||||||
|
assertNull(cache.get("missing", key -> null));
|
||||||
|
assertNull(cache.getIfPresent("missing"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidateDropsOneKeyAndInvalidateAllDropsEverything() {
|
||||||
|
Cache<String, String> 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<String, String> first = manager().build("shared", spec -> spec.maxSize(10));
|
||||||
|
Cache<String, String> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<K, V>` — 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.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-extensions</artifactId>
|
||||||
|
<version>2.1.0-SNAPSHOT</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>flash-ext-cache-core</artifactId>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
+50
@@ -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.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* User user = users.get(id, repo::findById);
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p>{@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 <K> key type — must have a stable {@code hashCode}/{@code equals}
|
||||||
|
* @param <V> value type
|
||||||
|
*/
|
||||||
|
public interface Cache<K, V> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the cached value, computing and storing it with {@code loader} if absent.
|
||||||
|
*
|
||||||
|
* <p>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<? super K, ? extends V> 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();
|
||||||
|
}
|
||||||
Vendored
+31
@@ -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.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* @Override protected void onInit() {
|
||||||
|
* users = require(CacheManager.class).build("users", spec -> spec
|
||||||
|
* .maxSize(10_000)
|
||||||
|
* .ttl(Duration.ofMinutes(10)));
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p>{@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. */
|
||||||
|
<K, V> Cache<K, V> build(String name, Consumer<CacheSpec> spec);
|
||||||
|
|
||||||
|
/** The named cache, or {@code null} if {@link #build} has not been called for it. */
|
||||||
|
<K, V> Cache<K, V> cache(String name);
|
||||||
|
|
||||||
|
/** Every cache name built so far, for an ops endpoint. */
|
||||||
|
java.util.Set<String> names();
|
||||||
|
}
|
||||||
Vendored
+64
@@ -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}.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* CacheSpec.of().maxSize(10_000).ttl(Duration.ofMinutes(10))
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p>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()}.
|
||||||
|
*
|
||||||
|
* <p>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; }
|
||||||
|
}
|
||||||
Vendored
+20
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -29,6 +29,15 @@
|
|||||||
<groupId>org.projectlombok</groupId>
|
<groupId>org.projectlombok</groupId>
|
||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!--
|
||||||
|
Annotations only, and optional: when present the generated schema mirrors the
|
||||||
|
constraints; when absent ConstraintHints is never loaded and nothing changes.
|
||||||
|
-->
|
||||||
|
<dependency>
|
||||||
|
<groupId>jakarta.validation</groupId>
|
||||||
|
<artifactId>jakarta.validation-api</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.junit.jupiter</groupId>
|
<groupId>org.junit.jupiter</groupId>
|
||||||
<artifactId>junit-jupiter</artifactId>
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
|||||||
+84
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<String, Object> 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<String> supported() {
|
||||||
|
return List.of("@NotNull", "@NotBlank", "@NotEmpty", "@Size", "@Min", "@Max", "@Email", "@Pattern");
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-1
@@ -365,6 +365,9 @@ public final class OpenApiBuilder {
|
|||||||
return null;
|
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 class SchemaRegistry {
|
||||||
private static final Set<Class<?>> SIMPLE = Set.of(
|
private static final Set<Class<?>> SIMPLE = Set.of(
|
||||||
String.class, CharSequence.class,
|
String.class, CharSequence.class,
|
||||||
@@ -476,8 +479,14 @@ public final class OpenApiBuilder {
|
|||||||
if (jp.access() == Access.WRITE_ONLY) property.put("writeOnly", true);
|
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);
|
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);
|
if (!properties.isEmpty()) out.put("properties", properties);
|
||||||
|
|||||||
@@ -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
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-validation</artifactId>
|
||||||
|
<version>${flash.version}</version>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-extensions</artifactId>
|
||||||
|
<version>2.1.0-SNAPSHOT</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>flash-ext-validation</artifactId>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!--
|
||||||
|
Annotations only (~90 KB). Hibernate Validator's engine is deliberately absent: it
|
||||||
|
resolves constraints reflectively per call and pulls ~2 MB plus EL. This module
|
||||||
|
compiles the same annotations into a flat check table once per class instead.
|
||||||
|
-->
|
||||||
|
<dependency>
|
||||||
|
<groupId>jakarta.validation</groupId>
|
||||||
|
<artifactId>jakarta.validation-api</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- Only needed for Validation.body(...); check(...) works without it. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-jackson</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-testing</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<!-- Interop only: proves constraints reach the published schema. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-openapi</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
+76
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
@@ -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)}.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* CreateUser dto = validation.body(req, CreateUser.class); // parse + verify
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p>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<Validator> 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> T body(Request request, Class<T> 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> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>Allocated only on failure — a passing validation constructs nothing.
|
||||||
|
*/
|
||||||
|
public final class ValidationException extends HttpException {
|
||||||
|
|
||||||
|
private final transient List<Violation> violations;
|
||||||
|
|
||||||
|
ValidationException(List<Violation> violations) {
|
||||||
|
super(422, describe(violations));
|
||||||
|
this.violations = List.copyOf(violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The individual failures, in field declaration order. */
|
||||||
|
public List<Violation> violations() {
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String describe(List<Violation> 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) {}
|
||||||
|
}
|
||||||
+37
@@ -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.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* FlashApp.create(8080)
|
||||||
|
* .install(new JacksonExtension())
|
||||||
|
* .install(new ValidationExtension())
|
||||||
|
* .scan("dev.example.api");
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
+216
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>{@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<ValidationException.Violation> 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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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<Check> 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<Check> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -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\"]");
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+117
@@ -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<String> 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,19 +24,42 @@
|
|||||||
<module>flash-ext-limiter</module>
|
<module>flash-ext-limiter</module>
|
||||||
<module>flash-ext-web-bundler</module>
|
<module>flash-ext-web-bundler</module>
|
||||||
<module>flash-ext-mcp</module>
|
<module>flash-ext-mcp</module>
|
||||||
|
<module>flash-ext-validation</module>
|
||||||
<module>flash-ext-scheduler</module>
|
<module>flash-ext-scheduler</module>
|
||||||
<module>flash-ext-data-core</module>
|
<module>flash-ext-data-core</module>
|
||||||
<module>flash-ext-data-jdbc</module>
|
<module>flash-ext-data-jdbc</module>
|
||||||
<module>flash-ext-data-hibernate</module>
|
<module>flash-ext-data-hibernate</module>
|
||||||
|
<module>flash-ext-cache-core</module>
|
||||||
|
<module>flash-ext-cache-caffeine</module>
|
||||||
</modules>
|
</modules>
|
||||||
|
|
||||||
<dependencyManagement>
|
<dependencyManagement>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-validation</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash-ext-scheduler</artifactId>
|
<artifactId>flash-ext-scheduler</artifactId>
|
||||||
<version>${project.version}</version>
|
<version>${project.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-cache-core</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-cache-caffeine</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||||
|
<artifactId>caffeine</artifactId>
|
||||||
|
<version>${caffeine.version}</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash-testing</artifactId>
|
<artifactId>flash-testing</artifactId>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import dev.relism.flash.extension.FlashApp;
|
|||||||
import dev.relism.flash.models.*;
|
import dev.relism.flash.models.*;
|
||||||
import dev.relism.flash.routing.routers.fastpathrouter.FastPathRouterImpl;
|
import dev.relism.flash.routing.routers.fastpathrouter.FastPathRouterImpl;
|
||||||
import dev.relism.flash.Flash;
|
import dev.relism.flash.Flash;
|
||||||
|
import dev.relism.flash.exceptions.HttpException;
|
||||||
import dev.relism.flash.http.ContentType;
|
import dev.relism.flash.http.ContentType;
|
||||||
import dev.relism.flash.http.HttpMethod;
|
import dev.relism.flash.http.HttpMethod;
|
||||||
import dev.relism.flash.template.ErrorPages;
|
import dev.relism.flash.template.ErrorPages;
|
||||||
@@ -56,17 +57,56 @@ public abstract class AbstractRouter {
|
|||||||
|
|
||||||
protected ExceptionHandler exceptionHandler = Flash.DEV
|
protected ExceptionHandler exceptionHandler = Flash.DEV
|
||||||
? (ex, req, res) -> {
|
? (ex, req, res) -> {
|
||||||
|
if (ex instanceof HttpException http) return renderHttpException(http, res);
|
||||||
res.status(500);
|
res.status(500);
|
||||||
res.type(ContentType.TEXT_HTML);
|
res.type(ContentType.TEXT_HTML);
|
||||||
return ErrorPages.renderException(req, ex);
|
return ErrorPages.renderException(req, ex);
|
||||||
}
|
}
|
||||||
: (ex, req, res) -> {
|
: (ex, req, res) -> {
|
||||||
|
if (ex instanceof HttpException http) return renderHttpException(http, res);
|
||||||
log.error("Unhandled exception in {} {}", req.method(), req.path(), ex);
|
log.error("Unhandled exception in {} {}", req.method(), req.path(), ex);
|
||||||
res.status(500);
|
res.status(500);
|
||||||
res.type(ContentType.JSON);
|
res.type(ContentType.JSON);
|
||||||
return JSON_500;
|
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.
|
||||||
|
*
|
||||||
|
* <p>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 SimpleHandler getNotFoundHandler() { return notFoundHandler; }
|
||||||
public ExceptionHandler getExceptionHandler() { return exceptionHandler; }
|
public ExceptionHandler getExceptionHandler() { return exceptionHandler; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package dev.relism.flash.routing;
|
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.http.HttpMethod;
|
||||||
import dev.relism.flash.models.Request;
|
import dev.relism.flash.models.Request;
|
||||||
import dev.relism.flash.models.RequestHandler;
|
import dev.relism.flash.models.RequestHandler;
|
||||||
@@ -7,6 +9,8 @@ import dev.relism.flash.models.Response;
|
|||||||
import dev.relism.flash.models.SimpleHandler;
|
import dev.relism.flash.models.SimpleHandler;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
class AbstractRouterTest {
|
class AbstractRouterTest {
|
||||||
@@ -77,4 +81,23 @@ class AbstractRouterTest {
|
|||||||
router.onException((ex, req, res) -> "Caught");
|
router.onException((ex, req, res) -> "Caught");
|
||||||
assertEquals("Caught", router.getExceptionHandler().handle(new RuntimeException(), null, null));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@
|
|||||||
<maven.versions.plugin.version>2.18.0</maven.versions.plugin.version>
|
<maven.versions.plugin.version>2.18.0</maven.versions.plugin.version>
|
||||||
<jmh.version>1.37</jmh.version>
|
<jmh.version>1.37</jmh.version>
|
||||||
<junit.version>5.11.0</junit.version>
|
<junit.version>5.11.0</junit.version>
|
||||||
|
<caffeine.version>3.1.8</caffeine.version>
|
||||||
|
<jakarta.validation.version>3.1.1</jakarta.validation.version>
|
||||||
<build.helper.plugin.version>3.6.0</build.helper.plugin.version>
|
<build.helper.plugin.version>3.6.0</build.helper.plugin.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
@@ -67,11 +69,31 @@
|
|||||||
<artifactId>flash-testing</artifactId>
|
<artifactId>flash-testing</artifactId>
|
||||||
<version>${project.version}</version>
|
<version>${project.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-validation</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>jakarta.validation</groupId>
|
||||||
|
<artifactId>jakarta.validation-api</artifactId>
|
||||||
|
<version>${jakarta.validation.version}</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash-ext-scheduler</artifactId>
|
<artifactId>flash-ext-scheduler</artifactId>
|
||||||
<version>${project.version}</version>
|
<version>${project.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-cache-core</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-cache-caffeine</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash-ext-jackson</artifactId>
|
<artifactId>flash-ext-jackson</artifactId>
|
||||||
|
|||||||
Reference in New Issue
Block a user