76 Commits
Author SHA1 Message Date
Zakaria El OrcheandClaude Opus 5 58bae41f7a docs(testing): document flash-testing and the limits it deliberately keeps
README covers the application handle, requests and assertions, service
replacement, multi-server wiring, scope, WebSockets, configuration and the
teardown ordering.

limits.md records the seven things the harness cannot do and what to use for
each: TLS, HTTP/2, WebSocket over HTTP/2, malformed requests, response framing,
the flash core module's dependency cycle, and scoped services. Each is a
consequence of a real constraint rather than an unfinished feature, so writing
them down stops the next person rediscovering them one at a time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 12:07:55 +00:00
Zakaria El OrcheandClaude Opus 5 7785712efe test(ext-web-bundler): migrate integration test to flash-testing
Two frontend layouts, each laid out on disk inside its own application's
configure(). The harness runs that lazily at first access, so @TempDir is
populated by then and the server for whichever test is not running never boots.

Replaces three blocks of HttpRequest.newBuilder(URI.create(...)) per test with
single-line assertions. 98 to 60 code lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 11:05:06 +00:00
Zakaria El OrcheandClaude Opus 5 c02459dd7c test(ext-mcp): migrate integration and security suites to flash-testing
McpExtensionIntegrationTest: 10 stateless JSON-RPC calls against one server
config, so one class-scoped server replaces a boot per test. 117 to 85 code
lines.

McpExtensionSecurityTest: the four server configurations it exercises — AUTO
without oidc, REQUIRED with a derived resource identifier, REQUIRED with an
explicit one, and REQUIRED with advertised scopes — become four named servers
sharing one FakeOidcProvider, replacing eight boots and a freePort() helper.
151 to 128 code lines. The boot-rejection test still builds its app directly:
a harness whose job is to boot an app is the wrong tool for asserting that
booting fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 11:05:06 +00:00
Zakaria El OrcheandClaude Opus 5 e0ad83eb2f test(core): read bound ports back from the app instead of guessing free ones
Every integration test picked a port by opening ServerSocket(0), closing it and
reusing the number, which races anything else on the machine between the close
and the rebind. FlashApp.port() reports the port the listener actually bound,
so the guess is gone: 18 freePort() helpers deleted, 40 call sites now pass
port(0) and read the result back.

Two ServerSocket(0) uses remain and are correct. ConnectionRunnerTest accepts
on its socket rather than using it to pick a number. H2LoadMeasurementTest
hands its port to an external nghttpd process, which has no equivalent of
port() to read back; that one is now commented to say so.

HttpServerTlsTest's two-listener case reads both back through ports().

HttpServerTest and HttpServerConcurrencyTest also move from @BeforeEach to
@BeforeAll — every test in them is read-only against the same routes, so 11 and
3 boots respectively become 1. HttpServerConcurrencyTest's lazy-compile test
keeps building its own app, since a freshly compiled router is the thing it
tests. HttpServerTest now runs 11 tests in 0.06s.

The http2 interop suites (curl, nghttp, grpcurl, h2spec), the load measurement
and the soak test are skipped without their external binaries or system
property, so those edits are compile-verified here and exercised in CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 11:05:06 +00:00
Zakaria El OrcheandClaude Opus 5 1c207cf94c feat(testing): boot lazily instead of eagerly in beforeEach
beforeEach called ensureStarted(), so every FlashTest field in a class booted
for every test whether or not that test touched it — a class holding four
servers paid for four boots per test. Booting is already lazy on first access,
so the hook was only ever forcing work forward.

Neither hook starts anything now. beforeAll still records that a static field
owns the class-scoped lifecycle, which is what keeps afterEach from tearing a
class-scoped server down after the first test.

This also lets an application read @TempDir inside configure(): JUnit populates
those during instance post-processing, before the first test body but after
extension beforeEach callbacks would have fired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 11:05:06 +00:00
Zakaria El OrcheandClaude Opus 5 fa5a025c93 test(ext-mcp): migrate McpAuthPolicyTest to flash-testing
First migration, chosen because it is the case that drove the harness design:
a Flash app plus a FakeOidcProvider, with tokens audience-bound to the app's
own port, so the port has to be readable after boot.

Drops the racy free-port dance, the hand-rolled HttpClient plumbing and the
per-test teardown; failures now report the response body. 120 to 106 code
lines, and what remains is tool-policy assertions rather than fixture code.

The two boot-rejection tests keep building their app directly — a harness whose
job is to boot an app is the wrong tool for asserting that booting fails — but
port(0) removes freePort() from those too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 10:22:40 +00:00
Zakaria El OrcheandClaude Opus 5 424ca31b7a feat(testing): add flash-testing, a JUnit 5 harness for Flash applications
Boots a real app on an OS-assigned port for a test class or a single test and
hands back a client pointed at it:

    @RegisterExtension
    static FlashTest app = FlashTest.of(new BlogApp())
            .mock(UserService.class, new InMemoryUserService());

    app.get("/api/users").expectStatus(200).expectBodyContains("alice");

A field rather than an annotation, because annotation values are compile-time
constants and so could never express a second server wired from the first —
FlashTest.of(new BlogApp(auth.baseUri())). Startup is lazy, so reading
baseUri() boots that server on the spot and declaration order does the wiring,
with no dependence on JUnit's extension ordering. A static field boots once per
class, a non-static field once per test; that is stock JUnit field semantics
rather than an option to configure.

Runs against a real loopback port instead of dispatching in-process. An
in-process dispatcher would be a third copy of the routing/handler/exception
sequence that Http1Connection and Http2StreamDispatcher already duplicate, kept
in sync by hand, and it would let a test pass while the status line,
content-length or HPACK encoding was broken.

mock() installs overrides as the last extension, after everything the
application and its extensions declare, so a fake always wins. Any object is
accepted, so a hand-written fake and a Mockito mock are equally welcome and
this module depends on no mocking library — only flash and junit-jupiter-api.

Teardown cancels the client before stopping the server: HttpClient holds
keep-alive sockets open and ServerLifecycle.stop() spins until the last one
closes, so the default 15s drain would otherwise be paid on every test class.
shutdownNow rather than close(), which blocks until every operation completes
and would hang on a leaked WebSocket.

Lives at the top level, not under flash-extensions/, which holds things you
install() onto an app; this carries junit-jupiter-api at compile scope and
nothing installable should.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 10:22:40 +00:00
Zakaria El OrcheandClaude Opus 5 4a85a27648 feat(core): expose bound ports, service override, FlashApplication and close hooks
Four small seams, each useful on its own, that together make a Flash app
testable without hand-rolled scaffolding.

- ServerHandle/ServerLifecycle/FlashApp gain port()/ports(). Listeners already
  bind in the FlashApp constructor, so port(0) resolved to a real port that
  nothing could read back; every integration test worked around this by
  opening a ServerSocket(0), closing it and reusing the number, which races
  anything else on the machine.

- FlashContext.override() replaces a binding instead of rejecting it. The
  duplicate-is-an-error rule stays everywhere else; this is the single
  deliberate exception, for swapping a service out in tests. A replacement is
  logged at INFO so misuse in production is visible.

- FlashApplication + FlashApp.apply() name an application independently of the
  port it runs on, so the same one can be booted twice. It takes FlashApp
  rather than FlashRegistrar because ws() and mount() live there. Being a
  functional interface, a lambda and a named class are the same thing.

- FlashContext.onClose() runs cleanup at stop(), children first and then in
  reverse registration order. stop() previously closed sockets and the
  executor and never touched the service graph, so a pooled DataSource was
  only ever released by JVM exit — invisible with one app per process, a leak
  per test class once a suite boots and stops many.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 10:22:23 +00:00
Zakaria El OrcheandClaude Sonnet 5 74169e40f4 feat(core): add any() route registration, warn on undocumented OpenAPI routes, log unhandled 500s
Publish Maven packages / publish (push) Failing after 3m35s
FlashRegistrar#any() registers a handler under every HTTP method for
verb-indifferent handlers (e.g. a reverse proxy). OpenApiExtension now warns
when a class-based handler has no @ApiOperation instead of silently omitting
it from the spec. AbstractRouter's default production exception handler now
logs unhandled exceptions server-side instead of only returning a JSON 500.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 11:56:51 +00:00
Zakaria El OrcheandClaude Sonnet 5 3d743b34ce fix(build): resolve fpr-core from Gitea, not the dead maven.relism.dev
Publish Maven packages / publish (push) Successful in 2m3s
maven.relism.dev is down. fpr-core's source (FastPathRouter) now publishes
to Gitea's own Maven registry via its own publish-maven.yml, same pattern
this project already uses for itself — point <repositories> there and bump
the pinned fpr-core version to the first build actually published there
(1.1.0-36216cd). Full reactor build + test suite green against the new
resolution.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 11:10:50 +00:00
Relism f8e0a1d3fa Merge pull request 'fix(http2): close a streaming response body on every exit path' (#13) from hotfix/http2-response-writer-stream-leak into master
Publish Maven packages / publish (push) Successful in 2m9s
Reviewed-on: #13
2026-08-14 23:02:30 +00:00
Relism 5954cac66c Merge pull request 'fix(websocket): mask outgoing CLOSE frames in client mode' (#12) from hotfix/ws-close-frame-masking into master
Publish Maven packages / publish (push) Canceled after 7s
Reviewed-on: #12
2026-08-14 23:02:12 +00:00
Zakaria El OrcheandClaude Sonnet 5 e5bec59410 fix(http2): close a streaming response body on every exit path
Http2ResponseWriter.streamBody (a streaming response's InputStream, driven
across startFlowControlled/resume as flow-control windows allow) was never
closed anywhere -- not on clean EOF, not on a write failure, not when the
stream is abandoned (RST_STREAM from the peer, connection teardown). Same
bug Http1ResponseWriter had before cf16be0, just never given the same fix: a
handler stream that releases a held resource (a pooled backend connection,
for a reverse proxy) from close() leaks it under any real amount of stream
resets or aborted connections.

- appendData closes streamBody once `end` is reached (clean completion) and
  on any IOException from the read itself, mirroring Http1ResponseWriter's
  relayAndClose/writeChunkedAndClose reasoning.
- New Http2ResponseWriter#abort(), called from Http2Stream#cancel() --
  symmetric with that method's existing http2Body.cancel() for the inbound
  leg, now covering the outbound one too. cancel() is the single hook every
  abandoned-stream path (RST_STREAM handling and connection teardown in
  Http2Connection, plus Http2StreamDispatcher) already goes through, so this
  covers every abort case without adding a new one.

closeStreamBodyQuietly() is idempotent (nulls streamBody after closing), so
the appendData and abort() close paths can't double-close or race.

Four new Http2ResponseWriterTest cases: normal completion in one call,
completion across a resume() (multiple flow-control windows), abort() while
still streaming, and abort() as a no-op on a non-streaming response. 697/697
flash-module tests green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LLwcyHUnbApCrY33gvgoa
2026-08-14 22:51:16 +00:00
Zakaria El OrcheandClaude Sonnet 5 901da954a9 fix(websocket): mask outgoing CLOSE frames in client mode
WebSocketSession.close(int) hand-wrote a raw, always-unmasked 4-byte CLOSE
frame, bypassing writeFrame's maskOutgoing handling that sendText/send/
sendPong already go through correctly. A client-mode session (maskOutgoing
true — WS-client usage, e.g. Pathway's UpstreamWebSocketConnector relaying a
proxied client's close to a backend) therefore sent an RFC-6455-invalid
unmasked frame.

This was latent until this same HTTP/2 branch's readFrame rewrite added the
receive-side masking check RFC 6455 §5.1 requires: a strict peer now rejects
the malformed frame with WebSocketProtocolException("client frame must be
masked") before ever exposing it as a CLOSE, silently dropping the close
instead of relaying it — reproduced end to end via Pathway's
ProxyWebSocketIntegrationTest.clientCloseIsForwardedToTheBackend.

close(int) now builds its 2-byte payload and calls the same writeFrame path
every other outgoing frame uses, so masking (or not) follows maskOutgoing
automatically. Existing server-mode close_setsClosedAndWritesFrame is
unchanged (byte-for-byte identical output — no mask bit, no key). Added
close_masksWhenActingAsClient (mirrors the existing sendText coverage) and a
round-trip regression, readFrame_acceptsCloseFrameWrittenByAClientSession,
that reproduces the actual bug: a client session's close() output fed
straight into a server session's readFrame().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LLwcyHUnbApCrY33gvgoa
2026-08-14 22:46:59 +00:00
Relism 787ae610d4 Merge pull request 'fix(ext): update stale HeaderMap references to Http1HeaderMap' (#11) from hotfix/header-map-rename into master
Publish Maven packages / publish (push) Successful in 2m36s
Reviewed-on: #11
2026-08-14 18:27:13 +00:00
Zakaria El OrcheandClaude Sonnet 5 b5ff5a4c1f fix(ext): update stale HeaderMap references to Http1HeaderMap
HeaderMap was split into the HeaderView interface and Http1HeaderMap
impl in the HTTP/2 model refactor, but two extension-module tests
(flash-ext-jackson, flash-ext-view-jte) still referenced the old
class name, breaking the CI build on master after the HTTP/2 PR merge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JCmqb7XGb3JQvCWf589yma
2026-08-14 18:25:42 +00:00
Relism 2078dea54f Merge pull request 'feat(core): HTTP/2 support, correctness fixes, and doc reorganization' (#10) from feature/core/http2 into master
Publish Maven packages / publish (push) Failing after 53s
Reviewed-on: #10
2026-08-14 18:20:30 +00:00
Zakaria El Orche a0dda8e47a refactor(core): remove out-of-scope HTTP/2 client/proxy, reorganize docs, refresh README
HttpProxy and Http2Client (719 LOC) shipped a reverse-proxy adapter and outbound HTTP/2
client from flash core with zero callers anywhere in the server itself — only each
other and their own tests. An HTTP/1.1+2 server framework has no business bundling an
outbound client; that capability belongs in its own flash-extensions/flash-ext-*
module if/when it's needed. Removed, along with the now-dead src/bench load driver
that depended on Http2Client (no replacement client written here — flagged as
follow-up work, not silently dropped).

docs/http2/ had accumulated core, cross-protocol documentation alongside genuine
HTTP/2-protocol internals: HTTP1-HARDENING, TRANSPORT, MESSAGE-MODEL,
TRAILERS-AND-STREAMING and BYTES all describe machinery HTTP/1.1 and HTTP/2 share, not
HTTP/2 specifically. Moved to a new docs/core/, leaving docs/http2/ to the protocol
layers, wire internals and operational docs that are actually HTTP/2-specific.
CLEARTEXT-AND-PROXY.md renamed to CLEARTEXT.md and its now-removed upstream-client
section cut, matching the source removal above.

README.md: removed the "HTTP/2 upstream proxy" section (documented the deleted
HttpProxy/Http2Client), the flash-bench module row and build command (not a module
that exists in this repo), and fixed every doc link to the new docs/core/ paths.
Added the new FlashConfiguration.maxConnections field to the configuration reference.

src/bench/ (a load-test harness distinct from the JMH suite, not wired into any Maven
profile or CI) is committed here for the first time.
2026-08-14 18:13:03 +00:00
Zakaria El Orche cf16be08c0 fix(core): fix HTTP/2 rate-limiter false positives, connection-flood OOM, and a streaming-body leak
Targeted stress testing under this branch's HTTP/2 work surfaced four independent
production bugs, each verified with a before/after load test and a regression test:

- Http2Limits.MAX_STREAMS_CREATED_PER_INTERVAL (400/10s) rejected legitimate
  high-concurrency HTTP/2 clients as if they were CVE-2023-44487 rapid-reset abuse —
  h2load's default pattern alone triggered 40-92% request failure. Raised to 100,000,
  matching MAX_STREAMS_PER_CONNECTION's existing lifetime budget; the RST_STREAM-rate
  counter remains the precise defence against the actual attack signature.

- Flash had no connection-admission control anywhere: AcceptLoop accepted every TCP
  connection unconditionally, so a connection flood (h2load -c 400) ran the JVM out of
  heap and crashed with OutOfMemoryError, killing even unrelated daemon threads.
  TransportLimits.defaultMaxConnections() auto-scales a cap from Runtime.maxMemory();
  ConnectionRunner.accept() enforces it before any per-connection state (TLS handshake
  included) is created. Verified surviving 42x the admission limit under both cleartext
  and TLS load with bounded RSS.

- Http1ResponseWriter never closed a handler's streaming response body on a write
  failure (e.g. the client disconnecting mid-transfer) — only on a clean EOF. A handler
  whose stream releases a held resource (a pooled backend connection, for a reverse
  proxy) from close() leaks it under any real amount of client disconnects. Now closed
  on every exit path, matching InputStream#close()'s own idempotency contract.

- Http2StreamState.transition() called the enum's values() every state transition;
  values() clones a fresh array on every call. Cached once, removing ~10.76% of
  allocations measured live under load.

695 -> 698 tests (three new regression tests), all passing.
2026-08-14 18:12:46 +00:00
Zakaria El Orche 825bdfc942 docs(core): document HTTP/2 operation and architecture 2026-08-13 21:39:37 +00:00
Zakaria El Orche 3679eed74a feat(core): add HTTP/2 performance gates 2026-08-13 21:29:21 +00:00
Zakaria El Orche 6386264a1e test(core): add HTTP/2 compliance suite 2026-08-13 20:51:35 +00:00
Zakaria El Orche f3011ffdf6 feat(core): add WebSocket over HTTP/2 2026-08-13 20:18:05 +00:00
Zakaria El Orche 3c1eb0d0df feat(core): add HTTP/2 cleartext proxy support 2026-08-13 20:00:59 +00:00
Zakaria El Orche 5755ef77fe feat(core): harden HTTP/2 abuse resistance 2026-08-13 19:40:52 +00:00
Zakaria El Orche ee90ac44ff feat(core): add HTTP trailers and push streaming 2026-08-13 19:23:26 +00:00
Zakaria El Orche 8d5340a0b4 feat(core): add HTTP/2 flow-controlled bodies 2026-08-13 19:00:19 +00:00
Zakaria El Orche c96d51f7ea feat(core): add HTTP/2 stream dispatch 2026-08-13 18:33:04 +00:00
Zakaria El Orche 9391f80f76 feat(core): add HTTP/2 response path 2026-08-13 18:04:22 +00:00
Zakaria El Orche cfa192e689 feat(core): add HTTP/2 connection state machine 2026-08-13 17:49:20 +00:00
Zakaria El Orche 95c33e7bf2 feat(core): add HPACK decoder 2026-08-13 17:17:29 +00:00
Zakaria El Orche f47f53c355 feat(core): add HPACK coding primitives 2026-08-13 16:48:47 +00:00
Zakaria El Orche 885c450f6b refactor(core): unify HTTP protocol package boundaries 2026-08-13 16:24:23 +00:00
Zakaria El OrcheandClaude Sonnet 5 d882ea255c feat(core): HTTP/2 Phase 6 — Request/Response model refactor
Pools Request/RequestBody/RequestLine/Response per connection (EX-20..EX-24),
following the same reset()/dev-mode-guard idiom Http1HeaderMap already used.
HeaderMap splits into HeaderView (interface) + Http1HeaderMap (impl, DEC-22).
Response gains byte-level structured headers, PreEncodedHeader, and
ResponseSerializer as the single source of truth for a response's header
sequence, consumed by Http1ResponseWriter's single-bulk-write rewrite (EX-27).
ByteTemplate gets O(1) slot lookup plus a buffer-writing overload (EX-28).
Multipart audited: three resource-exhaustion gaps found and fixed — unbounded
buffered part size, part count, and per-part header parsing (EX-38..EX-40) —
and boundary length confirmed already bounded (EX-41).

Re-measuring RequestPipelineBenchmark after the pooling work surfaced one more
per-request allocation underneath it (RequestParser building fresh
RequestByteViews every call) and, while checking the phase's own DoD text, an
unbounded Response.header(...) loop hazard neither had a limit — both fixed
(EX-42, EX-43). The h1 zero-alloc contract now holds: parseAndRoute measures
0.008 B/op (JMH noise floor), down from Phase 4's 120.008 B/op (DEC-20, DEC-23).

MESSAGE-MODEL.md records the pooling model; README gains an "Object lifetime"
section documenting the do-not-retain-past-the-handler contract. 503/503 tests
green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 15:26:08 +00:00
Zakaria El OrcheandClaude Sonnet 5 0e1bbed42c feat(core): HTTP/2 Phase 5 — frame layer
Implements HTTP/2 frame reading, validation, and writing: FrameType (the
10 RFC 9113 types + per-type validation descriptor), FrameFlags (with
the deliberate END_STREAM/ACK bit collision documented), FrameHeader (a
flyweight, never allocated per frame), Http2FrameReader (length-prefixed
reader over BufferedByteSource, mirroring RequestParser's buffer/
compaction discipline), FrameValidator (table-driven, specific RFC error
code per violation -- not a uniform code per type), Padding (RFC 9113
6.1/6.2), and FrameWriteBuffer (beginFrame/endFrame length back-patching
over Phase 4's ByteWriter).

All 10 frame types round-trip correctly; every RFC-mandated rejection
has its own test asserting the specific error code; the reader is
fuzz-tested against 10,000,000 random inputs (~14s). The zero-alloc
contract is measured, not asserted: reading + validating + consuming a
frame is 0.002 B/op, writing one is ~10^-4 B/op -- both indistinguishable
from zero (DEC-21).

Found and fixed EX-37 while writing Http2FrameReaderTest: BufferedByteSource's
deadline mechanism (EX-07's actual fix) NPE'd against a null socket, which
every isolated unit test in this codebase uses -- it had zero dedicated
test coverage of its own. Fixed to treat a null socket as "no OS-level
timeout to bound" rather than a misuse, and given BufferedByteSourceTest,
which did not exist before.

449/449 tests green, both with and without -Pjmh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 14:25:12 +00:00
Zakaria El OrcheandClaude Sonnet 5 704a00a551 feat(core): HTTP/2 Phase 4 — byte-layer foundations
Builds dev.relism.flash.bytes: ByteScan (scanning/comparison/hashing,
scalar + SWAR, property-tested against each other on every boundary and
20,000 random fuzz trials each), ArrayBackedByteView/SegmentedByteView
capability hierarchy, PooledSlice/SlicePool, ByteWriter, Pairs.

Cashes in the allocation and scanning wins the existing code left on the
table: EX-04 (word-at-a-time router matching, verified directly against
fpr-core's own ByteCompare), EX-05 (pooled views replacing per-call
anonymous ByteView allocations in HeaderMap/QueryParams/PathParams),
EX-09 (HeaderMap index built once per reset() instead of rescanning per
lookup), EX-19 (reusable PathParams on the router's per-connection
scratch), EX-25/EX-26 (single-allocation String construction), EX-33
(SWAR header-terminator scan in RequestParser).

Also closes EX-06's router half, missing from this phase's own EX-item
list in the plan (same class of omission DEC-12 recorded for Phase 1):
FastPathRouterImpl/FastPathWsRouterImpl's ThreadLocals (unbounded under
one-virtual-thread-per-connection) are replaced by an opaque,
caller-owned per-connection scratch object (AbstractRouter#newScratch),
not by extending ConnectionScratch as its own Javadoc originally assumed
-- that would have created transport's first dependency on routing in
the reverse direction. Full rationale in DEC-19.

Every optimization is measured, not asserted (DEC-20): SWAR scan 35.4%
faster than scalar, kept; EX-04's word-path 32.1% faster than
byte-at-a-time at the mechanism level, kept for its real future
consumers even though today's router doesn't yet route through it
(MethodPathByteView stays deliberately non-array-backed, per the plan's
own text). Router matching itself is ~0 B/op including parametric
routes. The full h1 pipeline is not literally 0 B/op yet -- 120 B/op is
Request/RequestBody/RequestLine construction, honestly attributed to
Phase 6's explicit scope rather than hidden.

395/395 tests green, both with and without -Pjmh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 14:07:29 +00:00
Zakaria El OrcheandClaude Sonnet 5 2bf261e4e2 feat(core): HTTP/2 Phase 3 — serialized frame writer (GO/NO-GO gate)
Implements the connection-level serialized frame writer per the plan's
go/no-go gate: tryLock() fast path with an intrusive Vyukov-style MPSC
fallback under contention, ReentrantLock throughout (never synchronized),
and a scan-based write-timeout reaper.

All four gate criteria met and measured: N=1 0 B/op and 42.6 ns overhead
(<=50 ns budget); N=64 65.5% throughput retention (>=60%) and 11.8-14.2 us
p999 (<1 ms); no carrier pinning; stress test 10,000/10,000 green across
1000 iterations x 5 concurrency levels x 2 scheduler configs. Compared
against plain-lock and dedicated-thread designs with real benchmark
numbers, not assertion. Full methodology and results in WRITER.md, DEC-09.

Also fixes a real regression found while resuming this work: the JMH
benchmark broke plain `mvn test` (no -Pjmh) because it lived in
src/test/java, which Surefire's test discovery loads regardless of
whether a class is ultimately selected as a test. Moved to a dedicated
src/jmh/java source root registered only under the jmh profile
(build-helper-maven-plugin), per DEC-17.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 14:05:01 +00:00
Zakaria El OrcheandClaude Sonnet 5 a315e1df8b feat(core): HTTP/2 Phase 2 — transport decomposition
Breaks HttpServer (563 lines, eleven responsibilities) into named,
single-purpose components and introduces the ConnectionProtocol seam
HTTP/2 plugs into starting Phase 8, per
flash/docs/http2/IMPLEMENTATION-PLAN.md Phase 2.

New packages:
- dev.relism.flash.transport: TransportFactory (composition root, EX-34),
  ListenerBinder, BoundListener, TransportTuning, AcceptLoop,
  ConnectionRunner (per-connection setup/teardown), ConnectionProtocol
  (the h1/h2 seam), ConnectionContext, ConnectionScratch + ScratchPool
  (EX-06), ServerLifecycle (implements ServerHandle; start/stop/graceful
  shutdown, EX-32).
- dev.relism.flash.http1: Http1Connection (the keep-alive request loop,
  implements ConnectionProtocol), Http1ResponseWriter, Http1KeepAlive
  (the shared Connection-header token-list scanner, EX-13).
- dev.relism.flash.websocket additions: WebSocketUpgrade (detection +
  handshake), WebSocketLoop (session loop), WebSocketProtocolException.

Existing-code defects fixed (EX-nn):
- EX-01: WebSocketSession's two blocking-write sites use ReentrantLock
  instead of synchronized (out) -- a virtual thread blocking inside
  synchronized pins its carrier platform thread on Java 21.
- EX-06: HttpServer's three ThreadLocals (SHA1, LONG_BUF,
  STREAM_RELAY_BUFFER) replaced by ConnectionScratch, pooled via
  ScratchPool instead of one-per-virtual-thread (i.e. one-per-connection)
  growth. The router's ThreadLocals are deliberately deferred to Phase 4
  per this EX item's own phasing -- see DEC-15 for the plan-wording fix.
- EX-11: WebSocketSession.readFrame's extended-length and mask-key bytes
  are now read in a single bounded readFully instead of one at a time.
- EX-12: full RFC 6455 frame validation -- continuation-frame
  reassembly, mandatory masking-direction enforcement, opcode
  validation, control-frame constraints (not fragmented, <=125 bytes),
  and WebSocketProtocolException carrying the correct close code (1002
  protocol error, 1009 message too big).
- EX-13: Connection header token-list scanning shared between the
  keep-alive decision and the WebSocket upgrade check.
- EX-14: HEAD responses report Content-Length but write no body.
- EX-15: Content-Type omitted when empty; Content-Length and the body
  omitted entirely for 204/304/1xx responses.
- EX-16: Date header (dev.relism.flash.http.DateHeader), refreshed once
  per second by a shared daemon thread; FlashConfiguration.sendDate.
- EX-32: two-stage graceful shutdown -- stop accepting, force
  Connection: close on the response an in-flight handler is still
  producing (re-checked after the handler runs, not just before
  dispatch, so a shutdown beginning mid-handler is still honoured),
  drain up to shutdownDrainTimeoutMs, then force-close.
- EX-34: ServerHandle.create delegates to TransportFactory instead of
  constructing HttpServer directly.

Two plan corrections recorded: DEC-15 (Phase 2's "no ThreadLocal
anywhere" DoD line contradicted EX-06's own multi-phase assignment --
corrected to match the registry) and DEC-16 (no separate
WebSocketFrameCodec class this phase; the EX-11/EX-12 fixes stay inside
WebSocketSession, which is one cohesive state machine under R6's own
carve-out -- revisit at Phase 15 if RFC 8441 needs the decoupling for
real).

HttpServer.java deleted.

311/311 tests green (flash module), run three times for stability of
the wall-clock-based timeout/shutdown tests. Whole-repo build green.
h1 benchmark regression check remains unverified in the plan's DoD (no
JMH harness until Phase 3, same caveat as Phase 1).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 12:03:44 +00:00
Zakaria El OrcheandClaude Sonnet 5 5a2aaf5a07 feat(core): HTTP/2 Phase 1 — HTTP/1.1 hardening and protocol negotiation
Fixes the request-smuggling and resource-exhaustion debt in the existing
HTTP/1.1 parser, and adds the ALPN/h2c-preface negotiation seam so a
connection's protocol is decided once, before any request is parsed, per
flash/docs/http2/IMPLEMENTATION-PLAN.md Phase 1.

Existing-code defects fixed (EX-nn):
- EX-02: reject Content-Length + Transfer-Encoding together (RFC 9112 6.1
  CL.TE/TE.CL smuggling), and conflicting duplicate Content-Length values.
- EX-03: strict, overflow-safe Content-Length parsing, replacing a parser
  that silently skipped non-digit bytes ("5abc" -> 5, "-1" -> 1).
- EX-07: header-read / idle-keep-alive / body-read timeouts enforced by an
  absolute deadline (dev.relism.flash.transport.BufferedByteSource), not
  merely Socket#setSoTimeout, which never trips against a peer trickling
  one byte per read within the window.
- EX-08: header count / name length / value length / request-line length
  bounds (Http1Limits), 431 on violation.
- EX-10: ChunkedInputStream now reads through BufferedByteSource instead
  of the raw unbuffered socket stream, and the header-parser's read-ahead
  bytes are handed over via a zero-copy prependOnce() instead of a
  SequenceInputStream/ByteArrayInputStream pair.
- EX-17: HttpStatus's status-code bound is computed from values() instead
  of a hand-maintained constant that silently threw
  ArrayIndexOutOfBoundsException when a code above it was added; added
  421, 431, 505, 507, 511 and others HTTP/2 and this hardening need.
- EX-18: bare-CR desync and obsolete line folding rejected.
- EX-30: the TLS handshake is forced explicitly, under a timeout, before
  any protocol decision -- SSLSocket#getApplicationProtocol() returned
  null until the handshake had run, and nothing previously forced it.
- EX-31: TLS 1.2 cipher suites on the RFC 9113 Appendix A blocklist are
  filtered out of a listener's enabled set whenever it offers h2 via ALPN.
- EX-35 (found in this phase): Transfer-Encoding values listing multiple
  codings ("gzip, chunked") were silently treated as not chunked at all,
  corrupting the message boundary -- only the whole value was compared.
- EX-36 (found in this phase): a header line with no ':' was silently
  skipped instead of rejected.

New:
- dev.relism.flash.transport.BufferedByteSource: the single buffered,
  deadline-aware, peekable view over a connection's inbound bytes.
- dev.relism.flash.transport.ProtocolNegotiator/NegotiatedProtocol: ALPN
  and h2c prior-knowledge detection. In this phase an H2 result is always
  closed cleanly -- there is no Http2Connection to hand off to until
  Phase 8. FlashConfiguration.http2Enabled gates the h2c preface peek.
- dev.relism.flash.exceptions.MalformedRequestException: a typed,
  status-carrying rejection distinct from HttpException, caught at the
  parse site so a malformed request never reaches the handler chain or
  the user's exception handler, and the connection is always closed.

Two small plan-document corrections recorded as DEC-12 (Phase 1's Files
list omitted BufferedByteSource.java and MalformedRequestException.java;
the request-line-length check description pointed at the wrong offset).
DEC-13/DEC-14 record the deadline and exception-hierarchy designs.

277/277 tests green (flash module), run twice for stability of the new
wall-clock-based HttpServerTimeoutTest cases. Whole-repo build green.
h1 benchmark regression check is left unverified in the plan's DoD: no
JMH harness exists yet (Phase 3 deliverable).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:40:03 +00:00
Zakaria El OrcheandClaude Sonnet 5 db6e4a4d0c feat(core): HTTP/2 Phase 0 — groundwork (limits, error model, decision log)
Establishes the package layout, limits/error model and decision-log
convention that every later HTTP/2 phase depends on, per
flash/docs/http2/IMPLEMENTATION-PLAN.md Phase 0.

- dev.relism.flash.h2: package-info (architecture overview), Http2ErrorCode
  (the 14 RFC 9113 §7 codes with precomputed 4-byte wire encodings),
  Http2Exception (connection error -> GOAWAY) and Http2StreamException
  (stream error -> RST_STREAM), neither extending IOException, both with
  stack-trace capture disabled on the hot rejection path.
- Http2Limits: every bound Phase 0 requires (concurrent streams, frame
  size, header list size, CONTINUATION/reset/settings/ping rate bounds,
  flow-control windows, HPACK table size/string length, assembly and idle
  timeouts), each documented with the attack or RFC clause it addresses.
- dev.relism.flash.http.Http1Limits: the h1 bounds needed by EX-03 (strict
  Content-Length) and EX-08 (header count/size limits).
- flash/docs/http2/DECISIONS.md seeded with DEC-01..DEC-11 (the ten
  decisions implied by the plan itself, plus DEC-11 recording that commits
  keep scope `core` rather than adding `h2` to AGENTS.md).
- flash/docs/http2/IMPLEMENTATION-PLAN.md: added the Progress Ledger
  (tracks phase status across sessions) and checked off Phase 0's DoD.

19 new tests, full flash module suite green (226/226).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 10:59:49 +00:00
Relism 8f1f30b973 Merge pull request 'fix(data): fire transaction synchronizations, and scope them to their transaction' (#9) from fix/tx-synchronizations into master
Publish Maven packages / publish (push) Successful in 1m53s
Reviewed-on: #9
2026-08-12 23:37:36 +00:00
Zakaria El OrcheandClaude Opus 5 c5179146c0 docs(data): write the data-layer docs in English
The three data modules' docs were in Italian, so the synchronization contract
added in the previous commit went in as Italian too, to match its file. English
is the project's language for docs, comments and READMEs alike, and a file half
in each is worse than either — so all three are translated, not just the new
section.

Content is otherwise unchanged, except the "synchronizations run on
commit/rollback" line in the two backend READMEs, which was vague before and is
now accurate about which hook sees the session/connection still bound, pointing
at flash-ext-data-core's README for the full contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 23:36:11 +00:00
Zakaria El OrcheandClaude Opus 5 0194470c1f feat(data): actually invoke TxSynchronization.beforeCommit, and document the contract
beforeCommit(boolean) has been part of the TxSynchronization API from the
start and was invoked by nothing, in either manager — anyone implementing it
got silence, the same failure class as the dropped afterCommit callbacks in
the previous commit. Left out of that one because fixing it is a design
decision rather than a restored behaviour; this is that decision, written
down.

It now runs immediately before the real commit, with the transaction still
active and its session/connection still bound, which is the whole reason to
have a hook on this side of the commit: it can still write through the same
resource and land in the same atomic unit. Skipped when the transaction is
already rollback-only, since there is no commit to precede.

Throwing from it vetoes the commit: the transaction rolls back, the surviving
callbacks hear ROLLED_BACK, and the exception propagates. Without that, a hook
running before the commit would be strictly less useful than one running
after. A commit that fails on its own now takes the same path instead of
completing silently with no callback at all, and a rollback that also fails is
attached as a suppressed exception rather than replacing the one that explains
the failure.

Documented on the interface itself and in flash-ext-data-core/docs/README.md:
which hook sits on which side of the commit, what each may still touch, what
throwing does, and the per-transaction scoping rule from the previous commit.

Tests: 6 more (3 per manager) — runs inside the transaction with the resource
still bound, receives the read-only flag, skipped on a rollback-only
transaction, and vetoes the commit when it throws. 37 across the two managers
now, all green, reactor verify passes the 80% Jacoco gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 23:33:05 +00:00
Zakaria El OrcheandClaude Opus 5 7299490d0d fix(data): fire transaction synchronizations, and scope them to their transaction
Two defects in the same mechanism, both silent.

1. HibernateTxManager#commit fired synchronizations *after* its finally block,
   where cleanupIfIdle() had already called ResourceRegistry.cleanup() and
   removed the ThreadLocal list holding them. fireSynchronizations() then read
   a freshly initialized empty list and did nothing. No afterCommit callback
   had ever run on the Hibernate path: no exception, no log, just silence.
   rollback() twenty lines below had the order right, which is what makes this
   an ordering slip rather than a design choice.

   Found in production, from the far end: an admin write landed in Postgres
   while the in-memory cache it was registered to refresh never heard about it,
   so the change only took effect when the process restarted and re-read the
   database at boot.

2. Synchronizations were a single flat per-thread list, fired from index 0 by
   whichever transaction completed first. A REQUIRES_NEW inner transaction
   therefore fired the *suspended* outer transaction's callbacks too — early,
   with the inner transaction's outcome, for a transaction that might still
   roll back. Each new transaction now records how many synchronizations were
   already registered when it began, and fires only its own tail.

Both managers get the fix and the same callback ordering: unbind the session or
connection first, so a callback that opens its own transaction (a cache reload,
an outbox drain) gets a fresh one instead of joining the transaction that just
committed, then fire, then clean up.

Also fixes JdbcTxStatus rejecting a null connection, which turned the two
propagations that deliberately produce a connectionless status — SUPPORTS with
no active transaction, and NOT_SUPPORTED — into an NPE inside begin(). The
Hibernate manager always allowed it, and resource() already reports the real
mistake with a message that names it.

Tests: 16 new across the two managers, kept deliberately parallel since the two
are interchangeable behind TxManager — synchronization firing, ordering,
per-transaction scoping, callbacks opening their own transaction, and the
previously untested SUPPORTS/NOT_SUPPORTED/MANDATORY propagations. Nothing
covered afterCommit before, which is how both defects shipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 23:23:47 +00:00
Zakaria El OrcheandClaude Sonnet 5 88ac3c3d1f fix(build): don't skip deploying flash-extensions' own POM
Publish Maven packages / publish (push) Successful in 2m2s
Its maven-deploy-plugin was configured with <skip>true</skip>, so the
first successful publish-maven.yml run deployed flash-parent, flash,
and every flash-ext-* jar to Gitea's Maven registry, but not
flash-extensions itself — the pom-packaging aggregator every
flash-ext-* submodule's effective POM inherits from via <parent>.
Remote consumers (Pathway's Docker build, resolving flash-ext-* from
the registry instead of a local ~/.m2 install) couldn't resolve that
parent POM and failed with "artifacts could not be resolved:
flash-extensions:pom (absent)".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 20:30:12 +00:00
Zakaria El OrcheandClaude Sonnet 5 3f0b49fa36 ci: use a real PAT (PACKAGES_TOKEN) instead of GITEA_TOKEN for deploy
Publish Maven packages / publish (push) Successful in 1m55s
Root cause of the persistent 401s found: Gitea's own per-job GITEA_TOKEN
cannot publish to any package registry at all — a known, still-
unimplemented limitation (go-gitea/gitea#23642), not a settings.xml
auth-format issue as first assumed. Confirmed by testing: GITEA_TOKEN
authenticated fine against the plain API but every registry write
endpoint rejected it regardless of scope or header style.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 20:23:18 +00:00
Zakaria El OrcheandClaude Sonnet 5 a19c59770d ci: add a debug step for the persistent 401 on deploy
Publish Maven packages / publish (push) Failing after 28s
Basic auth didn't fix it either — same 401 as the httpHeaders form.
Before guessing again: confirm GITEA_TOKEN actually reaches this step
non-empty, and check whether it authenticates against the plain API at
all (both header styles), independent of Maven/wagon-http.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 19:54:47 +00:00
Zakaria El OrcheandClaude Sonnet 5 3bc5952dc7 ci: switch Maven auth to basic (username/password), not httpHeaders
Publish Maven packages / publish (push) Failing after 27s
Still 401 after granting packages:write — the httpHeaders form from
Gitea's docs is honored by Maven's resolver (dependency reads) but
apparently not reliably by the wagon-http provider maven-deploy-plugin
uploads through, which deployed unauthenticated. Basic auth is
wagon-http's oldest, always-supported path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 19:53:12 +00:00
Zakaria El OrcheandClaude Sonnet 5 9d19de50cb ci: request packages:write — repo's default Actions token is read-only
Publish Maven packages / publish (push) Failing after 26s
Deploy failed with 401 Unauthorized: this repo's default Actions token
permission mode is Restricted (read-only on packages, not Permissive),
so the auto-provided GITEA_TOKEN couldn't push to the Maven registry
without explicitly requesting write access.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 19:50:58 +00:00
Zakaria El OrcheandClaude Sonnet 5 4710cf8633 ci: replace actions/checkout with a plain git clone
Publish Maven packages / publish (push) Failing after 27s
actions/checkout@v4 is a Node-based action; the maven:3.9-eclipse-temurin-21
container it was running in has no Node, so it failed with "node: executable
file not found in \$PATH". A shell git clone needs neither Node nor any
action runtime, just git (installed here via apt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 19:47:40 +00:00
Zakaria El OrcheandClaude Sonnet 5 6a0242d654 ci: use the global ubuntu-latest runner, not the org-scoped one
Publish Maven packages / publish (push) Failing after 59s
The "org" runner label is scoped to the Pixel-Services organization —
Flash5 lives under the separate Relism account, which can only reach
the global runner (labels ubuntu-latest/ubuntu-24.04/ubuntu-22.04).
This job just runs Maven inside a container, no Docker access needed,
so ubuntu-latest is a fine fit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 19:44:24 +00:00
Zakaria El OrcheandClaude Sonnet 5 0365c1f222 ci: fix runner label (org, not docker)
Publish Maven packages / publish (push) Canceled after 0s
The org's Gitea Actions runner is registered under the label "org" —
"docker" doesn't match anything online, so the publish job never got
picked up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 19:41:26 +00:00
Relism c235e67158 Merge pull request 'ci: publish Maven packages to Gitea registry on push to master' (#8) from ci/publish-maven into master
Publish Maven packages / publish (push) Canceled after 0s
2026-08-12 19:39:12 +00:00
Zakaria El OrcheandClaude Sonnet 5 5a16bc690c ci: publish Maven packages to Gitea registry on push to master
Lets Pathway (and other consumers) resolve dev.relism:flash from Gitea's
Maven registry instead of requiring a local `mvn install`. Every push
stamps all modules with a commit-scoped version (2.1.0-<short-sha>) before
deploying, since Gitea's registry won't let a build overwrite an existing
name+version.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 19:38:18 +00:00
Relism e217f9370d Merge pull request 'feat(ext-data): add unified Data gateway; mcp: derive roles claim from OIDC' (#7) from feature/data-unified-composition into master
Publish Snapshot / Deploy Snapshot (push) Canceled after 5s
CI / Build & Test (push) Canceled after 3s
Reviewed-on: #7
2026-08-12 18:39:28 +00:00
Relism be7df3987c Merge pull request 'Feature/core/deterministic boot graph' (#6) from feature/core/deterministic-boot-graph into master
CI / Build & Test (push) Canceled after 5s
Publish Snapshot / Deploy Snapshot (push) Canceled after 3s
Reviewed-on: #6
2026-08-12 18:39:01 +00:00
Zakaria El OrcheandClaude Sonnet 5 9e045287c1 feat(ext-data): add unified Data gateway; mcp: derive roles claim from OIDC
CI / Build & Test (push) Canceled after 1m28s
CI / Build & Test (pull_request) Canceled after 16s
Data/RepositoryFactory/HibernateData give applications one cached,
stateless entry point for repositories per entity type instead of
per-request instantiation, with write()/afterCommit() replacing manual
transaction+reload choreography.

McpConfig.rolesClaimPath is removed - MCP now derives the claim path
from OidcMiddleware.rolesClaimPath() so applications never duplicate
the roles-claim config between OIDC and MCP. McpPackageScanner is
rebuilt on the shared PackageScanner.discover(packageName) primitive,
doing only McpTool/McpResource/McpPrompt classification itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 18:37:30 +00:00
Zakaria El Orche 891ef99b8e refactor(core): make boot and middleware ordering deterministic
CI / Build & Test (pull_request) Canceled after 23s
CI / Build & Test (push) Failing after 4m51s
2026-08-12 16:42:49 +00:00
Zakaria El OrcheandClaude Sonnet 5 d7f36a7aea feat(ext-mcp): add MCP (Model Context Protocol) server extension
CI / Build & Test (push) Failing after 4m57s
Streamable HTTP transport (JSON-RPC 2.0 over POST), one-class-per-tool/resource/prompt
API mirroring RequestHandler, boot-time-precompiled schema/list payloads for a zero-alloc
hot path, and optional OAuth2 protection built on flash-ext-oidc (lazy-loaded, RFC 8707
audience binding, RFC 9728 Protected Resource Metadata). Registers the module in the
root and flash-extensions POMs and adds the ext-mcp commit scope to AGENTS.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 00:22:40 +00:00
Zakaria El OrcheandClaude Sonnet 5 8ece9975de feat(ext-web-bundler): add build-time asset scanning and static-frontend strategy
Introduces AssetDirectoryScanner, StaticFrontendStrategy, and WebBundlerBuild for
build-time asset discovery, plus config/docs updates for frontend-type resolution.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 00:22:26 +00:00
Relism 4032567d75 Merge pull request 'feat(core): add HTTP QUERY method support' (#5) from feature/core/http-query-method into master
CI / Build & Test (push) Canceled after 5s
Publish Snapshot / Deploy Snapshot (push) Canceled after 7s
Reviewed-on: #5
2026-08-10 13:29:24 +00:00
Zakaria El OrcheandClaude Sonnet 5 fa0a2d79b4 feat(core): add HTTP QUERY method support
CI / Build & Test (push) Failing after 4m55s
CI / Build & Test (pull_request) Canceled after 50s
Adds the QUERY method (RFC 10008) — safe and idempotent like GET,
but carries a request body like POST, useful for complex filters
that don't fit in a URL query string.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 13:09:26 +00:00
Relism 391ae6778e Merge pull request 'feat(core): ALPN configuration and TLS visibility on Request/WebSocketSession' (#4) from feature/core/tls-alpn into master
Publish Snapshot / Deploy Snapshot (push) Failing after 4m48s
CI / Build & Test (push) Failing after 5m4s
Reviewed-on: #4
2026-08-09 23:38:27 +00:00
Zakaria El OrcheandClaude Sonnet 5 1b48d14b4f feat(core): ALPN configuration and TLS visibility on Request/WebSocketSession
CI / Build & Test (push) Failing after 5m3s
CI / Build & Test (pull_request) Failing after 4m53s
- TlsConfig.applicationProtocols(String...) sets the listener's negotiable
  ALPN protocol list via SSLParameters, inherited by every accepted socket
  like clientAuth — works on both keystore() and ofContext(), untouched
  unless called. Enables TLS-ALPN-01 (RFC 8737) style on-demand cert
  issuance: a custom KeyManager can read the already-resolved protocol via
  engine/socket getHandshakeApplicationProtocol() inside
  chooseEngineServerAlias/chooseServerAlias, since ALPN is resolved during
  ClientHello/ServerHello, always before Certificate production.
- Request gains isSecure()/sslSession(), threaded through RequestParser from
  the accepted SSLSocket exactly like remoteAddress() — reference-only,
  zero per-request allocation. sslSession() defers to SSLSocket#getSession()
  lazily, so it's a cached-field read (handshake already completed by the
  time a handler can call it), never a forced handshake.
- WebSocketSession.isSecure()/sslSession() delegate to the upgrading
  Request rather than tracking the socket a second time.
- Documents TLS end-to-end in README.md (listeners, TlsConfig, SNI, ALPN,
  mTLS, Request/WebSocketSession accessors).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 23:34:22 +00:00
Relism 7cd8b3869c Merge pull request 'feat(core): add TLS/mTLS support with multi-listener and SNI' (#3) from feature/core/tls-support into master
Publish Snapshot / Deploy Snapshot (push) Failing after 4m58s
CI / Build & Test (push) Failing after 4m59s
Reviewed-on: #3
2026-08-09 22:23:53 +00:00
Zakaria El OrcheandClaude Sonnet 5 9f6808e90c feat(core): add TLS/mTLS support with multi-listener and SNI
CI / Build & Test (pull_request) Failing after 4m54s
CI / Build & Test (push) Failing after 4m54s
Flash can now serve HTTPS and WSS, on one or many listeners per app:

- FlashConfiguration gains an optional `tls` field for the single default
  listener, and a `listeners` list for apps that bind multiple ports (each
  independently plain or TLS).
- New dev.relism.flash.tls package: TlsConfig.keystore(path, password) builds
  an SSLContext from a PKCS12/JKS keystore, with SNI-based certificate
  selection for free when the keystore holds more than one alias (matched by
  SAN/CN, pure JDK APIs). TlsConfig.ofContext(sslContext) is a full escape
  hatch — Flash never calls setSSLParameters on that path, so caller-set
  protocols/cipher suites/ALPN survive untouched. TlsConfig.clientAuth(...)
  adds optional/required mTLS on either path.
- HttpServer moves from a single ServerSocket to a list of bound listeners;
  the per-request hot path (RequestParser, routing, response writing) is
  untouched — TLS only changes which bytes come out of accept(), so WSS needs
  no separate code path from WS.
- process()'s catch is widened to log non-IOException failures (e.g. a
  misbehaving custom KeyManager/TrustManager on the ofContext path) instead
  of swallowing them silently; the failure was already isolated to the one
  connection via the existing try-with-resources/executor-submission
  boundary — this only fixes the missing log line.
- Fixes a pre-existing gap where FlashConfiguration#host was accepted but
  never used to bind (listeners always bound to the wildcard address).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 21:41:57 +00:00
Relism bf2d54ca1a Merge pull request 'feat(core): WS client-mode masking, zero-copy header iteration, shared I/O relay buffer' (#1) from feature/core/ws-client-mode-io-reuse into master
Publish Snapshot / Deploy Snapshot (push) Failing after 5m32s
CI / Build & Test (push) Failing after 5m38s
Reviewed-on: #1
2026-08-09 20:38:20 +00:00
Zakaria El OrcheandClaude Sonnet 5 a037456634 feat(core): WS client-mode masking, zero-copy header iteration, shared I/O relay buffer
CI / Build & Test (push) Failing after 6m7s
CI / Build & Test (pull_request) Failing after 4m56s
- WebSocketSession supports client-mode outgoing frame masking (RFC 6455) via
  in-place XOR, reusing the unmask routine already used for inbound frames.
- HeaderMap gains an allocation-free forEach(HeaderConsumer) for callers that
  must handle an open-ended set of header names (e.g. proxying).
- HttpServer relays streaming/chunked response bodies through a shared
  per-connection ThreadLocal buffer instead of relying on InputStream#transferTo
  (which allocates internally) or a fresh byte[8192] per chunked write.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 20:24:56 +00:00
Relism 524bdeb28b Fix badge HTML syntax in docs workflow 2026-05-12 16:34:59 +02:00
Relism c619c17949 Refactor JavaDoc generation and verification steps 2026-05-12 16:28:20 +02:00
Relism c368843ad4 ci: fix javadoc path, encoding and delegate docs to docs.yml 2026-05-12 16:15:33 +02:00
Relism 35293a0a57 feat(ci): add workflow for publishing JavaDoc to GitHub Pages 2026-05-12 15:42:39 +02:00
Relism c514897ffc fix(ci): publish versioned javadocs only on gh-pages 2026-05-11 16:53:17 +02:00
Relism 6314bcff5f fix(ci): correct javadoc publish path and maven settings schema 2026-05-11 16:18:49 +02:00
Relism ccc5550598 feat: introduce WebSocket support with new endpoints and transaction propagation enhancements 2026-05-11 16:14:26 +02:00
github-actions[bot] a4a16bdb00 chore(release): prepare 2.1.0-SNAPSHOT 2026-05-11 13:54:19 +00:00
455 changed files with 37862 additions and 2456 deletions
+24
View File
@@ -0,0 +1,24 @@
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 http://maven.apache.org/xsd/settings-1.0.0.xsd">
<!--
Used only by .gitea/workflows/publish-maven.yml (mvn -s .gitea/maven-settings.xml deploy).
Not used for local builds. PACKAGES_TOKEN is a real personal access token (write:package
scope) on the Relism account, read from the env var the workflow exports — never written
to disk. Deliberately not Gitea's own per-job GITEA_TOKEN: that token can't publish to
any package registry at all, a known unimplemented limitation
(https://github.com/go-gitea/gitea/issues/23642) — confirmed here by testing: it
authenticated fine against the plain API but still got 401 from this endpoint.
Basic auth (username/password): the <httpHeaders> form Gitea's own docs show for this is
honored by Maven's resolver (used for reading <repositories>) but not reliably by the
wagon-http provider maven-deploy-plugin actually uploads through.
-->
<servers>
<server>
<id>gitea</id>
<username>Relism</username>
<password>${env.PACKAGES_TOKEN}</password>
</server>
</servers>
</settings>
+49
View File
@@ -0,0 +1,49 @@
name: Publish Maven packages
# Flash's own POM keeps `2.1.0-SNAPSHOT` as its committed version — that's what local
# `mvn install` (Pathway's normal dev loop, see its pom.xml `flash.version` comment) always
# produces, and changing it here would break that. Gitea's Maven registry, unlike a real
# snapshot repository, refuses to re-publish an existing name+version (must delete first —
# see https://docs.gitea.com/usage/packages/maven#publish-a-package), so every push instead
# publishes under a throwaway version stamped with the commit it built from
# (`2.1.0-<short-sha>`), via `versions:set` on a checkout copy — never touching the committed
# POMs. Consumers (Pathway's `docker` Maven profile) pin `flash.version` to one specific
# published build and bump it by hand to pick up newer Flash changes; see
# pathway/pom.xml's `docker` profile for the other half of this.
on:
push:
branches: [master]
jobs:
publish:
runs-on: ubuntu-latest
# No actions/checkout here on purpose: it's a Node-based action, and this container
# (chosen for its preinstalled mvn/JDK 21) has no Node — checkout would fail with
# "node: executable file not found". A plain git clone needs neither.
container:
image: maven:3.9-eclipse-temurin-21
steps:
- name: Checkout
run: |
apt-get update && apt-get install -y --no-install-recommends git
git clone https://git.pixel-services.com/Relism/Flash5.git .
git checkout ${{ gitea.sha }}
- name: Stamp every module with a commit-scoped version
run: |
SHORT_SHA=$(git rev-parse --short HEAD)
mvn -B versions:set -DnewVersion="2.1.0-${SHORT_SHA}" -DprocessAllModules=true -DgenerateBackupPoms=false
echo "Publishing as 2.1.0-${SHORT_SHA}"
- name: Deploy to the Gitea Maven registry
env:
# Not GITEA_TOKEN: Gitea's own job token can't publish to package registries at
# all (a known, still-unimplemented limitation — see
# https://github.com/go-gitea/gitea/issues/23642). Confirmed by testing: GITEA_TOKEN
# authenticated fine against the plain API but still got 401 from this endpoint no
# matter the auth style. PACKAGES_TOKEN is a real PAT with write:package scope.
PACKAGES_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
run: |
mvn -B -s .gitea/maven-settings.xml -DskipTests deploy \
-DaltReleaseDeploymentRepository=gitea::https://git.pixel-services.com/api/packages/Relism/maven \
-DaltSnapshotDeploymentRepository=gitea::https://git.pixel-services.com/api/packages/Relism/maven
+1 -1
View File
@@ -1,7 +1,7 @@
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0" <settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0
http://maven.apache.org/xsd/maven-1.0.0.xsd"> http://maven.apache.org/xsd/settings-1.0.0.xsd">
<servers> <servers>
<server> <server>
<id>Personal</id> <id>Personal</id>
+33 -1
View File
@@ -32,8 +32,40 @@ jobs:
server-username: MAVEN_USERNAME server-username: MAVEN_USERNAME
server-password: MAVEN_PASSWORD server-password: MAVEN_PASSWORD
- name: Install h2spec 2.6.0
run: |
curl --fail --location --silent --show-error \
--output /tmp/h2spec.tar.gz \
https://github.com/summerwind/h2spec/releases/download/v2.6.0/h2spec_linux_amd64.tar.gz
echo "157ee0de702e01ad40e752dbf074b366027e550c8e7504f9450da2809e279318 /tmp/h2spec.tar.gz" \
| sha256sum --check
tar --extract --gzip --file /tmp/h2spec.tar.gz --directory /tmp
- name: Install nghttp client
run: |
sudo apt-get update
sudo apt-get install --yes nghttp2-client
- name: Install grpcurl 1.9.3
run: |
curl --fail --location --silent --show-error \
--output /tmp/grpcurl.tgz \
https://github.com/fullstorydev/grpcurl/releases/download/v1.9.3/grpcurl_1.9.3_linux_x86_64.tar.gz
echo "a926b62a85787ccf73ef8736b3ae554f1242e39d92bb8767a79d6dd23b11d1d5 /tmp/grpcurl.tgz" \
| sha256sum --check
tar --extract --gzip --file /tmp/grpcurl.tgz --directory /tmp grpcurl
- name: Build and test - name: Build and test
run: mvn -B --settings .github/settings.xml clean verify run: >-
mvn -B --settings .github/settings.xml
-Dh2spec.executable=/tmp/h2spec
-Dcurl.executable=/usr/bin/curl
-Dnghttp.executable=/usr/bin/nghttp
-Dgrpcurl.executable=/tmp/grpcurl
-Djdk.tracePinnedThreads=full
-Pjmh
-Dflash.performance.gates=true
clean verify
env: env:
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
+348
View File
@@ -0,0 +1,348 @@
name: Publish Docs
on:
workflow_dispatch:
inputs:
version:
description: 'Docs version to publish (e.g. 2.1.0)'
required: true
type: string
workflow_call:
inputs:
version:
description: 'Docs version to publish (e.g. 2.1.0)'
required: true
type: string
secrets:
MAVEN_USERNAME:
required: true
MAVEN_PASSWORD:
required: true
jobs:
docs:
name: Build JavaDoc & Update gh-pages
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Temurin 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: maven
- name: Build project (compile + resolve deps, skip tests)
run: mvn -B --settings .github/settings.xml clean verify -DskipTests
env:
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
# javadoc:aggregate runs on the reactor root.
# With flash + flash-extensions both declared as modules in root pom.xml,
# this produces a single aggregated Javadoc covering all modules.
# Default output path: target/site/apidocs/ (no custom reportOutputDirectory set).
- name: Generate aggregated JavaDoc
run: |
mvn -B \
--settings .github/settings.xml \
-DskipTests \
org.apache.maven.plugins:maven-javadoc-plugin:3.6.3:aggregate
env:
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
- name: Verify JavaDoc output exists
run: |
APIDOCS=""
if [ -f "target/site/apidocs/index.html" ]; then
APIDOCS="target/site/apidocs"
elif [ -f "target/reports/apidocs/index.html" ]; then
APIDOCS="target/reports/apidocs"
else
echo "ERROR: JavaDoc output not found."
echo
echo "Contents of target/:"
find target -maxdepth 5 2>/dev/null || echo "(empty)"
exit 1
fi
echo "APIDOCS_DIR=$APIDOCS" >> $GITHUB_ENV
COUNT=$(find "$APIDOCS" -name '*.html' | wc -l)
echo "JavaDoc OK — $COUNT HTML files at $APIDOCS"
- name: Checkout gh-pages
uses: actions/checkout@v4
with:
ref: gh-pages
path: gh-pages-out
token: ${{ secrets.GITHUB_TOKEN }}
- name: Copy JavaDoc to versioned folder and latest
run: |
VERSION=${{ inputs.version }}
mkdir -p gh-pages-out/javadoc/$VERSION
cp -r "$APIDOCS_DIR"/. gh-pages-out/javadoc/$VERSION/
rm -rf gh-pages-out/latest
mkdir -p gh-pages-out/latest
cp -r "$APIDOCS_DIR"/. gh-pages-out/latest/
- name: Regenerate index.html
run: |
cd gh-pages-out
python3 - <<'EOF'
import os, re
def version_key(v):
parts = re.findall(r'\d+', v)
return [int(p) for p in parts] if parts else [0]
versions = sorted(
[d for d in os.listdir("javadoc") if os.path.isdir(f"javadoc/{d}")],
key=version_key,
reverse=True
)
latest = versions[0] if versions else None
rows = "\n".join(
f'''
<div class="release">
<div class="release-info">
<span class="version">{v}</span>
{"<span class='badge'>latest</span>" if v == latest else ""}
</div>
<a href="javadoc/{v}/index.html">Open</a>
</div>
'''
for v in versions
)
html = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flash — JavaDoc</title>
<style>
:root {
--bg: #ffffff;
--surface: #fafafa;
--border: #e5e7eb;
--text: #111827;
--muted: #6b7280;
--accent: #111827;
--accent-hover: #000000;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 64px 24px;
}
.container {
width: 100%;
max-width: 760px;
margin: 0 auto;
}
.header {
margin-bottom: 40px;
}
.header h1 {
font-size: 2rem;
font-weight: 600;
letter-spacing: -0.04em;
}
.header p {
margin-top: 10px;
color: var(--muted);
font-size: 0.95rem;
line-height: 1.6;
}
.latest {
display: inline-flex;
align-items: center;
margin-top: 20px;
padding-bottom: 2px;
color: var(--accent);
text-decoration: none;
font-size: 0.95rem;
font-weight: 500;
border-bottom: 1px solid transparent;
transition:
border-color 0.15s ease,
color 0.15s ease;
}
.latest:hover {
border-color: var(--accent);
color: var(--accent-hover);
}
.list {
border-top: 1px solid var(--border);
}
.release {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 0;
border-bottom: 1px solid var(--border);
}
.release-info {
display: flex;
align-items: center;
gap: 12px;
}
.version {
font-size: 0.96rem;
font-weight: 500;
letter-spacing: -0.01em;
}
.badge {
font-size: 0.72rem;
font-weight: 600;
color: var(--muted);
border: 1px solid var(--border);
padding: 2px 8px;
}
.release a {
color: var(--accent);
text-decoration: none;
font-size: 0.92rem;
}
.release a:hover {
text-decoration: underline;
}
.empty {
padding: 32px 0;
color: var(--muted);
font-size: 0.95rem;
}
@media (max-width: 640px) {
body {
padding: 40px 20px;
}
.header h1 {
font-size: 1.7rem;
}
.release {
flex-direction: column;
align-items: flex-start;
}
}
</style>
</head>
<body>
<div class="container">
<header class="header">
<h1>Flash JavaDoc</h1>
<p>
API documentation for all published Flash releases.
</p>
""" + (
f'''
<a class="latest" href="latest/index.html">
Latest release — {latest}
</a>
'''
if latest else ""
) + """
</header>
""" + (
f'''
<div class="list">
{rows}
</div>
'''
if rows else
'''
<div class="empty">
No versions published yet.
</div>
'''
) + """
</div>
</body>
</html>"""
with open("index.html", "w", encoding="utf-8") as f:
f.write(html)
print(f"index.html generated — {len(versions)} version(s): {versions}")
EOF
- name: Push gh-pages
run: |
cd gh-pages-out
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
git diff --cached --quiet || git commit -m "docs(javadoc): publish ${{ inputs.version }}"
git push origin gh-pages
+18 -84
View File
@@ -6,13 +6,15 @@ on:
- 'v*' - 'v*'
jobs: jobs:
# ── 1. Build, GPG-sign, deploy to Maven releases ──────────────────────────
release: release:
name: Build, Sign, Deploy & Publish name: Build, Sign & Deploy
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
pages: write
id-token: write outputs:
version: ${{ steps.version.outputs.VERSION }}
steps: steps:
- name: Checkout - name: Checkout
@@ -47,90 +49,22 @@ jobs:
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
- name: Generate aggregated JavaDoc
run: |
mvn -B --settings .github/settings.xml \
-pl flash,flash-extensions -am \
javadoc:aggregate -DskipTests
env:
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
- name: Checkout gh-pages
uses: actions/checkout@v4
with:
ref: gh-pages
path: gh-pages-out
token: ${{ secrets.GITHUB_TOKEN }}
- name: Copy JavaDoc to versioned folder
run: |
VERSION=${{ steps.version.outputs.VERSION }}
mkdir -p gh-pages-out/javadoc/$VERSION
cp -r target/reports/apidocs/. gh-pages-out/javadoc/$VERSION/
rm -rf gh-pages-out/latest
mkdir -p gh-pages-out/latest
cp -r target/reports/apidocs/. gh-pages-out/latest/
- name: Regenerate index.html
run: |
cd gh-pages-out
python3 - <<'EOF'
import os, re
versions = sorted(
[d for d in os.listdir("javadoc") if os.path.isdir(f"javadoc/{d}")],
key=lambda v: [int(x) for x in re.sub(r'[^0-9.]', '', v).split('.') if x],
reverse=True
)
rows = "\n".join(
f' <li><a href="javadoc/{v}/index.html">{v}</a></li>'
for v in versions
)
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Flash JavaDoc</title>
<style>
body {{ font-family: sans-serif; max-width: 600px; margin: 4rem auto; }}
h1 {{ font-size: 1.6rem; }}
ul {{ line-height: 2; }}
a {{ color: #0070f3; text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
</style>
</head>
<body>
<h1>Flash — JavaDoc</h1>
<p><a href="latest/index.html">&#8594; Latest</a></p>
<h2>All versions</h2>
<ul>
{rows}
</ul>
</body>
</html>"""
with open("index.html", "w") as f:
f.write(html)
print(f"index.html generated with {len(versions)} versions: {versions}")
EOF
- name: Push gh-pages
run: |
cd gh-pages-out
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
git diff --cached --quiet || git commit -m "docs(javadoc): release ${{ steps.version.outputs.VERSION }}"
git push origin gh-pages
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: v${{ steps.version.outputs.VERSION }} tag_name: ${{ github.ref_name }}
name: v${{ steps.version.outputs.VERSION }} name: ${{ github.ref_name }}
generate_release_notes: true generate_release_notes: true
draft: false draft: false
prerelease: false prerelease: false
# ── 2. Publish JavaDoc (single source of truth: docs.yml) ─────────────────
docs:
name: Publish JavaDoc
needs: release
uses: ./.github/workflows/docs.yml
with:
version: ${{ needs.release.outputs.version }}
secrets:
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
+72 -283
View File
@@ -4,292 +4,42 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="fc757130-fe3e-4ea9-8d69-c26ffb8545a4" name="Changes" comment="add core view extension with JTE and Thymeleaf support"> <list default="true" id="fc757130-fe3e-4ea9-8d69-c26ffb8545a4" name="Changes" comment="feat: introduce WebSocket support with new endpoints and transaction propagation enhancements">
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/pom.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.github/workflows/release.yml" beforeDir="false" afterPath="$PROJECT_DIR$/.github/workflows/release.yml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/DataExtension.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/Page.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/Repository.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/ResourceRegistry.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/Sort.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TransactionIsolation.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TransactionPropagation.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/Tx.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxDefinition.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxException.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxManager.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxOutcome.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxResourceKey.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxStatus.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxSynchronization.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/pom.xml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateRepository.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateTxManager.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateTxStatus.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/test/java/dev/relism/flash/ext/data/hibernate/HibernateTxManagerTest.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/test/java/dev/relism/flash/ext/data/hibernate/TestHelper.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/pom.xml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/main/java/dev/relism/flash/ext/data/jdbc/JdbcRepository.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/main/java/dev/relism/flash/ext/data/jdbc/JdbcTxManager.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/main/java/dev/relism/flash/ext/data/jdbc/JdbcTxStatus.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/test/java/dev/relism/flash/ext/data/jdbc/JdbcTxManagerTest.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteTarget.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendType.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/Flash.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/routers/radix/RadixPathRouterImpl.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/encodings.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/encodings.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/JacksonExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/JacksonExtension.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/JacksonMiddleware.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/JacksonMiddleware.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/Json.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/Json.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/ext/jackson/JacksonExtensionTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JacksonExtensionTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/ext/jackson/JacksonMiddlewareTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JacksonMiddlewareTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/ext/jackson/JsonTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JsonTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/Bucket.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/Bucket.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/BucketStore.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/BucketStore.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/Guard.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/Guard.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/KeyResolver.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/KeyResolver.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/Limit.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/Limit.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimitConfig.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimitConfig.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimitStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimitStrategy.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimiterConfig.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterConfig.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimiterExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterExtension.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/RateLimitStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/RateLimitStrategy.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/strategy/FixedWindowStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/strategy/FixedWindowStrategy.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/strategy/SlidingWindowStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/strategy/SlidingWindowStrategy.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/strategy/TokenBucketStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/strategy/TokenBucketStrategy.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/test/java/dev/relism/ext/limiter/LimiterOpenApiInteropTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/test/java/dev/relism/flash/ext/limiter/LimiterOpenApiInteropTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/Authenticated.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/Authenticated.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/ClaimsHolder.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ClaimsHolder.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/ClientAuthMethod.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ClientAuthMethod.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/DiscoveryClient.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/DiscoveryClient.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/InMemoryOidcSessionStore.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/InMemoryOidcSessionStore.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/JwtUtils.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/JwtUtils.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/JwtValidator.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/JwtValidator.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcAuthPolicy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcAuthPolicy.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcConfig.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcConfig.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcMiddleware.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcMiddleware.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcProviderMetadata.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcProviderMetadata.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcSession.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcSession.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcSessionStore.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcSessionStore.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcStateStore.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcStateStore.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcTokenResponse.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcTokenResponse.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcUser.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcUser.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcValidationException.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcValidationException.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/PkceUtils.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/PkceUtils.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/RolesAllowed.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/RolesAllowed.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/ScopesAllowed.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ScopesAllowed.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/TokenClient.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/TokenClient.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcAuthPolicyTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcAuthPolicyTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcMiddlewareAuthzTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcMiddlewareAuthzTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcOpenApiInteropTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcUserScopesTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcUserScopesTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/APIResponse.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/APIResponse.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/APIResponses.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/APIResponses.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiOperation.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ApiOperation.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ArraySchema.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ArraySchema.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/Content.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Content.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiBuilder.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiContributor.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiContributor.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiContributorRegistry.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiContributorRegistry.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiExtension.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiOperationContribution.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiOperationContribution.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiResponseContribution.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiResponseContribution.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/Parameter.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Parameter.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ParameterIn.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ParameterIn.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/Parameters.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Parameters.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/Schema.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Schema.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/SchemaProperty.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/SchemaProperty.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/SchemaType.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/SchemaType.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/ext/openapi/OpenApiBuilderTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/ext/openapi/OpenApiExtensionTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiExtensionTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/GraphSerializer.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/GraphSerializer.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/RouteViewerDataHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/RouteViewerDataHandler.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/RouteViewerExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/RouteViewerExtension.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/RouteViewerHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/RouteViewerHandler.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/RouteViewerStaticHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/RouteViewerStaticHandler.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/model/RouteGraph.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/model/RouteGraph.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/model/RouteRecord.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/model/RouteRecord.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/model/RouterNode.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/model/RouterNode.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/BaseViewExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/BaseViewExtension.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/BaseViewHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/BaseViewHandler.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/GlobalValue.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/GlobalValue.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/RenderedView.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/RenderedView.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/ViewModel.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/ViewModel.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/ViewRuntimeBridge.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/ViewRuntimeBridge.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/test/java/dev/relism/ext/view/core/ViewModelTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/test/java/dev/relism/flash/ext/view/core/ViewModelTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/README.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/docs/handlers.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/docs/handlers.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.class" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.class" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteExtension.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteHandler.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteRuntime.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteRuntime.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteSettings.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteSettings.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteTarget.java" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteTargetResolver.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteTargetResolver.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/Template.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/Template.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteExtensionTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteExtensionTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteRuntimeTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteRuntimeTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteSettingsTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteSettingsTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteTargetResolverTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteTargetResolverTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/model/HomePage.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/model/HomePage.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/resources/templates/pages/home.jte" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/resources/templates/pages/home.jte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/README.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/docs/fragments.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/docs/fragments.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/docs/handlers.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/docs/handlers.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/Fragment.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/Fragment.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/Template.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/Template.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafExtension.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafHandler.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafRuntime.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafRuntime.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafSettings.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafSettings.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafTarget.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafTarget.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafTargetResolver.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafTargetResolver.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/ext/view/thymeleaf/ThymeleafExtensionTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafExtensionTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/ext/view/thymeleaf/ThymeleafRuntimeTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafRuntimeTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/ext/view/thymeleaf/ThymeleafSettingsTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafSettingsTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/ext/view/thymeleaf/ThymeleafTargetResolverTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafTargetResolverTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/README.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/asset-sources.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/asset-sources.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/configuration.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/configuration.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/dev-lifecycle.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/dev-lifecycle.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/frontend-selection.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/frontend-selection.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/modes.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/modes.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/package-managers.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/package-managers.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/performance.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/performance.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/prod-serving.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/prod-serving.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/routing-fallback.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/routing-fallback.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/security-policies.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/security-policies.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/pom.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetCatalog.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetCatalog.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetEntry.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetEntry.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetIo.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetIo.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetLoadRequest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetLoadRequest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetMetadata.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetMetadata.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetPaths.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetPaths.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetsSource.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetsSource.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetsSources.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetsSources.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/BasePathEnforcementMode.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/BasePathEnforcementMode.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/ClasspathAssetManifest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ClasspathAssetManifest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/ClasspathAssetsSource.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ClasspathAssetsSource.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/CommandOrchestrator.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandOrchestrator.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/CommandSafetyMode.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandSafetyMode.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/CommandSafetyPolicy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandSafetyPolicy.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/CommandTokens.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandTokens.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/FilesystemAssetsSource.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FilesystemAssetsSource.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/FrontendStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendStrategy.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/FrontendType.java" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/FrontendTypeResolver.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendTypeResolver.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/InstallCache.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/InstallCache.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/InstallPolicy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/InstallPolicy.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/LoggingMode.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/LoggingMode.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/MimeTypes.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/MimeTypes.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/ModeResolver.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ModeResolver.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/OperationMode.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/OperationMode.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/PackageManager.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/PackageManager.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/PackageManagerAdapter.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/PackageManagerAdapter.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/RuntimeEnvironment.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/RuntimeEnvironment.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/RuntimeMode.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/RuntimeMode.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/SpaFallbackPolicy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/SpaFallbackPolicy.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/StaticAssetServingPolicy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticAssetServingPolicy.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/ViteFrontendStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ViteFrontendStrategy.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/WatchList.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WatchList.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/WebBundlerConfig.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerConfig.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/WebBundlerExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerExtension.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/WebBundlerRuntime.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerRuntime.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/AssetsSourceTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/AssetsSourceTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/CommandSafetyPolicyTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/CommandSafetyPolicyTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/InstallCacheTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/InstallCacheTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/ModeResolverTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/ModeResolverTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/PackageManagerAdapterTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/PackageManagerAdapterTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/WebBundlerConfigTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerConfigTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/WebBundlerExtensionDevGuardTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionDevGuardTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/WebBundlerExtensionIntegrationTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionIntegrationTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/pom.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/ChunkedInputStream.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/ChunkedInputStream.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/Flash.java" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/HttpServer.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/HttpServer.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/RequestParser.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/RequestParser.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/ServerHandle.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/ServerHandle.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/api/multipart/Multipart.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/api/multipart/Multipart.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/api/multipart/Part.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/api/multipart/Part.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/exceptions/HttpException.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/exceptions/HttpException.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/exceptions/InitializationException.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/exceptions/InitializationException.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/AnnotationProcessor.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/AnnotationProcessor.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/ExtensionPhase.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/ExtensionPhase.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashApp.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashApp.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashConfiguration.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashConfiguration.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashContext.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashContext.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashExtension.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashRegistrar.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashRegistrar.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashScope.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashScope.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/PackageScanner.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/PackageScanner.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/RouteDefinition.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/RouteDefinition.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/RouteEvent.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/RouteEvent.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/RouteListener.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/RouteListener.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/http/ContentType.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/http/ContentType.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/http/HttpMethod.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/http/HttpMethod.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/http/HttpStatus.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/http/HttpStatus.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/HeaderMap.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/HeaderMap.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/PathParams.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/PathParams.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/QueryParams.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/QueryParams.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/Request.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/Request.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/RequestBody.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/RequestBody.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/RequestHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/RequestHandler.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/RequestHelper.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/RequestHelper.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/RequestLine.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/RequestLine.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/Response.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/Response.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/SimpleHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/SimpleHandler.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/AbstractRouter.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/CONNECT.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/CONNECT.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/DELETE.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/DELETE.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/GET.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/GET.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/HEAD.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/HEAD.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/Middleware.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/Middleware.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/OPTIONS.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/OPTIONS.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/PATCH.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/PATCH.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/POST.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/POST.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/PURGE.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/PURGE.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/PUT.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/PUT.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/PathUtils.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/PathUtils.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/Route.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/Route.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/Routes.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/Routes.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/TRACE.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/TRACE.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImpl.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterImpl.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathViews.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathViews.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/routers/radix/RadixPathRouterImpl.java" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/template/ByteTemplate.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/template/ByteTemplate.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/template/ErrorPages.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/template/ErrorPages.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/ChunkedInputStreamTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/ChunkedInputStreamTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/HttpServerConcurrencyTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/HttpServerConcurrencyTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/HttpServerTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/HttpServerTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/RequestParserTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/RequestParserTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/api/multipart/MultipartTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/api/multipart/MultipartTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/http/ContentTypeTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/http/ContentTypeTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/http/HttpMethodTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/http/HttpMethodTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/http/HttpStatusTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/http/HttpStatusTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/HeaderMapTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/HeaderMapTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/PathParamsTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/PathParamsTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/QueryParamsTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/QueryParamsTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/RequestBodyTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/RequestBodyTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/RequestLineTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/RequestLineTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/RequestTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/RequestTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/ResponseTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/ResponseTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/SimpleHandlerTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/SimpleHandlerTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/AbstractRouterTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/routing/AbstractRouterTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/PathUtilsTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/routing/PathUtilsTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImplTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterImplTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathViewsTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathViewsTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/template/ByteTemplateTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/template/ByteTemplateTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/template/ErrorPagesTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/template/ErrorPagesTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/pom.xml" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" /> <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" /> <option name="LAST_RESOLUTION" value="IGNORE" />
</component> </component>
<component name="CopilotChats">
<option name="panelChat">
<chat>
<option name="activeSessionId" value="a8d77f48-1eb9-4c5a-961c-1e1452a25ca0" />
<option name="sessions">
<session>
<option name="chatType" value="PANEL" />
<option name="id" value="a8d77f48-1eb9-4c5a-961c-1e1452a25ca0" />
<option name="modeId" value="Agent" />
<option name="modelType" value="builtin_family" />
<option name="modelValue" value="auto" />
<option name="status" value="Completed" />
<option name="targetType" value="LOCAL" />
</session>
<session>
<option name="chatType" value="PANEL" />
<option name="id" value="82f48e09-2ca4-4a27-9a04-c65d7a5f0b88" />
<option name="modeId" value="Agent" />
<option name="modelType" value="builtin_family" />
<option name="modelValue" value="auto" />
<option name="targetType" value="LOCAL" />
</session>
</option>
<option name="type" value="PANEL" />
</chat>
</option>
</component>
<component name="CopilotPersistence"> <component name="CopilotPersistence">
<persistenceIdMap> <persistenceIdMap>
<entry key="_C:/Users/elorc/Documents/Coding/Java/practice/Flash" value="3Axc6mzLyNvh4TgGFvNSCSq83xw" /> <entry key="_C:/Users/elorc/Documents/Coding/Java/practice/Flash" value="3Axc6mzLyNvh4TgGFvNSCSq83xw" />
@@ -298,7 +48,7 @@
</persistenceIdMap> </persistenceIdMap>
</component> </component>
<component name="EmbeddingIndexingInfo"> <component name="EmbeddingIndexingInfo">
<option name="cachedIndexableFilesCount" value="407" /> <option name="cachedIndexableFilesCount" value="448" />
<option name="fileBasedEmbeddingIndicesEnabled" value="true" /> <option name="fileBasedEmbeddingIndicesEnabled" value="true" />
</component> </component>
<component name="FileTemplateManagerImpl"> <component name="FileTemplateManagerImpl">
@@ -534,7 +284,17 @@
<workItem from="1776931805422" duration="16122000" /> <workItem from="1776931805422" duration="16122000" />
<workItem from="1777051880577" duration="2650000" /> <workItem from="1777051880577" duration="2650000" />
<workItem from="1777150747725" duration="837000" /> <workItem from="1777150747725" duration="837000" />
<workItem from="1777198150359" duration="638000" /> <workItem from="1777198150359" duration="5628000" />
<workItem from="1777451402985" duration="709000" />
<workItem from="1777534738187" duration="13411000" />
<workItem from="1777970837797" duration="5042000" />
<workItem from="1778160998498" duration="2931000" />
<workItem from="1778319397472" duration="5040000" />
<workItem from="1778352978922" duration="2169000" />
<workItem from="1778414714349" duration="23000" />
<workItem from="1778417425077" duration="3241000" />
<workItem from="1778489168036" duration="9828000" />
<workItem from="1778576795735" duration="5051000" />
</task> </task>
<task id="LOCAL-00001" summary="Initial"> <task id="LOCAL-00001" summary="Initial">
<option name="closed" value="true" /> <option name="closed" value="true" />
@@ -632,7 +392,31 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1776724331443</updated> <updated>1776724331443</updated>
</task> </task>
<option name="localTasksCounter" value="13" /> <task id="LOCAL-00013" summary="refactor: rename packages and files to use 'flash' prefix for consistency">
<option name="closed" value="true" />
<created>1777199691803</created>
<option name="number" value="00013" />
<option name="presentableId" value="LOCAL-00013" />
<option name="project" value="LOCAL" />
<updated>1777199691804</updated>
</task>
<task id="LOCAL-00014" summary="fix: enhance global key validation and update template parameters">
<option name="closed" value="true" />
<created>1777451475753</created>
<option name="number" value="00014" />
<option name="presentableId" value="LOCAL-00014" />
<option name="project" value="LOCAL" />
<updated>1777451475753</updated>
</task>
<task id="LOCAL-00015" summary="feat: introduce WebSocket support with new endpoints and transaction propagation enhancements">
<option name="closed" value="true" />
<created>1778508899541</created>
<option name="number" value="00015" />
<option name="presentableId" value="LOCAL-00015" />
<option name="project" value="LOCAL" />
<updated>1778508899541</updated>
</task>
<option name="localTasksCounter" value="16" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -674,7 +458,12 @@
<MESSAGE value="preparing for another refactoring..." /> <MESSAGE value="preparing for another refactoring..." />
<MESSAGE value="implement OpenAPI contributor integration for rate limiting and response headers" /> <MESSAGE value="implement OpenAPI contributor integration for rate limiting and response headers" />
<MESSAGE value="add core view extension with JTE and Thymeleaf support" /> <MESSAGE value="add core view extension with JTE and Thymeleaf support" />
<option name="LAST_COMMIT_MESSAGE" value="add core view extension with JTE and Thymeleaf support" /> <MESSAGE value="refactor: rename packages and files to use 'flash' prefix for consistency" />
<MESSAGE value="fix: enhance global key validation and update template parameters" />
<MESSAGE value="chore(release): prepare 2.1.0-SNAPSHOT" />
<MESSAGE value="feat: introduce Spec and Query interfaces with transaction propagation enhancements" />
<MESSAGE value="feat: introduce WebSocket support with new endpoints and transaction propagation enhancements" />
<option name="LAST_COMMIT_MESSAGE" value="feat: introduce WebSocket support with new endpoints and transaction propagation enhancements" />
</component> </component>
<component name="XSLT-Support.FileAssociations.UIState"> <component name="XSLT-Support.FileAssociations.UIState">
<expand /> <expand />
+5 -2
View File
@@ -36,9 +36,9 @@ Format: `<type>(<scope>): <short description>`
| `chore` | Build, deps, tooling — no production code | | `chore` | Build, deps, tooling — no production code |
| `ci` | Changes to GitHub Actions workflows | | `ci` | Changes to GitHub Actions workflows |
Allowed scopes: `core`, `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-data-core`, `ext-data-jdbc`, `ext-data-hibernate`, `release`, `deps`, `ci`. `ext-mcp`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`, `release`, `deps`, `ci`.
Examples: Examples:
``` ```
@@ -85,6 +85,9 @@ chore(release): 2.1.0
- Root POM: `flash-parent` — defines all dependency versions and plugin config. - Root POM: `flash-parent` — defines all dependency versions and plugin config.
- `flash` module: the core framework JAR. - `flash` module: the core framework JAR.
- `flash-testing` module: JUnit 5 harness for testing Flash applications. Deliberately not
under `flash-extensions/` — it is not something you `install()`, and it carries
`junit-jupiter-api` at compile scope.
- `flash-extensions` POM: aggregator for all extension modules. - `flash-extensions` POM: aggregator for all extension modules.
- Extensions live under `flash-extensions/flash-ext-*/`. - Extensions live under `flash-extensions/flash-ext-*/`.
- When adding a new extension: - When adding a new extension:
+361 -32
View File
@@ -1,19 +1,21 @@
# Flash # Flash
A high-performance HTTP/1.1 server library for Java 21, built around virtual threads and a zero-allocation FSM router. A high-performance HTTP/1.1 and HTTP/2 server library for Java 21, built around virtual threads,
a zero-allocation FSM router, bounded protocol state, and one shared request/response API.
## Modules ## Modules
| Module | Description | | Module | Description |
|---|---| |---|---|
| `flash` | Core server library — router, request parser, HTTP I/O transport | | `flash` | Core server library — HTTP/1.1 and HTTP/2 transport, router, request/response model |
| `flash-testing` | JUnit 5 harness — boot an app on an ephemeral port, fake its services, assert on responses |
| `flash-extensions/flash-ext-jackson` | Jackson JSON integration | | `flash-extensions/flash-ext-jackson` | Jackson JSON integration |
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI | | `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI |
| `flash-extensions/flash-ext-oidc` | OIDC Authorization Code + PKCE flow | | `flash-extensions/flash-ext-oidc` | OIDC Authorization Code + PKCE flow |
| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, optional OAuth2 via flash-ext-oidc |
| `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-bench` | Demo harness (OIDC + OpenAPI + Jackson) |
## Requirements ## Requirements
@@ -57,31 +59,31 @@ app.post("/echo", (req, res) -> {
}); });
app.get("/users/{id}", (req, res) -> { app.get("/users/{id}", (req, res) -> {
String id = req.pathParam("id"); String id = req.param("id");
return "user:" + id; return "user:" + id;
}); });
``` ```
### Class-based handlers ### Class-based handlers
Extend `RequestHandler` (or a subclass like `JacksonHandler`) and annotate with `@Route`: Extend `RequestHandler`, annotate it, then scan its package. Dependencies are cached in
`onInit()` after Flash has resolved its complete boot-time service graph:
```java ```java
@Route(method = HttpMethod.GET, path = "/api/users") @GET("/api/users")
public class ListUsers extends JacksonHandler { public class ListUsers extends RequestHandler {
@Override private UserService users;
public Object handle(Request req, Response res) throws Exception {
return json(res, List.of("alice", "bob")); @Override protected void onInit() { users = require(UserService.class); }
} @Override public Object handle(Request req, Response res) { return users.list(); }
} }
// Register: app.scan("dev.example.api");
app.register(new ListUsers());
``` ```
### Middleware ### Middleware
Apply middleware via `.with()` on the `RouteHandle` returned by any registration call: Apply middleware at registration. Flash composes the final chain at boot:
```java ```java
Middleware authCheck = next -> (req, res) -> { Middleware authCheck = next -> (req, res) -> {
@@ -90,14 +92,13 @@ Middleware authCheck = next -> (req, res) -> {
return next.handle(req, res); return next.handle(req, res);
}; };
app.get("/secure", (req, res) -> "secret data") app.get("/secure", (req, res) -> "secret data", authCheck);
.with(authCheck);
``` ```
Multiple middlewares are composed outermost-first (left-to-right in the call): Multiple middlewares are composed outermost-first (left-to-right in the call):
```java ```java
app.get("/admin", handler).with(logging, auth, rateLimit); app.get("/admin", handler, logging, auth, rateLimit);
// execution order: logging → auth → rateLimit → handler // execution order: logging → auth → rateLimit → handler
``` ```
@@ -119,22 +120,22 @@ processors, services):
```java ```java
app.mount("/api", scope -> { app.mount("/api", scope -> {
scope.get("/health", (req, res) -> "ok"); // → GET /api/health scope.get("/health", (req, res) -> "ok"); // → GET /api/health
scope.register(new UserHandler()); // @Route(path="/users") → GET /api/users
scope.scan("dev.example.api"); scope.scan("dev.example.api");
}); });
``` ```
## Extensions ## Extensions
Extensions are installed before route registration. Each extension receives the `FlashRegistrar` Extensions have one declarative `configure` method. They declare services, processors and route
and `FlashContext` — it can register routes, expose services, and register annotation processors. callbacks; Flash resolves the complete graph, materialises routes, compiles both routers, then
opens listeners. Extension install order never makes a service “not ready”.
```java ```java
FlashApp.create(8080) FlashApp.create(8080)
.install(new JacksonExtension()) .install(new JacksonExtension())
.install(new OpenApiExtension("/openapi", "My API", "1.0.0")) .install(new OpenApiExtension("/openapi", "My API", "1.0.0"))
.install(new OidcExtension(oidcConfig)) .install(new OidcExtension(oidcConfig))
.register(new MyHandler()) .scan("dev.example.handlers")
.start(); .start();
``` ```
@@ -142,8 +143,10 @@ See extension-specific READMEs for full details:
- [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md) - [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md)
- [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/README.md) - [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/README.md)
- [`flash-ext-oidc`](flash-extensions/flash-ext-oidc/README.md) - [`flash-ext-oidc`](flash-extensions/flash-ext-oidc/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-testing`](flash-testing/docs/README.md)
## Error handlers ## Error handlers
@@ -163,24 +166,353 @@ app.onException((ex, req, res) -> {
|---|---|---| |---|---|---|
| `port` | — | TCP port to bind | | `port` | — | TCP port to bind |
| `host` | `"0.0.0.0"` | Bind address | | `host` | `"0.0.0.0"` | Bind address |
| `tls` | `null` | TLS for the default listener — see [TLS](#tls) |
| `listeners` | `[]` | Multiple bind targets (port + host + optional TLS) on one app — see [TLS](#tls) |
| `maxHeaderBufferSize` | `65536` | Max size of the header buffer (bytes) | | `maxHeaderBufferSize` | `65536` | Max size of the header buffer (bytes) |
| `wsFrameBufferSize` | `65536` | Per-connection WebSocket read buffer (bytes) |
| `headerReadTimeoutMs` | `10000` | Once a request's first byte arrives, how long the full header block may take. Bounds slowloris-style attacks — see [`HTTP1-HARDENING.md`](flash/docs/core/HTTP1-HARDENING.md). |
| `idleKeepAliveTimeoutMs` | `60000` | How long a keep-alive connection may sit idle waiting for its next request. |
| `bodyReadTimeoutMs` | `30000` | How long reading a request body (handler or automatic drain) may take. |
| `shutdownDrainTimeoutMs` | `15000` | How long graceful shutdown waits for in-flight requests before force-closing. |
| `maxConnections` | auto (~heap/10MB) | Maximum concurrent connections across all listeners before new ones are closed immediately at accept time, before any per-connection state (TLS handshake included) is created. Auto-scales from `Runtime.maxMemory()`; set explicitly for a known deployment size, or `0` to disable. |
| `http2Enabled` | `false` | Whether TLS listeners advertise HTTP/2 through ALPN. |
| `http2CleartextEnabled` | `false` | Whether plaintext listeners accept HTTP/2 prior knowledge (h2c). Independent from TLS HTTP/2. |
| `h2HuffmanDynamicValues` | `false` | HPACK-Huffman encode runtime response values. Constants remain pre-encoded; the measured default avoids an extra encode pass. |
| `h2MaxResetStreamsPerInterval` | `200` | Rapid Reset budget per rolling interval. |
| `h2MaxStreamsCreatedPerInterval` | `400` | New-stream budget per rolling interval. |
| `h2AbuseRateIntervalMs` | `10000` | Rolling interval for the two operator-tunable rate limits above. |
| `h2MaxStreamsPerConnection` | `100000` | Total stream budget; `0` disables it. |
| `h2MaxBytesPerConnection` | `0` | Optional total wire-byte budget; `0` disables it. |
| `h2MaxConnectionLifetimeMs` | `0` | Optional connection lifetime; `0` disables it. |
| `h2StreamIdleTimeoutMs` | `60000` | Inactive open-stream deadline. |
| `sendDate` | `true` | Add an RFC 9110 `Date` field to responses; disable when an upstream proxy supplies it. |
## Protocols
Routes, middleware, `Request`, `Response`, bodies, trailers, streaming and WebSockets use the same
API on HTTP/1.1 and HTTP/2. Protocol selection happens once per connection:
- On TLS listeners, enable `http2Enabled`; Flash advertises `h2` and `http/1.1` through ALPN and
uses the protocol selected by the client. Existing HTTP/1.1 clients continue to work.
- On plaintext listeners, enable `http2CleartextEnabled` to accept the HTTP/2 prior-knowledge
preface on the same port as HTTP/1.1. Clients that do not send that exact preface are parsed as
HTTP/1.1.
- With both switches left at their default `false`, Flash behaves as an HTTP/1.1 server.
After enabling the appropriate switch, application routes need no protocol-specific code. TLS
still requires the normal certificate configuration shown below.
Flash deliberately does not implement HTTP/2 server push, RFC 7540 dependency-tree priority
scheduling, or the obsolete HTTP/1.1 `Upgrade: h2c` transition. Server push has no application API,
RFC 9113 deprecated the old priority scheme, and cleartext HTTP/2 uses prior knowledge instead.
See the [HTTP/2 compliance record](flash/docs/http2/COMPLIANCE.md) for exact coverage.
## WebSockets over HTTP/2
The same `ws(path, handler)` route serves WebSockets over HTTP/1.1 and HTTP/2. When HTTP/2 is
enabled, Flash advertises RFC 8441 extended CONNECT support and carries WebSocket frames inside
flow-controlled DATA frames. No alternate handler, route, or session API is required:
```java
app.ws("/live", handler);
```
HTTP/1.1 clients use the ordinary `101 Switching Protocols` upgrade. HTTP/2 clients use an
extended CONNECT and receive status `200`; Flash applies the same RFC 6455 framing, masking,
fragmentation, close, and callback behavior on both transports. Client support for negotiating
WebSockets over HTTP/2 varies, so clients without RFC 8441 support continue to use HTTP/1.1.
## TLS
HTTPS and WSS are a transport-layer concern only. Once a listener is bound, the accepted socket
is plain or TLS; the selected HTTP connection implementation then performs either the HTTP/1.1
upgrade or the HTTP/2 extended CONNECT. WSS does not require a separate route or handler API.
### Quick start
```java
FlashApp.create(FlashConfiguration.builder()
.port(443)
.tls(TlsConfig.keystore(Path.of("cert.p12"), "changeit"))
.build())
.get("/ping", (req, res) -> "pong") // HTTPS
.ws("/live", handler) // WSS, same route API
.start();
```
### Multiple listeners
One app can bind any number of ports, each independently plain or TLS:
```java
FlashApp.create(FlashConfiguration.builder()
.listener(new FlashConfiguration.Listener(80)) // plain
.listener(new FlashConfiguration.Listener(443, TlsConfig.keystore(cert, pass))) // TLS
.build());
```
A non-empty `listeners` list takes precedence over the top-level `port`/`host`/`tls` fields.
Each listener gets its own accept threads; the router, WS router, and virtual-thread executor
are shared by all of them — one app, N ports.
### `TlsConfig`
| Factory | Use |
|---|---|
| `TlsConfig.keystore(Path, String)` | Builds the `SSLContext` from a PKCS12/JKS keystore (type guessed from the extension). Pins `TLSv1.2`/`TLSv1.3` as enabled protocols; cipher suites are left at the JDK's own curated default. |
| `TlsConfig.ofContext(SSLContext)` | Escape hatch — the given `SSLContext` is used exactly as built. Flash never calls `setSSLParameters` on this path beyond what you explicitly request via `clientAuth`/`applicationProtocols`, so anything else you configured (custom `KeyManager`, ALPN, cipher suites) is authoritative. |
Chainable on either factory:
```java
TlsConfig.keystore(cert, pass)
.clientAuth(ClientAuth.REQUIRE) // mTLS: NONE (default) | OPTIONAL | REQUIRE
.applicationProtocols("acme-tls/1", "http/1.1") // ALPN, in preference order
```
**SNI** falls out of `keystore()` for free: a keystore holding more than one certificate entry
is matched against the requested hostname by each certificate's SAN (falling back to CN) — no
per-hostname config. The first entry in the keystore is the default when SNI is absent or
matches nothing (same convention as nginx/HAProxy's `default_server`).
**ALPN and custom certificate selection** (e.g. TLS-ALPN-01 / RFC 8737 for on-demand ACME
issuance): ALPN is resolved while consuming `ClientHello`/producing `ServerHello`, which always
precedes `Certificate` production. A custom `X509ExtendedKeyManager` passed via `ofContext`
can therefore read `engine.getHandshakeApplicationProtocol()` (or
`((SSLSocket) socket).getHandshakeApplicationProtocol()`) inside
`chooseEngineServerAlias`/`chooseServerAlias` — the negotiated protocol is already resolved by
then, so the certificate decision can key off it.
**mTLS with a private CA**: `clientAuth(...)` only requests/requires a client certificate;
`keystore()` deliberately doesn't expose a way to configure which CAs are trusted for that
certificate (it uses the JDK default trust store). For a private CA, build the `SSLContext`
yourself with a `TrustManagerFactory` and use `ofContext(...)`.
### Reading TLS info from a request
```java
app.get("/whoami", (req, res) -> {
if (!req.isSecure()) return "plain";
SSLSession session = req.sslSession(); // null iff !isSecure()
X509Certificate peer = (X509Certificate) session.getPeerCertificates()[0]; // mTLS only
return session.getCipherSuite() + " / " + session.getProtocol();
});
```
`Request.isSecure()` / `Request.sslSession()` cost nothing extra per request: the `SSLSocket`
reference is threaded through once per connection (same mechanism as `remoteAddress()`), and
`sslSession()` only calls `SSLSocket#getSession()` — a cached-field read once the handshake
that got the request this far has already completed, never a forced handshake.
`WebSocketSession` mirrors this exactly (`isSecure()`, `sslSession()`) by delegating to the
upgrading `Request` — no separate TLS state is tracked for WS.
## Object lifetime
`Request` and `Response` are **pooled per connection**, not allocated per request: one instance is
created per connection and repositioned (`reset()`) over each new request/response in turn — the
same idiom Java NIO buffers use, applied to the whole request/response model
(`flash/docs/core/MESSAGE-MODEL.md` has the full design record). This is what makes a warm h1
request/response cycle 0 B/op.
**Do not retain a `Request` or `Response` past the handler that received it.** A reference kept in
a field, a captured closure, a `CompletableFuture` continuation, or a background thread and read
*after* the handler returns will observe whatever the *next* request on that connection
repositioned the same instance to — not the request you thought you had:
```java
// WRONG — captures `req`, reads it after the handler has returned
app.get("/slow", (req, res) -> {
CompletableFuture.runAsync(() -> log(req.header("X-Trace-Id"))); // may log the NEXT request's header
return "ok";
});
```
Copy out whatever you need before returning or handing work off asynchronously — every accessor
that returns a `String` (`header`, `param`, `query`, `path`, …) gives you an independent heap copy
that's safe to keep as long as you like:
```java
app.get("/slow", (req, res) -> {
String traceId = req.header("X-Trace-Id"); // copy now, safe to retain
CompletableFuture.runAsync(() -> log(traceId));
return "ok";
});
```
Run with `-Dflash.env=dev` and a use-after-return access throws `IllegalStateException` immediately
at the offending call site instead of silently reading the wrong request's data — turn this on in
tests and local development. It's a no-op in production beyond a single `boolean` field read.
`req.body()`/`RequestBody` follows the same rule — materialise (`.bytes()`) or fully consume
(`.stream()`) it inside the handler; don't stash the `RequestBody` itself for later.
### Reusable response headers
Use `PreEncodedHeader` for a constant header sent by many responses. It stores the name and value
once and remains valid on both HTTP versions:
```java
private static final PreEncodedHeader NO_STORE =
new PreEncodedHeader("cache-control", "no-store");
app.get("/health", (req, res) -> res.header(NO_STORE).body("ok"));
```
`Response.header(byte[])` accepts a complete CRLF-terminated HTTP/1 field line and is therefore
HTTP/1-only; HPACK needs the name and value as separate fields. Prefer `PreEncodedHeader` for shared
application and middleware code.
### Trailers and push streaming
Request trailers become available after the body reaches EOF:
```java
byte[] payload = req.body().bytes();
String status = req.trailers().first("grpc-status");
```
For a producer-driven response, `Response.streaming` provides a blocking `ResponseStream`. Its
bounded buffer and HTTP/2 flow-control windows apply backpressure directly to the producer's
virtual thread:
```java
return res.streaming(stream -> {
try {
stream.write(payload, 0, payload.length);
stream.trailer("result", "complete");
} catch (IOException failure) {
throw new UncheckedIOException(failure);
}
});
```
The API renders as chunked data and trailers on HTTP/1.1, and DATA plus trailing HEADERS on
HTTP/2. Flash core supplies these transport primitives; a higher-level gRPC codec belongs in a
future `flash-ext-grpc` extension.
## Testing
`flash-testing` boots a real app on an OS-assigned port for the duration of a test, and hands you
a client pointed at it. Add it with test scope:
```xml
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-testing</artifactId>
<version>${flash.version}</version>
<scope>test</scope>
</dependency>
```
```java
class UserRoutesTest {
@RegisterExtension
static FlashTest app = FlashTest.of(new BlogApp())
.mock(UserService.class, new InMemoryUserService());
@Test
void listsUsers() {
app.get("/api/users")
.expectStatus(200)
.expectHeader("content-type", "application/json")
.expectBodyContains("alice");
}
}
```
`FlashTest.of` takes a `FlashApplication` — your app's routes, extensions and services expressed
independently of which port they run on:
```java
public final class BlogApp implements FlashApplication {
@Override public void configure(FlashApp app) {
app.install(new JacksonExtension());
app.mount("/api", scope -> scope.scan("dev.blog.api"));
}
}
FlashApp.create(8080).apply(new BlogApp()).startAndBlock(); // production
```
It is a functional interface, so a lambda works too:
`FlashTest.of(app -> app.get("/ping", (req, res) -> "pong"))`.
### Requests
The HTTP verb sends the request; `expect*` assertions chain and report the real response body on
failure. `get` and `delete` skip the builder when there is nothing to add.
```java
app.get("/api/users").expectStatus(200);
app.request()
.header("Authorization", "Bearer " + token)
.json("{\"name\":\"bob\"}")
.post("/api/users")
.expectStatus(201);
try (FlashWebSocket socket = app.ws("/live")) {
socket.sendText("hello");
assertEquals("echo:hello", socket.awaitText(Duration.ofSeconds(2)));
}
```
### Replacing services
`mock` installs replacements after everything your app and its extensions declare, so a fake always
wins. Any object will do — `flash-testing` depends on no mocking library, so a hand-written fake and
a Mockito mock are equally welcome.
### More than one server
`FlashTest` is an ordinary object in a field, so a test class can hold as many as it needs and wire
one from another in plain Java. Startup is lazy — reading `baseUri()` boots that server on the spot
— so declaration order does the wiring:
```java
@RegisterExtension static FlashTest auth = FlashTest.of(new FakeOidcApp());
@RegisterExtension static FlashTest api = FlashTest.of(new BlogApp(auth.baseUri()));
```
### Scope
A `static` field boots once for the test class; a non-static field boots a fresh app for every test.
That is stock JUnit field semantics — the isolation switch is the keyword, not an option.
### Configuration
Full reference: [`flash-testing/docs`](flash-testing/docs/README.md), including the
[limits](flash-testing/docs/limits.md) the harness deliberately does not cross.
`profile` customises the `FlashConfiguration` — timeouts, HTTP/2 switches, buffer sizes. Host, port
and the shutdown drain window are stamped afterwards, so a profile cannot break the harness;
`listener(...)` and `tls(...)` are rejected because the harness owns the loopback listener it gives
you a client for.
```java
FlashTest.of(new BlogApp()).profile(cfg -> cfg.http2CleartextEnabled(true));
```
## Architecture ## Architecture
``` ```
ServerSocket.accept() TransportFactory.create() # binds every listener, wires the connection runner
RequestParser.parse() # zero-alloc header parsing, buffer reuse across keep-alive AcceptLoop # one per listener × accept thread; hands sockets off
GlobalRouter.route() # two-tier: mounted sub-routers (longest prefix) then FastPathRouterImpl ConnectionRunner.accept() # per-connection setup: TLS handshake, protocol negotiation
→ RequestHandler.handle() # user handler; return value sets body → ProtocolNegotiator # ALPN / h2c-preface — decides the protocol once
→ Request.drain() # consume unread body for keep-alive ├─ Http1Connection.run() # request parser, router, handler, h1 response writer
→ HttpServer writes response # status line, headers, then fixed or chunked body └─ Http2Connection.run() # frame demux, HPACK, stream dispatch, flow control
→ loop or close socket # based on Connection header → RequestHandler.handle() # the same protocol-neutral request/response API
``` ```
- **Virtual threads** — each accepted socket runs on a virtual thread (`Executors.newVirtualThreadPerTaskExecutor()`). Java 21 required. - **Virtual threads** — each accepted socket runs on a virtual thread (`Executors.newVirtualThreadPerTaskExecutor()`, owned by `TransportFactory`). Java 21 required.
- **Zero-allocation router** — `FastPathRouterImpl` uses `fpr-core`, a byte-level FSM that matches on `METHOD + path` bytes with no per-request allocation. - **Zero-allocation router** — `FastPathRouterImpl` uses `fpr-core`, a byte-level FSM that matches on `METHOD + path` bytes with no per-request allocation.
- **Keep-alive** — `RequestParser` reuses its header buffer across requests on the same connection. - **Keep-alive** — `RequestParser` reuses its header buffer across requests on the same connection.
- **Chunked transfer** — both chunked request bodies (decoded via `ChunkedInputStream`) and chunked response bodies are supported. - **Chunked transfer** — both chunked request bodies (decoded via `ChunkedInputStream`) and chunked response bodies are supported.
- **TLS is transport-only** — see [TLS](#tls). Listeners bind either a plain `ServerSocket` or an `SSLServerSocket`; nothing downstream of `accept()` branches on which.
- **`ConnectionProtocol` seam** — HTTP/1.1 and HTTP/2 are peers behind this interface, selected once per connection by `ProtocolNegotiator`; routing and application models are shared.
## Build & test ## Build & test
@@ -193,7 +525,4 @@ mvn test
# Run a single test class # Run a single test class
mvn test -pl flash -Dtest=RequestParserTest mvn test -pl flash -Dtest=RequestParserTest
# Run the benchmark demo server
java -jar flash-bench/target/flash-bench-1.0-SNAPSHOT.jar
``` ```
+24
View File
@@ -0,0 +1,24 @@
config:
target: "ws://localhost:8080/echo"
engines:
ws: {}
phases:
- duration: 30
arrivalRate: 50
rampTo: 500
name: "Riscaldamento progressivo"
- duration: 120
arrivalRate: 1000 # 1000 nuovi utenti al secondo
name: "Carico Estremo"
ensure:
maxErrorRate: 5
p99: 150
scenarios:
- name: "Saturazione Totale"
engine: ws
flow:
- loop:
- send: "Benchmark data"
# Rimosso il 'think' per eliminare il limite artificiale di 10msg/s per utente
count: 100 # Ogni utente spara a raffica 100 messaggi senza pause
@@ -0,0 +1,125 @@
# flash-ext-data-core
Shared core for Flash's data layer.
## Purpose
This module defines the transactional contract shared across backend implementations. It does not
talk to Hibernate or JDBC directly: it exposes abstractions and a minimal runtime, nothing else.
## Components
- `TxDefinition`: immutable transaction metadata.
- `TxStatus`: runtime state returned by the manager.
- `TxManager`: the `begin`/`commit`/`rollback` contract.
- `Tx`: runtime orchestration and the per-thread transaction stack.
- `ResourceRegistry`: thread-local storage for resources and synchronizations.
- `Repository<T, ID>`: self-transactional base repository.
- `Spec<T>`: composable predicate.
- `Query<T>`: query object carrying spec, sort and paging.
- `SpecBuilder<T>`: fluent DSL for building typed specs.
- `RepositorySupport<T, ID>`: shared internal helper.
- `TransactionPropagation`: propagation semantics.
- `TransactionIsolation`: isolation level.
- `TxSynchronization`: lifecycle hooks (see below).
## Execution model
The flow is:
1. `Tx.call(definition, work)` calls `TxManager.begin(definition)`.
2. The `TxManager` creates a backend-specific `TxStatus`.
3. The status is pushed onto the thread-local stack.
4. The work uses `Tx.resource(Class)` to obtain the current resource.
5. When the work ends, `Tx` chooses between `commit` and `rollback`.
6. The stack is popped, and the thread-local is cleared once it is empty.
## Supported propagation
- `REQUIRED`: use the active transaction, or open a new one.
- `REQUIRES_NEW`: suspend the current transaction and open a new one.
- `SUPPORTS`: join the active transaction if there is one, otherwise run without a transaction.
- `NOT_SUPPORTED`: suspend the current transaction and run without one.
- `MANDATORY`: require an active transaction.
## Synchronizations (`TxSynchronization`)
Lifecycle hooks for **one** transaction, registered through `Data.afterCommit(...)` (or directly
with `ResourceRegistry.addSynchronization(...)`).
Every callback belongs to exactly the innermost transaction active at registration time, and fires
exactly once, when *that* transaction completes:
- a **joined** inner transaction (`REQUIRED`) is not a transaction of its own, so callbacks
registered inside one wait for the outermost commit;
- a `REQUIRES_NEW` transaction is, so completing it fires only its own callbacks and leaves the
suspended outer transaction's pending.
### Which side of the commit each hook sits on
| hook | when | resource |
| --- | --- | --- |
| `beforeCommit(readOnly)` | immediately **before** the real commit | session/connection still **bound**, transaction still active |
| `afterCommit()` / `afterRollback()` | after completion | resource already **unbound** |
| `afterCompletion(outcome)` | after the two above | resource already unbound |
`beforeCommit` is the only hook that can still write through the same resource and have the write
land in the same atomic unit: flush a buffer, stamp an audit row, materialize a derived value. It
is skipped when the transaction is already `rollback-only`, since there is no commit to precede.
Post-completion callbacks run with the resource unbound instead: one that opens its own transaction
gets a **fresh** one rather than joining the transaction that just finished. That is what makes
them the right place to refresh a cache, enqueue a message, or notify anything outside the
database.
### Failure
Throwing from `beforeCommit` **vetoes the commit**: the transaction is rolled back,
`afterRollback`/`afterCompletion(ROLLED_BACK)` fire, and the exception reaches the caller. That is
the reason the hook runs before the commit rather than after — it can still refuse.
The post-completion hooks have no such power: the transaction is already over by the time they run,
so an exception propagates but changes nothing already committed, and stops the callbacks queued
behind it.
## Using `Repository`
`Repository` is the shared base for concrete repositories. Every public operation internally uses a
`REQUIRED` transaction, read-only where applicable.
Subclasses implement the `doXxx(...)` methods:
- `doFind(Query<T>)`
- `doFindOne(Spec<T>)`
- `doFindPage(Query<T>)`
- `doDeleteAll(Spec<T>)`
- `doUpdateAll(Spec<T>, T)`
The old `findAll(...)` and `findPage(...)` overloads were reduced to a combination of `Query<T>` and
`Spec<T>`.
```java
public abstract class Repository<T, ID> {
protected Repository(Tx tx) { ... }
protected final <R> R tx(Tx.TxCallable<R> work) { ... }
}
```
## Composing with Flash
`DataExtension` registers:
- `Tx` in the `FlashContext`
- `TxManager` in the `FlashContext`
- an annotation processor for `@Transactional`
This makes the data layer composable with Flash's extension system without global state.
## Implementation notes
- The transaction stack is thread-local and is cleared once it becomes empty.
- Backend resources are suspended and restored for `REQUIRES_NEW` and `NOT_SUPPORTED`.
- `TxSynchronization` is the hook point for commit/rollback/completion callbacks.
- Synchronizations live in a thread-local list; every new transaction records how many were already
registered when it opened and fires only its own tail, so a `REQUIRES_NEW` does not drag along
the suspended transaction's callbacks.
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId> <artifactId>flash-extensions</artifactId>
<version>2.0.0</version> <version>2.1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>flash-ext-data-core</artifactId> <artifactId>flash-ext-data-core</artifactId>
@@ -1,13 +1,15 @@
package dev.relism.flash.ext.data; package dev.relism.flash.ext.data;
import dev.relism.flash.ext.data.core.Tx; import dev.relism.flash.ext.data.core.Tx;
import dev.relism.flash.ext.data.core.Data;
import dev.relism.flash.ext.data.core.TxDefinition; import dev.relism.flash.ext.data.core.TxDefinition;
import dev.relism.flash.ext.data.core.TxManager; import dev.relism.flash.ext.data.core.TxManager;
import dev.relism.flash.ext.data.core.TransactionPropagation; import dev.relism.flash.ext.data.core.TransactionPropagation;
import dev.relism.flash.extension.ExtensionPhase; import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.extension.FlashContext; import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension; import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar; import dev.relism.flash.routing.MiddlewareKey;
import dev.relism.flash.routing.MiddlewareNode;
import dev.relism.flash.routing.Middleware; import dev.relism.flash.routing.Middleware;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
@@ -15,16 +17,26 @@ import java.util.List;
import java.util.Objects; import java.util.Objects;
public final class DataExtension implements FlashExtension { public final class DataExtension implements FlashExtension {
private static final MiddlewareKey TRANSACTION = MiddlewareKey.of("flash.data.transaction");
private final TxManager txManager; private final TxManager txManager;
private final Tx tx;
private final Data data;
public DataExtension(TxManager txManager) { public DataExtension(TxManager txManager) {
this(txManager, null);
}
public DataExtension(TxManager txManager, Data data) {
this.txManager = Objects.requireNonNull(txManager); this.txManager = Objects.requireNonNull(txManager);
this.data = data;
this.tx = data != null ? data.tx() : new Tx(txManager);
} }
@Override @Override
public void provide(FlashContext ctx) { public void configure(FlashRegistrar<?> app, FlashContext ctx) {
Tx.init(txManager); ctx.provide(Tx.class, tx);
ctx.provide(TxManager.class, txManager); ctx.provide(TxManager.class, txManager);
if (data != null) ctx.provide(Data.class, data);
ctx.addAnnotationProcessor(handlerClass -> { ctx.addAnnotationProcessor(handlerClass -> {
Transactional ann = handlerClass.getAnnotation(Transactional.class); Transactional ann = handlerClass.getAnnotation(Transactional.class);
if (ann == null) { if (ann == null) {
@@ -33,21 +45,17 @@ public final class DataExtension implements FlashExtension {
TxDefinition definition = TxDefinition.DEFAULTS TxDefinition definition = TxDefinition.DEFAULTS
.withPropagation(mapTxType(ann.value())); .withPropagation(mapTxType(ann.value()));
Middleware middleware = next -> (req, res) -> { Middleware middleware = next -> (req, res) -> {
return Tx.call(definition, () -> next.handle(req, res)); return tx.call(definition, () -> next.handle(req, res));
}; };
return List.of(middleware); return List.of(MiddlewareNode.of(TRANSACTION, middleware));
}); });
} }
@Override
public int priority() {
return ExtensionPhase.EARLY.value;
}
private TransactionPropagation mapTxType(Transactional.TxType txType) { private TransactionPropagation mapTxType(Transactional.TxType txType) {
return switch (txType) { return switch (txType) {
case REQUIRED, SUPPORTS -> TransactionPropagation.REQUIRED; case REQUIRED -> TransactionPropagation.REQUIRED;
case REQUIRES_NEW -> TransactionPropagation.REQUIRES_NEW; case REQUIRES_NEW -> TransactionPropagation.REQUIRES_NEW;
case SUPPORTS -> TransactionPropagation.SUPPORTS;
case MANDATORY -> TransactionPropagation.MANDATORY; case MANDATORY -> TransactionPropagation.MANDATORY;
case NOT_SUPPORTED, NEVER -> TransactionPropagation.NOT_SUPPORTED; case NOT_SUPPORTED, NEVER -> TransactionPropagation.NOT_SUPPORTED;
}; };
@@ -0,0 +1,46 @@
package dev.relism.flash.ext.data.core;
import java.util.Objects;
import java.io.Serializable;
import java.util.concurrent.ConcurrentHashMap;
/**
* Application-facing data gateway. Repositories are created once per entity type and are safe to
* share: transaction/session state stays in {@link Tx}, never in the repository instance.
*/
public final class Data {
private final Tx tx;
private final RepositoryFactory repositories;
private final ConcurrentHashMap<Class<?>, Repository<?, ?>> cache = new ConcurrentHashMap<>();
public Data(Tx tx, RepositoryFactory repositories) {
this.tx = Objects.requireNonNull(tx, "tx");
this.repositories = Objects.requireNonNull(repositories, "repositories");
}
public Tx tx() { return tx; }
@SuppressWarnings("unchecked")
public <T, ID extends Serializable> Repository<T, ID> repository(Class<T> type) {
Objects.requireNonNull(type, "type");
return (Repository<T, ID>) cache.computeIfAbsent(type, key -> repositories.create(tx, type));
}
public void read(Tx.TxRunnable work) { tx.run(tx.readOnly(), work); }
public <T> T read(Tx.TxCallable<T> work) { return tx.call(tx.readOnly(), work); }
public void write(Tx.TxRunnable work) { tx.run(work); }
public <T> T write(Tx.TxCallable<T> work) { return tx.call(work); }
/** Transitional low-level access for infrastructure that needs an explicit definition. */
public void run(Tx.TxRunnable work) { tx.run(work); }
public <T> T call(Tx.TxCallable<T> work) { return tx.call(work); }
public <T> T call(TxDefinition definition, Tx.TxCallable<T> work) { return tx.call(definition, work); }
public TxDefinition readOnly() { return tx.readOnly(); }
/** Registers work that runs only after the enclosing write transaction commits. */
public void afterCommit(Runnable work) {
if (!tx.isActive()) throw new IllegalStateException("afterCommit requires an active transaction");
ResourceRegistry.addSynchronization(new TxSynchronization() {
@Override public void afterCommit() { work.run(); }
});
}
}
@@ -0,0 +1,28 @@
package dev.relism.flash.ext.data.core;
public record Query<T>(Spec<T> spec, Sort sort, Integer page, Integer size) {
public Query {
spec = spec == null ? Spec.all() : spec;
sort = sort == null ? Sort.unsorted() : sort;
}
public static <T> Query<T> all() {
return new Query<>(Spec.all(), Sort.unsorted(), null, null);
}
public Query<T> where(Spec<T> spec) {
return new Query<>(spec, sort, page, size);
}
public Query<T> orderBy(Sort sort) {
return new Query<>(spec, sort, page, size);
}
public Query<T> page(int page, int size) {
return new Query<>(spec, sort, page, size);
}
public boolean isPaged() {
return page != null && size != null;
}
}
@@ -4,109 +4,118 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
/** public abstract class Repository<T, ID> extends RepositorySupport<T, ID> {
* Base repository. Subclasses only extend this — never HibernateRepository
* or JdbcRepository directly. The concrete backing is transparent.
*
* Every method auto-wraps in REQUIRED transaction — safe to call with or
* without an active transaction on the thread.
*/
public abstract class Repository<T, ID> {
private final TxDefinition required = TxDefinition.DEFAULTS protected Repository(Tx tx) {
.withPropagation(TransactionPropagation.REQUIRED); super(tx);
}
// ── CRUD ──────────────────────────────────────────────────────────────────
public Optional<T> findById(ID id) { public Optional<T> findById(ID id) {
return tx(() -> doFindById(id)); return roQuery(() -> doFindById(id));
}
public List<T> findAll() {
return tx(this::doFindAll);
}
public List<T> findAll(int page, int size) {
return tx(() -> doFindAll(page, size));
}
public List<T> findAll(Sort sort) {
return tx(() -> doFindAll(sort));
}
public List<T> findAll(int page, int size, Sort sort) {
return tx(() -> doFindAll(page, size, sort));
}
public Page<T> findPage(int page, int size) {
return tx(() -> doFindPage(page, size));
}
public Page<T> findPage(int page, int size, Sort sort) {
return tx(() -> doFindPage(page, size, sort));
}
public T save(T entity) {
return tx(() -> doSave(entity));
}
public List<T> saveAll(Iterable<T> entities) {
return tx(() -> {
List<T> saved = new ArrayList<>();
for (T e : entities) saved.add(doSave(e));
return saved;
});
}
public T update(T entity) {
return tx(() -> doUpdate(entity));
}
public void delete(T entity) {
tx(() -> { doDelete(entity); return null; });
}
public void deleteById(ID id) {
tx(() -> { doDeleteById(id); return null; });
}
public void deleteAll(Iterable<T> entities) {
tx(() -> { entities.forEach(this::doDelete); return null; });
} }
public boolean existsById(ID id) { public boolean existsById(ID id) {
return tx(() -> doExistsById(id)); return roQuery(() -> doExistsById(id));
} }
public long count() { public long count() {
return tx(this::doCount); return roQuery(this::doCount);
} }
// ── Auto-wrap helper ────────────────────────────────────────────────────── public List<T> findAll() {
return findAll(Query.all());
/**
* Ensures the work runs inside a transaction.
* If one is already active (caller annotated @Transactional or inside Tx.run)
* it joins it — no new connection opened.
* If none is active it opens one, commits, and closes it transparently.
*/
protected final <R> R tx(Tx.TxCallable<R> work) {
return Tx.call(required, work);
} }
// ── Abstract — implemented by HibernateRepository / JdbcRepository ──────── public List<T> findAll(Spec<T> spec) {
return findAll(Query.<T>all().where(spec));
}
public List<T> findAll(Query<T> query) {
return roQuery(() -> doFind(query));
}
public Page<T> findPage(Query<T> query) {
return roQuery(() -> doFindPage(query));
}
public Optional<T> findOne(Spec<T> spec) {
return roQuery(() -> doFindOne(spec));
}
public T save(T entity) {
return rwQuery(() -> doSave(entity));
}
public T update(T entity) {
return rwQuery(() -> doUpdate(entity));
}
public List<T> saveAll(Iterable<T> entities) {
return rwQuery(() -> doSaveAll(entities));
}
public void delete(T entity) {
rwQuery(() -> {
doDelete(entity);
return null;
});
}
public void deleteById(ID id) {
rwQuery(() -> {
doDeleteById(id);
return null;
});
}
public int deleteAll(Spec<T> spec) {
return rwQuery(() -> doDeleteAll(spec));
}
public int updateAll(Spec<T> spec, T patch) {
return rwQuery(() -> doUpdateAll(spec, patch));
}
public List<T> findAll(int page, int size) {
return findAll(Query.<T>all().page(page, size));
}
public List<T> findAll(Sort sort) {
return findAll(Query.<T>all().orderBy(sort));
}
public List<T> findAll(int page, int size, Sort sort) {
return findAll(Query.<T>all().orderBy(sort).page(page, size));
}
public Page<T> findPage(int page, int size) {
return findPage(Query.<T>all().page(page, size));
}
public Page<T> findPage(int page, int size, Sort sort) {
return findPage(Query.<T>all().orderBy(sort).page(page, size));
}
public void deleteAll(Iterable<T> entities) {
rwQuery(() -> {
for (T entity : entities) {
doDelete(entity);
}
return null;
});
}
protected abstract Optional<T> doFindById(ID id); protected abstract Optional<T> doFindById(ID id);
protected abstract List<T> doFindAll(); protected abstract List<T> doFind(Query<T> query);
protected abstract List<T> doFindAll(int page, int size); protected abstract Optional<T> doFindOne(Spec<T> spec);
protected abstract List<T> doFindAll(Sort sort); protected abstract Page<T> doFindPage(Query<T> query);
protected abstract List<T> doFindAll(int page, int size, Sort sort); protected abstract boolean doExistsById(ID id);
protected abstract Page<T> doFindPage(int page, int size); protected abstract long doCount();
protected abstract Page<T> doFindPage(int page, int size, Sort sort); protected abstract T doSave(T entity);
protected abstract T doSave(T entity); protected abstract List<T> doSaveAll(Iterable<T> entities);
protected abstract T doUpdate(T entity); protected abstract T doUpdate(T entity);
protected abstract void doDelete(T entity); protected abstract void doDelete(T entity);
protected abstract void doDeleteById(ID id); protected abstract void doDeleteById(ID id);
protected abstract boolean doExistsById(ID id); protected abstract int doDeleteAll(Spec<T> spec);
protected abstract long doCount(); protected abstract int doUpdateAll(Spec<T> spec, T patch);
} }
@@ -0,0 +1,9 @@
package dev.relism.flash.ext.data.core;
import java.io.Serializable;
/** Creates the backend-specific, stateless repository for one entity type. */
@FunctionalInterface
public interface RepositoryFactory {
<T, ID extends Serializable> Repository<T, ID> create(Tx tx, Class<T> type);
}
@@ -0,0 +1,24 @@
package dev.relism.flash.ext.data.core;
public abstract class RepositorySupport<T, ID> {
private final Tx tx;
private final TxDefinition rw = TxDefinition.DEFAULTS
.withPropagation(TransactionPropagation.REQUIRED);
private final TxDefinition ro = rw.asReadOnly();
protected RepositorySupport(Tx tx) {
this.tx = tx;
}
protected final Tx tx() {
return tx;
}
protected final <R> R roQuery(Tx.TxCallable<R> work) {
return tx.call(ro, work);
}
protected final <R> R rwQuery(Tx.TxCallable<R> work) {
return tx.call(rw, work);
}
}
@@ -31,6 +31,11 @@ public final class ResourceRegistry {
SYNCHRONIZATIONS.get().clear(); SYNCHRONIZATIONS.get().clear();
} }
public static void cleanup() {
RESOURCES.remove();
SYNCHRONIZATIONS.remove();
}
public static <R> R get(TxResourceKey key, Class<R> type) { public static <R> R get(TxResourceKey key, Class<R> type) {
Object value = RESOURCES.get().get(key); Object value = RESOURCES.get().get(key);
if (value == null) { if (value == null) {
@@ -48,9 +53,52 @@ public final class ResourceRegistry {
SYNCHRONIZATIONS.get().add(Objects.requireNonNull(sync)); SYNCHRONIZATIONS.get().add(Objects.requireNonNull(sync));
} }
public static void fireSynchronizations(TxOutcome outcome) { /**
List<TxSynchronization> syncs = List.copyOf(SYNCHRONIZATIONS.get()); * How many synchronizations are registered right now — captured by a transaction manager when
SYNCHRONIZATIONS.get().clear(); * it opens a new transaction, and handed back to {@link #fireSynchronizations} on completion
* so that transaction only fires its own. See there for why that matters.
*/
public static int synchronizationCount() {
return SYNCHRONIZATIONS.get().size();
}
/**
* Runs {@link TxSynchronization#beforeCommit} on the synchronizations registered from
* {@code fromIndex} onward, while their transaction is still active and its resource still
* bound. Unlike {@link #fireSynchronizations} this leaves them registered: they still have
* their post-completion callbacks to come. Exceptions propagate on purpose — a beforeCommit
* that throws vetoes the commit, see {@link TxSynchronization}.
*
* <p>Snapshots before iterating, so a callback that registers further synchronizations (a
* nested {@code Data#afterCommit}) doesn't mutate the list mid-loop. Those new ones join the
* transaction's post-completion callbacks without getting a {@code beforeCommit} of their own,
* which is the only coherent answer once the pass is already running.
*/
public static void fireBeforeCommit(boolean readOnly, int fromIndex) {
List<TxSynchronization> pending = SYNCHRONIZATIONS.get();
if (fromIndex >= pending.size()) {
return;
}
for (TxSynchronization sync : List.copyOf(pending.subList(fromIndex, pending.size()))) {
sync.beforeCommit(readOnly);
}
}
/**
* Fires (and removes) the synchronizations registered from {@code fromIndex} onward — the ones
* belonging to the transaction now completing. Everything before that index was registered by
* an enclosing transaction that is merely <em>suspended</em>, not finished: a REQUIRES_NEW
* inner transaction sets its own baseline, so committing it no longer drags the outer's
* pending callbacks along — which fired them early, and with the inner transaction's outcome,
* for an outer transaction that might still roll back.
*/
public static void fireSynchronizations(TxOutcome outcome, int fromIndex) {
List<TxSynchronization> pending = SYNCHRONIZATIONS.get();
if (fromIndex >= pending.size()) {
return;
}
List<TxSynchronization> syncs = List.copyOf(pending.subList(fromIndex, pending.size()));
pending.subList(fromIndex, pending.size()).clear();
for (TxSynchronization sync : syncs) { for (TxSynchronization sync : syncs) {
if (outcome == TxOutcome.COMMITTED) { if (outcome == TxOutcome.COMMITTED) {
sync.afterCommit(); sync.afterCommit();
@@ -7,6 +7,10 @@ public record Sort(List<Column> columns) {
public record Column(String column, boolean asc) {} public record Column(String column, boolean asc) {}
public static Sort unsorted() { return new Sort(List.of()); }
public boolean isSorted() { return !columns.isEmpty(); }
public static Sort by(String column) { return new Sort(List.of(new Column(column, true))); } public static Sort by(String column) { return new Sort(List.of(new Column(column, true))); }
public static Sort desc(String column) { return new Sort(List.of(new Column(column, false))); } public static Sort desc(String column) { return new Sort(List.of(new Column(column, false))); }
public static Sort by(String col, boolean asc){ return new Sort(List.of(new Column(col, asc))); } public static Sort by(String col, boolean asc){ return new Sort(List.of(new Column(col, asc))); }
@@ -0,0 +1,26 @@
package dev.relism.flash.ext.data.core;
@FunctionalInterface
public interface Spec<T> {
String toFragment(SpecContext ctx);
default Spec<T> and(Spec<T> other) {
return ctx -> "(" + this.toFragment(ctx) + " AND " + other.toFragment(ctx) + ")";
}
default Spec<T> or(Spec<T> other) {
return ctx -> "(" + this.toFragment(ctx) + " OR " + other.toFragment(ctx) + ")";
}
default Spec<T> not() {
return ctx -> "NOT (" + this.toFragment(ctx) + ")";
}
static <T> Spec<T> all() {
return ctx -> "1=1";
}
static <T> Spec<T> none() {
return ctx -> "1=0";
}
}
@@ -0,0 +1,37 @@
package dev.relism.flash.ext.data.core;
import java.util.Collection;
import java.util.Objects;
import java.util.stream.Collectors;
public final class SpecBuilder<T> {
private SpecBuilder() {}
public static <T, V> FieldSpec<T, V> field(String column) {
return new FieldSpec<>(column);
}
public static final class FieldSpec<T, V> {
private final String column;
private FieldSpec(String column) {
this.column = Objects.requireNonNull(column);
}
public Spec<T> eq(V value) { return ctx -> column + " = " + ctx.bind(value); }
public Spec<T> neq(V value) { return ctx -> column + " != " + ctx.bind(value); }
public Spec<T> like(String pattern) { return ctx -> column + " like " + ctx.bind(pattern); }
public Spec<T> isNull() { return ctx -> column + " is null"; }
public Spec<T> isNotNull() { return ctx -> column + " is not null"; }
public Spec<T> in(Collection<V> values) {
return ctx -> column + " in (" + values.stream().map(ctx::bind).collect(Collectors.joining(", ")) + ")";
}
public <C extends Comparable<C>> Spec<T> gt(C value) { return ctx -> column + " > " + ctx.bind(value); }
public <C extends Comparable<C>> Spec<T> lt(C value) { return ctx -> column + " < " + ctx.bind(value); }
public <C extends Comparable<C>> Spec<T> between(C lo, C hi) {
return ctx -> column + " between " + ctx.bind(lo) + " and " + ctx.bind(hi);
}
}
}
@@ -0,0 +1,5 @@
package dev.relism.flash.ext.data.core;
public interface SpecContext {
String bind(Object value);
}
@@ -3,6 +3,7 @@ package dev.relism.flash.ext.data.core;
public enum TransactionPropagation { public enum TransactionPropagation {
REQUIRED, REQUIRED,
REQUIRES_NEW, REQUIRES_NEW,
SUPPORTS,
NOT_SUPPORTED, NOT_SUPPORTED,
MANDATORY MANDATORY
} }
@@ -2,83 +2,78 @@ package dev.relism.flash.ext.data.core;
import java.util.ArrayDeque; import java.util.ArrayDeque;
import java.util.Deque; import java.util.Deque;
import java.util.Objects;
public final class Tx { public final class Tx {
private static final ThreadLocal<Deque<TxStatus>> STATUS_STACK = private static final ThreadLocal<Deque<TxStatus>> STATUS_STACK =
ThreadLocal.withInitial(ArrayDeque::new); ThreadLocal.withInitial(ArrayDeque::new);
private static volatile TxManager manager; private final TxManager manager;
private Tx() {} public Tx(TxManager txManager) {
this.manager = Objects.requireNonNull(txManager);
public static void init(TxManager txManager) {
if (manager != null) {
throw new IllegalStateException("TxManager already initialized");
}
manager = txManager;
} }
public static void run(TxRunnable work) { public void run(TxRunnable work) {
run(TxDefinition.DEFAULTS, work); run(TxDefinition.DEFAULTS, work);
} }
public static void run(TxDefinition definition, TxRunnable work) { public void run(TxDefinition definition, TxRunnable work) {
call(definition, () -> { call(definition, () -> {
work.run(); work.run();
return null; return null;
}); });
} }
public static <T> T call(TxCallable<T> work) { public <T> T call(TxCallable<T> work) {
return call(TxDefinition.DEFAULTS, work); return call(TxDefinition.DEFAULTS, work);
} }
public static <T> T call(TxDefinition definition, TxCallable<T> work) { public <T> T call(TxDefinition definition, TxCallable<T> work) {
TxStatus status = manager().begin(definition); TxStatus status = manager.begin(definition);
pushStatus(status); pushStatus(status);
try { try {
T result = work.call(); T result = work.call();
if (status.isRollbackOnly()) { if (status.isRollbackOnly()) {
manager().rollback(status); manager.rollback(status);
} else { } else {
manager().commit(status); manager.commit(status);
} }
return result; return result;
} catch (Exception e) { } catch (Exception e) {
manager().rollback(status); silentRollback(status);
throw (e instanceof TxException txException) ? txException : new TxException(e); throw (e instanceof TxException txException) ? txException : new TxException(e);
} catch (Throwable t) {
silentRollback(status);
throw sneakyThrow(t);
} finally { } finally {
popStatus(); popStatus();
if (STATUS_STACK.get().isEmpty()) {
STATUS_STACK.remove();
}
} }
} }
public static boolean isActive() { public boolean isActive() {
return !STATUS_STACK.get().isEmpty(); return !STATUS_STACK.get().isEmpty();
} }
public static void setRollbackOnly() { public void setRollbackOnly() {
currentStatus().markRollbackOnly(); currentStatus().markRollbackOnly();
} }
public static <R> R resource(Class<R> type) { public <R> R resource(Class<R> type) {
return currentStatus().resource(type); return currentStatus().resource(type);
} }
public static TxDefinition requiresNew() { public TxDefinition requiresNew() {
return TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW); return TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW);
} }
public static TxDefinition readOnly() { public TxDefinition readOnly() {
return TxDefinition.DEFAULTS.asReadOnly(); return TxDefinition.DEFAULTS.asReadOnly();
} }
private static TxManager manager() { private TxStatus currentStatus() {
if (manager == null) {
throw new IllegalStateException("No TxManager installed");
}
return manager;
}
private static TxStatus currentStatus() {
TxStatus status = STATUS_STACK.get().peek(); TxStatus status = STATUS_STACK.get().peek();
if (status == null) { if (status == null) {
throw new IllegalStateException("No active transaction"); throw new IllegalStateException("No active transaction");
@@ -86,17 +81,29 @@ public final class Tx {
return status; return status;
} }
private static void pushStatus(TxStatus status) { private void pushStatus(TxStatus status) {
STATUS_STACK.get().push(status); STATUS_STACK.get().push(status);
} }
private static void popStatus() { private void popStatus() {
Deque<TxStatus> stack = STATUS_STACK.get(); Deque<TxStatus> stack = STATUS_STACK.get();
if (!stack.isEmpty()) { if (!stack.isEmpty()) {
stack.pop(); stack.pop();
} }
} }
private void silentRollback(TxStatus status) {
try {
manager.rollback(status);
} catch (Exception ignored) {
}
}
@SuppressWarnings("unchecked")
private static <E extends Throwable> RuntimeException sneakyThrow(Throwable t) throws E {
throw (E) t;
}
@FunctionalInterface @FunctionalInterface
public interface TxRunnable { public interface TxRunnable {
void run(); void run();
@@ -1,8 +1,58 @@
package dev.relism.flash.ext.data.core; package dev.relism.flash.ext.data.core;
/**
* Lifecycle hooks for one transaction, registered through {@code Data#afterCommit} (or
* {@link ResourceRegistry#addSynchronization} directly) and fired by the {@link TxManager} that
* owns the transaction they were registered in.
*
* <p>Every callback belongs to exactly one transaction — the innermost one active at registration
* time — and fires exactly once, when <em>that</em> transaction completes. A joined
* ({@code REQUIRED}) inner transaction is not a transaction of its own, so callbacks registered
* inside one wait for the outermost commit; a {@code REQUIRES_NEW} transaction is, so completing
* it fires only its own callbacks and leaves the suspended outer transaction's alone.
*
* <h3>Where each hook sits relative to the commit</h3>
* <ul>
* <li>{@link #beforeCommit(boolean)} — immediately <b>before</b> the real commit, with the
* transaction still active and its session/connection still bound. This is the only hook
* that can still write through that same resource and have the write land in the same atomic
* unit: flush a buffer, stamp an audit row, materialize a derived value. Skipped entirely
* when the transaction is already rollback-only, since there is no commit to precede.</li>
* <li>{@link #afterCommit()} / {@link #afterRollback()}, then {@link #afterCompletion(TxOutcome)}
* — <b>after</b> the transaction has completed and its resource has been unbound. Nothing
* done here is part of the transaction: a callback that opens its own transaction gets a
* fresh one instead of joining the one that just finished, which is what makes this the
* right place to refresh a cache, enqueue a message, or notify anything outside the
* database.</li>
* </ul>
*
* <h3>Failure</h3>
* Throwing from {@link #beforeCommit(boolean)} <b>vetoes the commit</b>: the transaction is rolled
* back, {@link #afterRollback()}/{@link #afterCompletion(TxOutcome)} fire with
* {@link TxOutcome#ROLLED_BACK}, and the exception propagates to the caller. That is the point of
* this hook running before the commit rather than after — it can still refuse.
*
* <p>The post-completion hooks have no such power: the transaction is over by the time they run,
* so an exception from one propagates to the caller but changes nothing already committed, and
* stops the callbacks queued behind it.
*/
public interface TxSynchronization { public interface TxSynchronization {
/**
* Runs inside the transaction, immediately before it commits — see the interface javadoc.
* Throwing from here rolls the transaction back instead of committing it.
*
* @param readOnly whether the transaction was opened read-only, so a callback that would
* otherwise write can skip work it is not allowed to do
*/
default void beforeCommit(boolean readOnly) {} default void beforeCommit(boolean readOnly) {}
/** Runs after a successful commit, with the transaction's resource already unbound. */
default void afterCommit() {} default void afterCommit() {}
/** Runs after a rollback, with the transaction's resource already unbound. */
default void afterRollback() {} default void afterRollback() {}
/** Runs after {@link #afterCommit()}/{@link #afterRollback()}, whichever applied. */
default void afterCompletion(TxOutcome outcome) {} default void afterCompletion(TxOutcome outcome) {}
} }
@@ -0,0 +1,95 @@
# flash-ext-data-hibernate
Hibernate backend for `flash-ext-data-core`.
## Purpose
This module implements `TxManager` on top of a `SessionFactory` and provides a Hibernate-centric
repository base class.
## How to use it
### 1. Create the manager
```java
SessionFactory sessionFactory = ...;
HibernateTxManager txManager = new HibernateTxManager(sessionFactory);
DataExtension extension = new DataExtension(txManager);
```
### 2. Install the extension in Flash
The extension registers `Tx` and `TxManager` in the `FlashContext`. Class-based handlers annotated
with `@Transactional` are wrapped automatically.
### 3. Define a repository
```java
public final class UserRepository extends HibernateRepository<User, Long> {
public UserRepository(Tx tx) {
super(tx, User.class);
}
}
```
With the query/spec model you can expose reusable fields as constants:
```java
public final class UserRepository extends HibernateRepository<User, Long> {
public static final SpecBuilder.FieldSpec<User, String> EMAIL = SpecBuilder.field("u.email");
public static final SpecBuilder.FieldSpec<User, Boolean> ACTIVE = SpecBuilder.field("u.active");
public UserRepository(Tx tx) {
super(tx, User.class);
}
public Optional<User> findByEmail(String email) {
return findOne(EMAIL.eq(email));
}
}
```
Domain-specific queries can use the base class helpers:
```java
public List<User> findByEmailDomain(String domain) {
return findMany("from User u where u.email like :email", q ->
q.setParameter("email", "%@" + domain)
);
}
```
## How it works underneath
- The current transaction is represented by `HibernateTxStatus`.
- The resource exposed to the core is a `Session`.
- `Tx.resource(Session.class)` retrieves the `Session` from the current context.
- `REQUIRES_NEW` suspends the active status and opens a new `Session`.
- `NOT_SUPPORTED` suspends the active transaction and continues with no session bound.
## Repository base class
`HibernateRepository` provides:
- `findById`, `findAll`, `findPage`, `findOne`
- `save`, `update`, `delete`, `saveAll`
- bulk `deleteAll(Spec<T>)` and `updateAll(Spec<T>, T)`
- HQL helpers: `hql(...)`, `hqlMutate(...)`
Concrete classes only have to implement domain queries, never the transactional plumbing.
## Transactional semantics
- `REQUIRED`: join, or open a new transaction.
- `REQUIRES_NEW`: suspend the current context.
- `SUPPORTS`: join if a transaction exists, otherwise no-op.
- `NOT_SUPPORTED`: suspend and continue without a transaction.
- `MANDATORY`: fail if there is no transaction.
## Notes
- The `Session` is closed when a new transaction ends.
- Synchronizations registered in a transaction fire when *that* transaction completes:
`beforeCommit` while it is still active and the `Session` still bound, the post-completion hooks
once it is unbound. See `flash-ext-data-core/docs/README.md` for the full contract.
- This backend is meant to be used through the base class, not directly.
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId> <artifactId>flash-extensions</artifactId>
<version>2.0.0</version> <version>2.1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>flash-ext-data-hibernate</artifactId> <artifactId>flash-ext-data-hibernate</artifactId>
@@ -0,0 +1,25 @@
package dev.relism.flash.ext.data.hibernate;
import dev.relism.flash.ext.data.core.Data;
import dev.relism.flash.ext.data.core.Repository;
import dev.relism.flash.ext.data.core.RepositoryFactory;
import dev.relism.flash.ext.data.core.Tx;
import dev.relism.flash.ext.data.core.TxManager;
import java.io.Serializable;
/** Hibernate-backed {@link Data} factory. */
public final class HibernateData {
private HibernateData() {}
private static final RepositoryFactory REPOSITORIES = new RepositoryFactory() {
@Override
public <T, ID extends Serializable> Repository<T, ID> create(Tx tx, Class<T> type) {
return new HibernateRepository<T, ID>(tx, type) {};
}
};
public static Data create(TxManager manager) {
Tx tx = new Tx(manager);
return new Data(tx, REPOSITORIES);
}
}
@@ -1,79 +1,74 @@
package dev.relism.flash.ext.data.hibernate; package dev.relism.flash.ext.data.hibernate;
import dev.relism.flash.ext.data.core.*; import dev.relism.flash.ext.data.core.*;
import jakarta.persistence.TypedQuery;
import org.hibernate.Session; import org.hibernate.Session;
import org.hibernate.query.MutationQuery; import org.hibernate.query.MutationQuery;
import jakarta.persistence.TypedQuery;
import java.io.Serializable; import java.io.Serializable;
import java.util.*; import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** public abstract class HibernateRepository<T, ID extends Serializable> extends Repository<T, ID> {
* Hibernate-backed repository base.
* Never extend this directly — extend {@link Repository} from the core.
* This class is instantiated internally by flash-ext-data-hibernate.
*/
public abstract class HibernateRepository<T, ID extends Serializable>
extends Repository<T, ID> {
private final Class<T> type; private final Class<T> type;
protected HibernateRepository(Class<T> type) { protected HibernateRepository(Tx tx, Class<T> type) {
super(tx);
this.type = type; this.type = type;
} }
// ── Session — always safe, tx() wrapper guarantees active transaction ─────
protected Session session() { protected Session session() {
return Tx.resource(Session.class); return tx().resource(Session.class);
} }
// ── Repository abstract impl ──────────────────────────────────────────────
@Override @Override
protected Optional<T> doFindById(ID id) { protected Optional<T> doFindById(ID id) {
return Optional.ofNullable(session().get(type, id)); return Optional.ofNullable(session().get(type, id));
} }
@Override @Override
protected List<T> doFindAll() { protected List<T> doFind(Query<T> query) {
return hql("from " + type.getSimpleName()).getResultList(); HibernateSpecContext ctx = new HibernateSpecContext();
String where = query.spec() != null ? " where " + query.spec().toFragment(ctx) : "";
String order = query.sort() != null && query.sort().isSorted() ? " order by " + orderClause(query.sort()) : "";
TypedQuery<T> q = session().createQuery("from " + type.getSimpleName() + where + order, type);
ctx.applyParameters(q);
if (query.isPaged()) {
q.setFirstResult(query.page() * query.size());
q.setMaxResults(query.size());
}
return q.getResultList();
} }
@Override @Override
protected List<T> doFindAll(int page, int size) { protected Optional<T> doFindOne(Spec<T> spec) {
return hql("from " + type.getSimpleName()) return doFind(Query.<T>all().where(spec).page(0, 1)).stream().findFirst();
.setFirstResult(page * size)
.setMaxResults(size)
.getResultList();
} }
@Override @Override
protected List<T> doFindAll(Sort sort) { protected Page<T> doFindPage(Query<T> query) {
return hql("from " + type.getSimpleName() + orderClause(sort)) if (!query.isPaged()) {
.getResultList(); throw new IllegalArgumentException("Paged query requires page and size");
}
long total = countWhere(query.spec());
List<T> content = doFind(query);
return new Page<>(content, query.page(), query.size(), total);
} }
@Override @Override
protected List<T> doFindAll(int page, int size, Sort sort) { protected boolean doExistsById(ID id) {
return hql("from " + type.getSimpleName() + orderClause(sort)) return doFindById(id).isPresent();
.setFirstResult(page * size)
.setMaxResults(size)
.getResultList();
} }
@Override @Override
protected Page<T> doFindPage(int page, int size) { protected long doCount() {
long total = doCount(); return countWhere(Spec.all());
return new Page<>(doFindAll(page, size), page, size, total);
}
@Override
protected Page<T> doFindPage(int page, int size, Sort sort) {
long total = doCount();
return new Page<>(doFindAll(page, size, sort), page, size, total);
} }
@Override @Override
@@ -82,6 +77,22 @@ public abstract class HibernateRepository<T, ID extends Serializable>
return entity; return entity;
} }
@Override
protected List<T> doSaveAll(Iterable<T> entities) {
List<T> saved = new ArrayList<>();
Session s = session();
int i = 0;
for (T entity : entities) {
s.persist(entity);
saved.add(entity);
if (++i % 50 == 0) {
s.flush();
s.clear();
}
}
return saved;
}
@Override @Override
protected T doUpdate(T entity) { protected T doUpdate(T entity) {
return session().merge(entity); return session().merge(entity);
@@ -99,66 +110,37 @@ public abstract class HibernateRepository<T, ID extends Serializable>
} }
@Override @Override
protected boolean doExistsById(ID id) { protected int doDeleteAll(Spec<T> spec) {
return doFindById(id).isPresent(); HibernateSpecContext ctx = new HibernateSpecContext();
String where = " where " + spec.toFragment(ctx);
MutationQuery q = session().createMutationQuery("delete from " + type.getSimpleName() + where);
ctx.applyParameters(q);
return q.executeUpdate();
} }
@Override @Override
protected long doCount() { protected int doUpdateAll(Spec<T> spec, T patch) {
return session() throw new UnsupportedOperationException("Override doUpdateAll() for bulk UPDATE support");
.createQuery("select count(*) from " + type.getSimpleName(), Long.class)
.uniqueResultOptional()
.orElse(0L);
} }
// ── Query helpers — usabili nelle sottoclassi domain ───────────────────── protected List<T> hql(String hql, Consumer<TypedQuery<T>> params) {
return roQuery(() -> {
protected TypedQuery<T> hql(String hql) { TypedQuery<T> q = session().createQuery(hql, type);
return session().createQuery(hql, type);
}
protected <R> TypedQuery<R> hql(String hql, Class<R> resultType) {
return session().createQuery(hql, resultType);
}
protected Optional<T> findOne(String hql, Consumer<TypedQuery<T>> params) {
TypedQuery<T> q = hql(hql);
params.accept(q);
return q.getResultStream().findFirst();
}
protected List<T> findMany(String hql, Consumer<TypedQuery<T>> params) {
return tx(() -> {
TypedQuery<T> q = hql(hql);
params.accept(q); params.accept(q);
return q.getResultList(); return q.getResultList();
}); });
} }
protected List<T> findMany(String hql, Consumer<TypedQuery<T>> params, protected <R> List<R> hql(String hql, Class<R> resultType, Consumer<TypedQuery<R>> params) {
int page, int size) { return roQuery(() -> {
return tx(() -> { TypedQuery<R> q = session().createQuery(hql, resultType);
TypedQuery<T> q = hql(hql);
params.accept(q); params.accept(q);
return q.setFirstResult(page * size).setMaxResults(size).getResultList(); return q.getResultList();
}); });
} }
protected Page<T> findManyPaged(String hql, String countHql, protected int hqlMutate(String hql, Consumer<MutationQuery> params) {
Consumer<TypedQuery<T>> params, return rwQuery(() -> {
int page, int size) {
return tx(() -> {
long total = session()
.createQuery(countHql, Long.class)
.uniqueResultOptional()
.orElse(0L);
List<T> content = findMany(hql, params, page, size);
return new Page<>(content, page, size, total);
});
}
protected int execute(String hql, Consumer<MutationQuery> params) {
return tx(() -> {
MutationQuery q = session().createMutationQuery(hql); MutationQuery q = session().createMutationQuery(hql);
params.accept(q); params.accept(q);
return q.executeUpdate(); return q.executeUpdate();
@@ -169,8 +151,16 @@ public abstract class HibernateRepository<T, ID extends Serializable>
return type; return type;
} }
private long countWhere(Spec<T> spec) {
HibernateSpecContext ctx = new HibernateSpecContext();
String where = spec != null ? " where " + spec.toFragment(ctx) : "";
TypedQuery<Long> q = session().createQuery("select count(*) from " + type.getSimpleName() + where, Long.class);
ctx.applyParameters(q);
return q.getResultStream().findFirst().orElse(0L);
}
private String orderClause(Sort sort) { private String orderClause(Sort sort) {
return " order by " + sort.columns().stream() return sort.columns().stream()
.map(c -> c.column() + (c.asc() ? " ASC" : " DESC")) .map(c -> c.column() + (c.asc() ? " ASC" : " DESC"))
.collect(Collectors.joining(", ")); .collect(Collectors.joining(", "));
} }
@@ -0,0 +1,28 @@
package dev.relism.flash.ext.data.hibernate;
import dev.relism.flash.ext.data.core.SpecContext;
import jakarta.persistence.TypedQuery;
import org.hibernate.query.MutationQuery;
import java.util.LinkedHashMap;
import java.util.Map;
final class HibernateSpecContext implements SpecContext {
private final Map<String, Object> params = new LinkedHashMap<>();
private int counter;
@Override
public String bind(Object value) {
String name = "p" + (++counter);
params.put(name, value);
return ":" + name;
}
void applyParameters(TypedQuery<?> query) {
params.forEach(query::setParameter);
}
void applyParameters(MutationQuery query) {
params.forEach(query::setParameter);
}
}
@@ -8,6 +8,7 @@ import java.util.Objects;
public class HibernateTxManager implements TxManager { public class HibernateTxManager implements TxManager {
private static final TxResourceKey HIBERNATE_STATUS_KEY = TxResourceKey.of("hibernate.tx.status"); private static final TxResourceKey HIBERNATE_STATUS_KEY = TxResourceKey.of("hibernate.tx.status");
private static final TxResourceKey HIBERNATE_SUSPENDED_KEY = TxResourceKey.of("hibernate.tx.suspended");
private final SessionFactory sf; private final SessionFactory sf;
@@ -22,35 +23,60 @@ public class HibernateTxManager implements TxManager {
? joinExisting(definition) ? joinExisting(definition)
: beginNew(definition); : beginNew(definition);
case REQUIRES_NEW -> beginNew(definition); case REQUIRES_NEW -> beginNew(definition);
case SUPPORTS -> ResourceRegistry.isBound(HIBERNATE_STATUS_KEY)
? joinExisting(definition)
: noOp(definition);
case MANDATORY -> { case MANDATORY -> {
if (!ResourceRegistry.isBound(HIBERNATE_STATUS_KEY)) if (!ResourceRegistry.isBound(HIBERNATE_STATUS_KEY))
throw new IllegalStateException("MANDATORY: no active transaction"); throw new IllegalStateException("MANDATORY: no active transaction");
yield joinExisting(definition); yield joinExisting(definition);
} }
case NOT_SUPPORTED -> throw new UnsupportedOperationException("NOT_SUPPORTED is not implemented"); case NOT_SUPPORTED -> {
HibernateTxStatus suspended = suspendIfNeeded();
yield noOp(definition, suspended);
}
}; };
} }
private TxStatus beginNew(TxDefinition definition) { private TxStatus beginNew(TxDefinition definition) {
HibernateTxStatus suspended = ResourceRegistry.getOrNull(HIBERNATE_STATUS_KEY, HibernateTxStatus.class); return beginNew(definition, suspendIfNeeded());
if (suspended != null) { }
ResourceRegistry.unbind(HIBERNATE_STATUS_KEY);
} private TxStatus beginNew(TxDefinition definition, HibernateTxStatus suspended) {
// Anything already registered belongs to an enclosing transaction this one is nested
// inside (or suspended over) — see ResourceRegistry#fireSynchronizations.
int synchronizationBaseline = ResourceRegistry.synchronizationCount();
Session s = sf.openSession(); Session s = sf.openSession();
s.beginTransaction(); boolean bound = false;
if (definition.readOnly()) s.setDefaultReadOnly(true); try {
if (definition.isolation() != TransactionIsolation.DEFAULT) { s.beginTransaction();
s.doWork(connection -> connection.setTransactionIsolation(definition.isolation().level())); if (definition.readOnly()) s.setDefaultReadOnly(true);
if (definition.isolation() != TransactionIsolation.DEFAULT) {
s.doWork(connection -> connection.setTransactionIsolation(definition.isolation().level()));
}
HibernateTxStatus status = new HibernateTxStatus(
s,
true,
definition.readOnly(),
suspended,
new HibernateTxStatus.RollbackMarker(),
synchronizationBaseline
);
ResourceRegistry.bind(HIBERNATE_STATUS_KEY, status);
bound = true;
return status;
} catch (RuntimeException e) {
silentClose(s);
throw e;
} catch (Exception e) {
silentClose(s);
throw new TxException(e);
} finally {
if (!bound && suspended != null) {
ResourceRegistry.unbind(HIBERNATE_SUSPENDED_KEY);
ResourceRegistry.bind(HIBERNATE_STATUS_KEY, suspended);
}
} }
HibernateTxStatus status = new HibernateTxStatus(
s,
true,
definition.readOnly(),
suspended,
new HibernateTxStatus.RollbackMarker()
);
ResourceRegistry.bind(HIBERNATE_STATUS_KEY, status);
return status;
} }
private TxStatus joinExisting(TxDefinition definition) { private TxStatus joinExisting(TxDefinition definition) {
@@ -58,31 +84,81 @@ public class HibernateTxManager implements TxManager {
if (definition.readOnly() && !existing.isReadOnly()) { if (definition.readOnly() && !existing.isReadOnly()) {
throw new TxException("Cannot join read-write tx as read-only"); throw new TxException("Cannot join read-write tx as read-only");
} }
// Baseline 0 is never read: a joined status isn't a new transaction, so commit()/rollback()
// hand it straight back to the transaction it joined without firing anything.
return new HibernateTxStatus( return new HibernateTxStatus(
existing.session(), existing.session(),
false, false,
definition.readOnly(), definition.readOnly(),
null, null,
existing.rollbackMarker() existing.rollbackMarker(),
0
); );
} }
private TxStatus noOp(TxDefinition definition) {
return noOp(definition, null);
}
private TxStatus noOp(TxDefinition definition, HibernateTxStatus suspended) {
return new HibernateTxStatus(null, false, definition.readOnly(), suspended, new HibernateTxStatus.RollbackMarker(), 0);
}
private HibernateTxStatus suspendIfNeeded() {
HibernateTxStatus suspended = ResourceRegistry.getOrNull(HIBERNATE_STATUS_KEY, HibernateTxStatus.class);
if (suspended != null) {
ResourceRegistry.unbind(HIBERNATE_STATUS_KEY);
ResourceRegistry.bind(HIBERNATE_SUSPENDED_KEY, suspended);
}
return suspended;
}
@Override @Override
public void commit(TxStatus status) { public void commit(TxStatus status) {
HibernateTxStatus s = (HibernateTxStatus) status; HibernateTxStatus s = (HibernateTxStatus) status;
if (!s.isNewTransaction()) { if (!s.isNewTransaction()) {
resumeIfNeeded(s);
cleanupIfIdle();
return; return;
} }
TxOutcome outcome = null;
try { try {
if (s.isRollbackOnly() && s.session().getTransaction().isActive()) { if (s.isRollbackOnly() && s.session().getTransaction().isActive()) {
s.session().getTransaction().rollback(); s.session().getTransaction().rollback();
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK); outcome = TxOutcome.ROLLED_BACK;
} else { } else {
// Still inside the transaction, session still bound: a beforeCommit callback can
// write through it and land in this same commit. Throwing from there vetoes the
// commit — see TxSynchronization.
ResourceRegistry.fireBeforeCommit(s.isReadOnly(), s.synchronizationBaseline());
s.session().getTransaction().commit(); s.session().getTransaction().commit();
ResourceRegistry.fireSynchronizations(TxOutcome.COMMITTED); outcome = TxOutcome.COMMITTED;
} }
} catch (RuntimeException e) {
// A vetoing beforeCommit, or a commit that failed outright: either way nothing was
// committed, so roll back and let the remaining callbacks hear ROLLED_BACK rather than
// nothing at all. A rollback failure here is swallowed deliberately — it would mask
// the exception that actually explains what went wrong, which is the one propagating.
if (s.session().getTransaction().isActive()) {
try {
s.session().getTransaction().rollback();
} catch (RuntimeException suppressed) {
e.addSuppressed(suppressed);
}
}
outcome = TxOutcome.ROLLED_BACK;
throw e;
} finally { } finally {
// Order is load-bearing, and getting it wrong is silent: cleanupIfIdle() calls
// ResourceRegistry.cleanup(), which removes the very ThreadLocal list of
// synchronizations still waiting to be fired — firing afterwards saw a freshly
// initialized empty list and dropped every callback on the floor. cleanupAndResume()
// still has to come first, so a synchronization that opens its own transaction
// (Registry#reload() in Pathway does) starts a fresh one instead of joining the
// session that just committed. rollback() below already had this order right.
cleanupAndResume(s); cleanupAndResume(s);
if (outcome != null) ResourceRegistry.fireSynchronizations(outcome, s.synchronizationBaseline());
cleanupIfIdle();
} }
} }
@@ -91,24 +167,53 @@ public class HibernateTxManager implements TxManager {
HibernateTxStatus s = (HibernateTxStatus) status; HibernateTxStatus s = (HibernateTxStatus) status;
if (!s.isNewTransaction()) { if (!s.isNewTransaction()) {
s.markRollbackOnly(); s.markRollbackOnly();
resumeIfNeeded(s);
cleanupIfIdle();
return; return;
} }
try { try {
if (s.session().getTransaction().isActive()) { if (s.session().getTransaction().isActive()) {
s.session().getTransaction().rollback(); s.session().getTransaction().rollback();
} }
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK);
} finally { } finally {
// Same order as commit(): unbind the session first so a callback opening its own
// transaction gets a fresh one, fire before cleanupIfIdle() can drop the list.
cleanupAndResume(s); cleanupAndResume(s);
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK, s.synchronizationBaseline());
cleanupIfIdle();
} }
} }
private void cleanupAndResume(HibernateTxStatus status) { private void cleanupAndResume(HibernateTxStatus status) {
ResourceRegistry.unbind(HIBERNATE_STATUS_KEY); ResourceRegistry.unbind(HIBERNATE_STATUS_KEY);
status.session().close(); silentClose(status.session());
resumeIfNeeded(status);
}
private void cleanupIfIdle() {
if (!ResourceRegistry.isBound(HIBERNATE_STATUS_KEY) && !ResourceRegistry.isBound(HIBERNATE_SUSPENDED_KEY)) {
ResourceRegistry.cleanup();
}
}
private void resumeIfNeeded(HibernateTxStatus status) {
HibernateTxStatus suspended = status.suspended(); HibernateTxStatus suspended = status.suspended();
if (suspended == null) {
suspended = ResourceRegistry.getOrNull(HIBERNATE_SUSPENDED_KEY, HibernateTxStatus.class);
}
if (suspended != null) { if (suspended != null) {
ResourceRegistry.unbind(HIBERNATE_SUSPENDED_KEY);
ResourceRegistry.bind(HIBERNATE_STATUS_KEY, suspended); ResourceRegistry.bind(HIBERNATE_STATUS_KEY, suspended);
} }
} }
private void silentClose(Session session) {
if (session == null) {
return;
}
try {
session.close();
} catch (Exception ignored) {
}
}
} }
@@ -13,19 +13,22 @@ class HibernateTxStatus implements TxStatus {
private final boolean readOnly; private final boolean readOnly;
private final HibernateTxStatus suspended; private final HibernateTxStatus suspended;
private final RollbackMarker rollbackMarker; private final RollbackMarker rollbackMarker;
private final int synchronizationBaseline;
HibernateTxStatus( HibernateTxStatus(
Session session, Session session,
boolean newTransaction, boolean newTransaction,
boolean readOnly, boolean readOnly,
HibernateTxStatus suspended, HibernateTxStatus suspended,
RollbackMarker rollbackMarker RollbackMarker rollbackMarker,
int synchronizationBaseline
) { ) {
this.session = session; this.session = session;
this.newTransaction = newTransaction; this.newTransaction = newTransaction;
this.readOnly = readOnly; this.readOnly = readOnly;
this.suspended = suspended; this.suspended = suspended;
this.rollbackMarker = rollbackMarker; this.rollbackMarker = rollbackMarker;
this.synchronizationBaseline = synchronizationBaseline;
} }
@Override public boolean isNewTransaction() { return newTransaction; } @Override public boolean isNewTransaction() { return newTransaction; }
@@ -35,10 +38,16 @@ class HibernateTxStatus implements TxStatus {
@Override @Override
public <R> R resource(Class<R> type) { public <R> R resource(Class<R> type) {
if (session == null) {
throw new IllegalStateException("No session bound to this transaction status");
}
return type.cast(session); return type.cast(session);
} }
Session session() { return session; } Session session() { return session; }
HibernateTxStatus suspended() { return suspended; } HibernateTxStatus suspended() { return suspended; }
RollbackMarker rollbackMarker() { return rollbackMarker; } RollbackMarker rollbackMarker() { return rollbackMarker; }
/** Index into {@code ResourceRegistry}'s synchronization list where this transaction's own callbacks start. */
int synchronizationBaseline() { return synchronizationBaseline; }
} }
@@ -0,0 +1,258 @@
package dev.relism.flash.ext.data.hibernate;
import dev.relism.flash.ext.data.core.*;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* Transaction synchronization semantics — the {@code beforeCommit}/{@code afterCommit}/
* {@code afterRollback} callbacks {@code Data#afterCommit} exposes, and the contract callers build
* on: "my callback runs once, for my transaction, on the right side of the commit".
*
* <p>None of this was covered before, and the gap was not academic: {@link #afterCommit_fires_on_commit}
* failed against the original {@code commit()}, which fired synchronizations only after
* {@code cleanupIfIdle()} had already dropped the ThreadLocal list holding them — so every callback
* was silently discarded, on every commit, with no error and no log. Downstream that meant an admin
* write landing in Postgres while the in-memory cache it was supposed to refresh never heard about
* it until the process restarted.
*/
class HibernateTxManagerSynchronizationTest {
static SessionFactory sf;
static HibernateTxManager manager;
@BeforeAll
static void setup() {
sf = TestHelper.buildSessionFactory();
manager = new HibernateTxManager(sf);
}
@AfterAll
static void teardown() {
if (sf != null) {
sf.close();
}
}
@AfterEach
void cleanup() {
ResourceRegistry.clear();
}
/** Records which callbacks ran, in order — order is part of the contract, not just the fact they ran. */
private static final class Recorder implements TxSynchronization {
final List<String> calls = new ArrayList<>();
@Override public void beforeCommit(boolean readOnly) { calls.add("beforeCommit:" + readOnly); }
@Override public void afterCommit() { calls.add("afterCommit"); }
@Override public void afterRollback() { calls.add("afterRollback"); }
@Override public void afterCompletion(TxOutcome outcome) { calls.add("afterCompletion:" + outcome); }
}
private static final List<String> COMMITTED = List.of("beforeCommit:false", "afterCommit", "afterCompletion:COMMITTED");
private static final List<String> ROLLED_BACK = List.of("afterRollback", "afterCompletion:ROLLED_BACK");
@Test
void afterCommit_fires_on_commit() {
Recorder recorder = new Recorder();
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
ResourceRegistry.addSynchronization(recorder);
manager.commit(tx);
assertEquals(COMMITTED, recorder.calls);
}
@Test
void afterRollback_fires_on_rollback() {
Recorder recorder = new Recorder();
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
ResourceRegistry.addSynchronization(recorder);
manager.rollback(tx);
assertEquals(ROLLED_BACK, recorder.calls);
}
/** A commit() call on a tx already marked rollback-only really rolls back — and has no commit to precede. */
@Test
void commit_of_a_rollback_only_tx_rolls_back_without_running_beforeCommit() {
Recorder recorder = new Recorder();
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
tx.markRollbackOnly();
ResourceRegistry.addSynchronization(recorder);
manager.commit(tx);
assertEquals(ROLLED_BACK, recorder.calls);
}
/** beforeCommit runs inside the transaction: session still bound, transaction still active. */
@Test
void beforeCommit_runs_while_the_transaction_is_still_active() {
List<Boolean> stillActive = new ArrayList<>();
List<Session> sessionSeen = new ArrayList<>();
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
Session session = tx.resource(Session.class);
ResourceRegistry.addSynchronization(new TxSynchronization() {
@Override
public void beforeCommit(boolean readOnly) {
stillActive.add(session.getTransaction().isActive());
// MANDATORY only succeeds while a transaction is bound — proof this runs inside it.
TxStatus joined = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.MANDATORY));
sessionSeen.add(joined.resource(Session.class));
manager.commit(joined);
}
});
manager.commit(tx);
assertEquals(List.of(true), stillActive, "the transaction must not have committed yet");
assertSame(session, sessionSeen.get(0), "the same session must still be bound, so writes land in this commit");
}
@Test
void beforeCommit_is_told_whether_the_transaction_is_read_only() {
Recorder readWrite = new Recorder();
TxStatus rw = manager.begin(TxDefinition.DEFAULTS);
ResourceRegistry.addSynchronization(readWrite);
manager.commit(rw);
Recorder readOnly = new Recorder();
TxStatus ro = manager.begin(TxDefinition.DEFAULTS.asReadOnly());
ResourceRegistry.addSynchronization(readOnly);
manager.commit(ro);
assertEquals("beforeCommit:false", readWrite.calls.get(0));
assertEquals("beforeCommit:true", readOnly.calls.get(0));
}
/** Throwing from beforeCommit is a veto: no commit, the rollback callbacks run, the exception propagates. */
@Test
void a_throwing_beforeCommit_vetoes_the_commit() {
Recorder recorder = new Recorder();
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
Session session = tx.resource(Session.class);
ResourceRegistry.addSynchronization(new TxSynchronization() {
@Override public void beforeCommit(boolean readOnly) { throw new IllegalStateException("veto"); }
});
ResourceRegistry.addSynchronization(recorder);
IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> manager.commit(tx));
assertEquals("veto", thrown.getMessage());
assertFalse(session.getTransaction().isActive(), "the vetoed transaction must be rolled back, not left open");
assertEquals(ROLLED_BACK, recorder.calls, "the surviving callbacks must hear ROLLED_BACK, not silence");
}
/**
* The load-bearing ordering detail: post-completion callbacks run <em>after</em> the committed
* session is unbound, so a callback that opens its own transaction (a cache reload, an outbox
* drain) gets a fresh one instead of silently joining the transaction that just committed.
*/
@Test
void a_synchronization_may_open_its_own_transaction() {
List<Session> sessionsSeen = new ArrayList<>();
List<Boolean> wasNewTransaction = new ArrayList<>();
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
Session committedSession = outer.resource(Session.class);
ResourceRegistry.addSynchronization(new TxSynchronization() {
@Override
public void afterCommit() {
TxStatus own = manager.begin(TxDefinition.DEFAULTS);
sessionsSeen.add(own.resource(Session.class));
wasNewTransaction.add(own.isNewTransaction());
manager.commit(own);
}
});
manager.commit(outer);
assertEquals(1, sessionsSeen.size(), "the callback must have run");
assertEquals(List.of(true), wasNewTransaction, "must start its own transaction, not join the committed one");
assertNotSame(committedSession, sessionsSeen.get(0));
}
/** A joined (REQUIRED) inner commit is not a real commit — callbacks wait for the outermost one. */
@Test
void a_joined_commit_defers_synchronizations_to_the_outermost_commit() {
Recorder recorder = new Recorder();
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
ResourceRegistry.addSynchronization(recorder);
manager.commit(inner);
assertEquals(List.of(), recorder.calls, "the joined commit did not commit anything yet");
manager.commit(outer);
assertEquals(COMMITTED, recorder.calls);
}
/** Each callback belongs to one transaction: a second transaction must not re-run the first's. */
@Test
void synchronizations_do_not_leak_into_the_next_transaction() {
Recorder recorder = new Recorder();
TxStatus first = manager.begin(TxDefinition.DEFAULTS);
ResourceRegistry.addSynchronization(recorder);
manager.commit(first);
recorder.calls.clear();
TxStatus second = manager.begin(TxDefinition.DEFAULTS);
manager.commit(second);
assertEquals(List.of(), recorder.calls);
}
/**
* A REQUIRES_NEW inner transaction suspends the outer one; committing the inner must not drag
* the still-pending outer transaction's callbacks along with it. They belong to a transaction
* that has not committed — and may yet roll back, in which case firing {@code afterCommit} for
* it would be a straight lie.
*/
@Test
void a_requires_new_commit_leaves_the_suspended_transactions_synchronizations_alone() {
Recorder outerSync = new Recorder();
Recorder innerSync = new Recorder();
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
ResourceRegistry.addSynchronization(outerSync);
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW));
ResourceRegistry.addSynchronization(innerSync);
manager.commit(inner);
assertEquals(COMMITTED, innerSync.calls);
assertEquals(List.of(), outerSync.calls, "the outer transaction has not committed yet");
manager.commit(outer);
assertEquals(COMMITTED, outerSync.calls);
}
/** A rolled-back inner REQUIRES_NEW must not fire the outer's callbacks either — same reason, opposite outcome. */
@Test
void a_requires_new_rollback_leaves_the_suspended_transactions_synchronizations_alone() {
Recorder outerSync = new Recorder();
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
ResourceRegistry.addSynchronization(outerSync);
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW));
manager.rollback(inner);
assertEquals(List.of(), outerSync.calls, "the outer transaction is still open");
manager.commit(outer);
assertEquals(COMMITTED, outerSync.calls);
}
}
@@ -65,4 +65,44 @@ class HibernateTxManagerTest {
assertTrue(outer.isRollbackOnly()); assertTrue(outer.isRollbackOnly());
manager.rollback(outer); manager.rollback(outer);
} }
/** SUPPORTS without an active transaction yields a sessionless status: not a transaction, no session to hand out. */
@Test
void supports_without_active_transaction_is_a_sessionless_no_op() {
TxStatus s = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.SUPPORTS));
assertFalse(s.isNewTransaction());
assertThrows(IllegalStateException.class, () -> s.resource(Session.class));
assertDoesNotThrow(() -> manager.commit(s));
}
@Test
void not_supported_suspends_the_active_transaction_and_restores_it_on_commit() {
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
Session outerSession = outer.resource(Session.class);
TxStatus suspended = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.NOT_SUPPORTED));
assertFalse(suspended.isNewTransaction());
assertThrows(IllegalStateException.class, () -> suspended.resource(Session.class));
manager.commit(suspended);
TxStatus rejoined = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
assertSame(outerSession, rejoined.resource(Session.class), "the suspended transaction must be back");
manager.rollback(outer);
}
@Test
void mandatory_without_active_transaction_is_rejected() {
assertThrows(IllegalStateException.class,
() -> manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.MANDATORY)));
}
/** A read-only join onto a read-write transaction is a contract violation, not a silent downgrade. */
@Test
void read_only_cannot_join_a_read_write_transaction() {
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
assertThrows(TxException.class,
() -> manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED).asReadOnly()));
manager.rollback(outer);
}
} }
@@ -0,0 +1,101 @@
# flash-ext-data-jdbc
JDBC backend for `flash-ext-data-core`.
## Purpose
This module implements `TxManager` on top of a `DataSource` and provides a raw-SQL repository base
class.
## How to use it
### 1. Create the manager
```java
DataSource dataSource = ...;
JdbcTxManager txManager = new JdbcTxManager(dataSource);
DataExtension extension = new DataExtension(txManager);
```
### 2. Install the extension in Flash
As with Hibernate, `DataExtension` registers `Tx` in the `FlashContext` and enables
`@Transactional` on class-based handlers.
### 3. Define a repository
```java
public final class UserRepository extends JdbcRepository<User, Long> {
public UserRepository(Tx tx) {
super(tx, "users", "id");
}
@Override
protected User mapRow(ResultSet rs) throws SQLException {
return new User(rs.getLong("id"), rs.getString("name"));
}
}
```
Here too you can expose reusable `Spec`s and compose queries from the service layer:
```java
public final class UserRepository extends JdbcRepository<User, Long> {
public static final SpecBuilder.FieldSpec<User, String> EMAIL = SpecBuilder.field("email");
public UserRepository(Tx tx) {
super(tx, "users", "id");
}
}
```
Saving and updating need an explicit binding:
```java
@Override
protected String insertSql() {
return "insert into users(name) values(?)";
}
@Override
protected void bindInsert(PreparedStatement ps, User entity) throws SQLException {
ps.setString(1, entity.name());
}
```
## How it works underneath
- The current transaction exposes a `Connection`.
- `Tx.resource(Connection.class)` retrieves the connection bound to the thread.
- `REQUIRES_NEW` suspends the active connection and opens a new one.
- `NOT_SUPPORTED` suspends the context and continues without a transaction.
## Repository base class
`JdbcRepository` provides:
- `select` queries through `queryOne`, `queryMany`
- mutations through `mutate`
- persistence through `doSave`, `doUpdate`
- paging through `doFindPage`
- bulk `deleteAll(Spec<T>)`
- raw helpers `queryOne(...)`, `queryMany(...)`, `mutate(...)`
Concrete repositories only have to translate between `ResultSet` and the domain.
## Transactional semantics
- `REQUIRED`: join, or open a new transaction.
- `REQUIRES_NEW`: suspend the current context.
- `SUPPORTS`: join if a transaction exists, otherwise no-op.
- `NOT_SUPPORTED`: suspend and continue without a transaction.
- `MANDATORY`: fail if there is no transaction.
## Notes
- The `Connection` is closed when a new transaction ends.
- Synchronizations registered in a transaction fire when *that* transaction completes:
`beforeCommit` while it is still active and the `Connection` still bound, the post-completion
hooks once it is unbound. See `flash-ext-data-core/docs/README.md` for the full contract.
- If a repository uses `doDelete(T)`, the default behaviour is unsupported: use `deleteById` or
override it.
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId> <artifactId>flash-extensions</artifactId>
<version>2.0.0</version> <version>2.1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>flash-ext-data-jdbc</artifactId> <artifactId>flash-ext-data-jdbc</artifactId>
@@ -3,87 +3,99 @@ package dev.relism.flash.ext.data.jdbc;
import dev.relism.flash.ext.data.core.*; import dev.relism.flash.ext.data.core.*;
import java.sql.*; import java.sql.*;
import java.util.*; import java.util.ArrayList;
import java.util.stream.Collectors; import java.util.List;
import java.util.Optional;
public abstract class JdbcRepository<T, ID> extends Repository<T, ID> { public abstract class JdbcRepository<T, ID> extends Repository<T, ID> {
private final String table; private final String table;
private final String idColumn; private final String idColumn;
protected JdbcRepository(String table, String idColumn) { protected JdbcRepository(Tx tx, String table, String idColumn) {
this.table = table; super(tx);
this.table = table;
this.idColumn = idColumn; this.idColumn = idColumn;
} }
protected Connection connection() { protected Connection connection() {
return Tx.resource(Connection.class); return tx().resource(Connection.class);
} }
// ── Subclass contract ───────────────────────────────────────────────────── protected abstract T mapRow(ResultSet rs) throws SQLException;
protected abstract void bindInsert(PreparedStatement ps, T entity) throws SQLException;
protected abstract T mapRow(ResultSet rs) throws SQLException; protected abstract void bindUpdate(PreparedStatement ps, T entity) throws SQLException;
protected abstract void bindInsert(PreparedStatement ps, T entity) throws SQLException;
protected abstract void bindUpdate(PreparedStatement ps, T entity) throws SQLException;
protected abstract String insertSql(); protected abstract String insertSql();
protected abstract String updateSql(); protected abstract String updateSql();
// ── Repository abstract impl ──────────────────────────────────────────────
@Override @Override
protected Optional<T> doFindById(ID id) { protected Optional<T> doFindById(ID id) {
return queryOne("select * from " + table + " where " + idColumn + " = ?", return queryOne("select * from " + table + " where " + idColumn + " = ?", ps -> ps.setObject(1, id));
ps -> ps.setObject(1, id));
} }
@Override @Override
protected List<T> doFindAll() { protected List<T> doFind(Query<T> query) {
return queryMany("select * from " + table, ps -> {}); JdbcSpecContext ctx = new JdbcSpecContext();
} String where = query.spec() != null ? " where " + query.spec().toFragment(ctx) : "";
String order = query.sort() != null && query.sort().isSorted() ? " order by " + orderClause(query.sort()) : "";
String paging = query.isPaged() ? " limit ? offset ?" : "";
@Override return queryMany("select * from " + table + where + order + paging, ps -> {
protected List<T> doFindAll(int page, int size) { if (query.isPaged()) {
return queryMany("select * from " + table + " limit ? offset ?", ps -> { ctx.applyParameters(ps);
ps.setInt(1, size); int base = ctx.size();
ps.setInt(2, page * size); ps.setInt(base + 1, query.size());
ps.setInt(base + 2, query.page() * query.size());
return;
}
ctx.applyParameters(ps);
}); });
} }
@Override @Override
protected List<T> doFindAll(Sort sort) { protected Optional<T> doFindOne(Spec<T> spec) {
return queryMany("select * from " + table + orderClause(sort), ps -> {}); return doFind(Query.<T>all().where(spec).page(0, 1)).stream().findFirst();
} }
@Override @Override
protected List<T> doFindAll(int page, int size, Sort sort) { protected Page<T> doFindPage(Query<T> query) {
return queryMany("select * from " + table + orderClause(sort) + " limit ? offset ?", if (!query.isPaged()) {
ps -> { throw new IllegalArgumentException("Paged query requires page and size");
ps.setInt(1, size); }
ps.setInt(2, page * size); long total = countWhere(query.spec());
}); List<T> content = doFind(query);
return new Page<>(content, query.page(), query.size(), total);
} }
@Override @Override
protected Page<T> doFindPage(int page, int size) { protected boolean doExistsById(ID id) {
long total = doCount(); return queryOne("select 1 from " + table + " where " + idColumn + " = ?", ps -> ps.setObject(1, id), rs -> rs.getInt(1)).isPresent();
return new Page<>(doFindAll(page, size), page, size, total);
} }
@Override @Override
protected Page<T> doFindPage(int page, int size, Sort sort) { protected long doCount() {
long total = doCount(); return queryOne("select count(*) from " + table, ps -> {}, rs -> rs.getLong(1)).orElse(0L);
return new Page<>(doFindAll(page, size, sort), page, size, total);
} }
@Override @Override
protected T doSave(T entity) { protected T doSave(T entity) {
try (PreparedStatement ps = connection().prepareStatement( try (PreparedStatement ps = connection().prepareStatement(insertSql(), Statement.RETURN_GENERATED_KEYS)) {
insertSql(), Statement.RETURN_GENERATED_KEYS)) {
bindInsert(ps, entity); bindInsert(ps, entity);
ps.executeUpdate(); ps.executeUpdate();
applyGeneratedKey(ps, entity); applyGeneratedKey(ps, entity);
return entity; return entity;
} catch (SQLException e) { throw new TxException(e); } } catch (SQLException e) {
throw new TxException(e);
}
}
@Override
protected List<T> doSaveAll(Iterable<T> entities) {
List<T> saved = new ArrayList<>();
for (T entity : entities) {
saved.add(doSave(entity));
}
return saved;
} }
@Override @Override
@@ -92,7 +104,9 @@ public abstract class JdbcRepository<T, ID> extends Repository<T, ID> {
bindUpdate(ps, entity); bindUpdate(ps, entity);
ps.executeUpdate(); ps.executeUpdate();
return entity; return entity;
} catch (SQLException e) { throw new TxException(e); } } catch (SQLException e) {
throw new TxException(e);
}
} }
@Override @Override
@@ -102,38 +116,35 @@ public abstract class JdbcRepository<T, ID> extends Repository<T, ID> {
@Override @Override
protected void doDeleteById(ID id) { protected void doDeleteById(ID id) {
mutate("delete from " + table + " where " + idColumn + " = ?", mutate("delete from " + table + " where " + idColumn + " = ?", ps -> ps.setObject(1, id));
ps -> ps.setObject(1, id));
} }
@Override @Override
protected boolean doExistsById(ID id) { protected int doDeleteAll(Spec<T> spec) {
return queryOne("select 1 from " + table + " where " + idColumn + " = ?", JdbcSpecContext ctx = new JdbcSpecContext();
ps -> ps.setObject(1, id), String where = " where " + spec.toFragment(ctx);
rs -> rs.getInt(1)).isPresent(); return mutate("delete from " + table + where, ctx::applyParameters);
} }
@Override @Override
protected long doCount() { protected int doUpdateAll(Spec<T> spec, T patch) {
return queryOne("select count(*) from " + table, ps -> {}, throw new UnsupportedOperationException("Override doUpdateAll() for bulk UPDATE support");
rs -> rs.getLong(1)).orElse(0L);
} }
// ── Query helpers ─────────────────────────────────────────────────────────
protected Optional<T> queryOne(String sql, SqlBinder params) { protected Optional<T> queryOne(String sql, SqlBinder params) {
List<T> r = queryMany(sql, params); List<T> r = queryMany(sql, params);
return r.isEmpty() ? Optional.empty() : Optional.of(r.get(0)); return r.isEmpty() ? Optional.empty() : Optional.of(r.get(0));
} }
protected <R> Optional<R> queryOne(String sql, SqlBinder params, protected <R> Optional<R> queryOne(String sql, SqlBinder params, SqlMapper<R> mapper) {
SqlMapper<R> mapper) {
try (PreparedStatement ps = connection().prepareStatement(sql)) { try (PreparedStatement ps = connection().prepareStatement(sql)) {
params.bind(ps); params.bind(ps);
try (ResultSet rs = ps.executeQuery()) { try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Optional.of(mapper.map(rs)) : Optional.empty(); return rs.next() ? Optional.of(mapper.map(rs)) : Optional.empty();
} }
} catch (SQLException e) { throw new TxException(e); } } catch (SQLException e) {
throw new TxException(e);
}
} }
protected List<T> queryMany(String sql, SqlBinder params) { protected List<T> queryMany(String sql, SqlBinder params) {
@@ -144,26 +155,47 @@ public abstract class JdbcRepository<T, ID> extends Repository<T, ID> {
while (rs.next()) results.add(mapRow(rs)); while (rs.next()) results.add(mapRow(rs));
return results; return results;
} }
} catch (SQLException e) { throw new TxException(e); } } catch (SQLException e) {
throw new TxException(e);
}
} }
protected int mutate(String sql, SqlBinder params) { protected int mutate(String sql, SqlBinder params) {
try (PreparedStatement ps = connection().prepareStatement(sql)) { try (PreparedStatement ps = connection().prepareStatement(sql)) {
params.bind(ps); params.bind(ps);
return ps.executeUpdate(); return ps.executeUpdate();
} catch (SQLException e) { throw new TxException(e); } } catch (SQLException e) {
throw new TxException(e);
}
} }
protected void applyGeneratedKey(PreparedStatement ps, T entity) throws SQLException { protected void applyGeneratedKey(PreparedStatement ps, T entity) throws SQLException {
// override when entity has a generated PK // override when entity has a generated PK
} }
private String orderClause(Sort sort) { protected Class<T> entityType() {
return " order by " + sort.columns().stream() return null;
.map(c -> c.column() + (c.asc() ? " ASC" : " DESC"))
.collect(Collectors.joining(", "));
} }
@FunctionalInterface public interface SqlBinder { void bind(PreparedStatement ps) throws SQLException; } private long countWhere(Spec<T> spec) {
@FunctionalInterface public interface SqlMapper<R> { R map(ResultSet rs) throws SQLException; } JdbcSpecContext ctx = new JdbcSpecContext();
String where = spec != null ? " where " + spec.toFragment(ctx) : "";
return queryOne("select count(*) from " + table + where, ctx::applyParameters, rs -> rs.getLong(1)).orElse(0L);
}
private String orderClause(Sort sort) {
return sort.columns().stream()
.map(c -> c.column() + (c.asc() ? " ASC" : " DESC"))
.collect(java.util.stream.Collectors.joining(", "));
}
@FunctionalInterface
public interface SqlBinder {
void bind(PreparedStatement ps) throws SQLException;
}
@FunctionalInterface
public interface SqlMapper<R> {
R map(ResultSet rs) throws SQLException;
}
} }
@@ -0,0 +1,28 @@
package dev.relism.flash.ext.data.jdbc;
import dev.relism.flash.ext.data.core.SpecContext;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
final class JdbcSpecContext implements SpecContext {
private final List<Object> params = new ArrayList<>();
@Override
public String bind(Object value) {
params.add(value);
return "?";
}
void applyParameters(PreparedStatement ps) throws SQLException {
for (int i = 0; i < params.size(); i++) {
ps.setObject(i + 1, params.get(i));
}
}
int size() {
return params.size();
}
}
@@ -9,6 +9,7 @@ import java.util.Objects;
public class JdbcTxManager implements TxManager { public class JdbcTxManager implements TxManager {
private static final TxResourceKey JDBC_STATUS_KEY = TxResourceKey.of("jdbc.tx.status"); private static final TxResourceKey JDBC_STATUS_KEY = TxResourceKey.of("jdbc.tx.status");
private static final TxResourceKey JDBC_SUSPENDED_KEY = TxResourceKey.of("jdbc.tx.suspended");
private final DataSource ds; private final DataSource ds;
@@ -23,22 +24,30 @@ public class JdbcTxManager implements TxManager {
? joinExisting(definition) ? joinExisting(definition)
: beginNew(definition); : beginNew(definition);
case REQUIRES_NEW -> beginNew(definition); case REQUIRES_NEW -> beginNew(definition);
case SUPPORTS -> ResourceRegistry.isBound(JDBC_STATUS_KEY)
? joinExisting(definition)
: noOp(definition);
case MANDATORY -> { case MANDATORY -> {
if (!ResourceRegistry.isBound(JDBC_STATUS_KEY)) if (!ResourceRegistry.isBound(JDBC_STATUS_KEY))
throw new IllegalStateException("MANDATORY: no active transaction"); throw new IllegalStateException("MANDATORY: no active transaction");
yield joinExisting(definition); yield joinExisting(definition);
} }
case NOT_SUPPORTED -> throw new UnsupportedOperationException("NOT_SUPPORTED is not implemented"); case NOT_SUPPORTED -> {
JdbcTxStatus suspended = suspendIfNeeded();
yield noOp(definition, suspended);
}
}; };
} }
private TxStatus beginNew(TxDefinition definition) { private TxStatus beginNew(TxDefinition definition) {
Connection conn = null;
// Anything already registered belongs to an enclosing transaction this one is nested
// inside (or suspended over) — see ResourceRegistry#fireSynchronizations.
int synchronizationBaseline = ResourceRegistry.synchronizationCount();
JdbcTxStatus suspended = suspendIfNeeded();
boolean bound = false;
try { try {
JdbcTxStatus suspended = ResourceRegistry.getOrNull(JDBC_STATUS_KEY, JdbcTxStatus.class); conn = ds.getConnection();
if (suspended != null) {
ResourceRegistry.unbind(JDBC_STATUS_KEY);
}
Connection conn = ds.getConnection();
conn.setAutoCommit(false); conn.setAutoCommit(false);
if (definition.readOnly()) conn.setReadOnly(true); if (definition.readOnly()) conn.setReadOnly(true);
if (definition.isolation() != TransactionIsolation.DEFAULT) { if (definition.isolation() != TransactionIsolation.DEFAULT) {
@@ -49,12 +58,20 @@ public class JdbcTxManager implements TxManager {
true, true,
definition.readOnly(), definition.readOnly(),
suspended, suspended,
new JdbcTxStatus.RollbackMarker() new JdbcTxStatus.RollbackMarker(),
synchronizationBaseline
); );
ResourceRegistry.bind(JDBC_STATUS_KEY, status); ResourceRegistry.bind(JDBC_STATUS_KEY, status);
bound = true;
return status; return status;
} catch (SQLException e) { } catch (SQLException e) {
silentClose(conn);
throw new TxException(e); throw new TxException(e);
} finally {
if (!bound && suspended != null) {
ResourceRegistry.unbind(JDBC_SUSPENDED_KEY);
ResourceRegistry.bind(JDBC_STATUS_KEY, suspended);
}
} }
} }
@@ -63,62 +80,144 @@ public class JdbcTxManager implements TxManager {
if (definition.readOnly() && !existing.isReadOnly()) { if (definition.readOnly() && !existing.isReadOnly()) {
throw new TxException("Cannot join read-write tx as read-only"); throw new TxException("Cannot join read-write tx as read-only");
} }
// Baseline 0 is never read: a joined status isn't a new transaction, so commit()/rollback()
// hand it straight back to the transaction it joined without firing anything.
return new JdbcTxStatus( return new JdbcTxStatus(
existing.connection(), existing.connection(),
false, false,
definition.readOnly(), definition.readOnly(),
null, null,
existing.rollbackMarker() existing.rollbackMarker(),
0
); );
} }
private TxStatus noOp(TxDefinition definition) {
return noOp(definition, null);
}
private TxStatus noOp(TxDefinition definition, JdbcTxStatus suspended) {
return new JdbcTxStatus(null, false, definition.readOnly(), suspended, new JdbcTxStatus.RollbackMarker(), 0);
}
private JdbcTxStatus suspendIfNeeded() {
JdbcTxStatus suspended = ResourceRegistry.getOrNull(JDBC_STATUS_KEY, JdbcTxStatus.class);
if (suspended != null) {
ResourceRegistry.unbind(JDBC_STATUS_KEY);
ResourceRegistry.bind(JDBC_SUSPENDED_KEY, suspended);
}
return suspended;
}
@Override @Override
public void commit(TxStatus status) { public void commit(TxStatus status) {
JdbcTxStatus s = (JdbcTxStatus) status; JdbcTxStatus s = (JdbcTxStatus) status;
if (!s.isNewTransaction()) { if (!s.isNewTransaction()) {
resumeIfNeeded(s);
cleanupIfIdle();
return; return;
} }
TxOutcome outcome = null;
try { try {
if (s.isRollbackOnly()) { if (s.isRollbackOnly()) {
s.connection().rollback(); s.connection().rollback();
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK); outcome = TxOutcome.ROLLED_BACK;
return; } else {
// Still inside the transaction, connection still bound: a beforeCommit callback
// can write through it and land in this same commit. Throwing from there vetoes
// the commit — see TxSynchronization.
ResourceRegistry.fireBeforeCommit(s.isReadOnly(), s.synchronizationBaseline());
s.connection().commit();
outcome = TxOutcome.COMMITTED;
} }
s.connection().commit();
ResourceRegistry.fireSynchronizations(TxOutcome.COMMITTED);
} catch (SQLException e) { } catch (SQLException e) {
throw new TxException(e); TxException wrapped = new TxException(e);
outcome = rollbackAfterFailedCommit(s, wrapped);
throw wrapped;
} catch (RuntimeException e) {
outcome = rollbackAfterFailedCommit(s, e);
throw e;
} finally { } finally {
// Unbind the connection before firing, so a callback that opens its own transaction
// gets a fresh one instead of joining the connection that just committed — and fire
// before cleanupIfIdle(), whose ResourceRegistry.cleanup() drops the pending list.
cleanupAndResume(s); cleanupAndResume(s);
if (outcome != null) ResourceRegistry.fireSynchronizations(outcome, s.synchronizationBaseline());
cleanupIfIdle();
} }
} }
/**
* Nothing was committed — a vetoing {@code beforeCommit}, or a commit that failed outright —
* so undo whatever the transaction had done and report {@code ROLLED_BACK} to the callbacks
* still queued behind it. A failure to roll back is attached to the exception already on its
* way out rather than replacing it: that one explains what actually went wrong.
*/
private static TxOutcome rollbackAfterFailedCommit(JdbcTxStatus s, Throwable propagating) {
try {
s.connection().rollback();
} catch (SQLException suppressed) {
propagating.addSuppressed(suppressed);
}
return TxOutcome.ROLLED_BACK;
}
@Override @Override
public void rollback(TxStatus status) { public void rollback(TxStatus status) {
JdbcTxStatus s = (JdbcTxStatus) status; JdbcTxStatus s = (JdbcTxStatus) status;
if (!s.isNewTransaction()) { if (!s.isNewTransaction()) {
s.markRollbackOnly(); s.markRollbackOnly();
resumeIfNeeded(s);
cleanupIfIdle();
return; return;
} }
try { try {
s.connection().rollback(); s.connection().rollback();
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK);
} catch (SQLException e) { } catch (SQLException e) {
throw new TxException(e); throw new TxException(e);
} finally { } finally {
// Same order as commit() above, for the same two reasons.
cleanupAndResume(s); cleanupAndResume(s);
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK, s.synchronizationBaseline());
cleanupIfIdle();
} }
} }
private void cleanupAndResume(JdbcTxStatus status) { private void cleanupAndResume(JdbcTxStatus status) {
ResourceRegistry.unbind(JDBC_STATUS_KEY); ResourceRegistry.unbind(JDBC_STATUS_KEY);
try { try {
status.connection().close(); if (status.connection() != null) {
status.connection().close();
}
} catch (SQLException ignored) { } catch (SQLException ignored) {
} }
resumeIfNeeded(status);
}
private void cleanupIfIdle() {
if (!ResourceRegistry.isBound(JDBC_STATUS_KEY) && !ResourceRegistry.isBound(JDBC_SUSPENDED_KEY)) {
ResourceRegistry.cleanup();
}
}
private void resumeIfNeeded(JdbcTxStatus status) {
JdbcTxStatus suspended = status.suspended(); JdbcTxStatus suspended = status.suspended();
if (suspended == null) {
suspended = ResourceRegistry.getOrNull(JDBC_SUSPENDED_KEY, JdbcTxStatus.class);
}
if (suspended != null) { if (suspended != null) {
ResourceRegistry.unbind(JDBC_SUSPENDED_KEY);
ResourceRegistry.bind(JDBC_STATUS_KEY, suspended); ResourceRegistry.bind(JDBC_STATUS_KEY, suspended);
} }
} }
private void silentClose(Connection connection) {
if (connection == null) {
return;
}
try {
connection.close();
} catch (SQLException ignored) {
}
}
} }
@@ -3,7 +3,6 @@ package dev.relism.flash.ext.data.jdbc;
import dev.relism.flash.ext.data.core.TxStatus; import dev.relism.flash.ext.data.core.TxStatus;
import java.sql.Connection; import java.sql.Connection;
import java.util.Objects;
class JdbcTxStatus implements TxStatus { class JdbcTxStatus implements TxStatus {
static final class RollbackMarker { static final class RollbackMarker {
@@ -15,19 +14,27 @@ class JdbcTxStatus implements TxStatus {
private final boolean readOnly; private final boolean readOnly;
private final JdbcTxStatus suspended; private final JdbcTxStatus suspended;
private final RollbackMarker rollbackMarker; private final RollbackMarker rollbackMarker;
private final int synchronizationBaseline;
// No requireNonNull on the connection: a SUPPORTS-without-a-transaction or a NOT_SUPPORTED
// status is deliberately connectionless (see JdbcTxManager#noOp), and rejecting null here
// turned both of those propagations into an NPE at begin() — the Hibernate manager has
// always allowed it. resource() reports the real mistake, asking a connectionless status for
// its connection, where it can name it.
JdbcTxStatus( JdbcTxStatus(
Connection connection, Connection connection,
boolean newTransaction, boolean newTransaction,
boolean readOnly, boolean readOnly,
JdbcTxStatus suspended, JdbcTxStatus suspended,
RollbackMarker rollbackMarker RollbackMarker rollbackMarker,
int synchronizationBaseline
) { ) {
this.connection = Objects.requireNonNull(connection); this.connection = connection;
this.newTransaction = newTransaction; this.newTransaction = newTransaction;
this.readOnly = readOnly; this.readOnly = readOnly;
this.suspended = suspended; this.suspended = suspended;
this.rollbackMarker = rollbackMarker; this.rollbackMarker = rollbackMarker;
this.synchronizationBaseline = synchronizationBaseline;
} }
@Override public boolean isNewTransaction() { return newTransaction; } @Override public boolean isNewTransaction() { return newTransaction; }
@@ -37,10 +44,16 @@ class JdbcTxStatus implements TxStatus {
@Override @Override
public <R> R resource(Class<R> type) { public <R> R resource(Class<R> type) {
if (connection == null) {
throw new IllegalStateException("No connection bound to this transaction status");
}
return type.cast(connection); return type.cast(connection);
} }
Connection connection() { return connection; } Connection connection() { return connection; }
JdbcTxStatus suspended() { return suspended; } JdbcTxStatus suspended() { return suspended; }
RollbackMarker rollbackMarker() { return rollbackMarker; } RollbackMarker rollbackMarker() { return rollbackMarker; }
/** Index into {@code ResourceRegistry}'s synchronization list where this transaction's own callbacks start. */
int synchronizationBaseline() { return synchronizationBaseline; }
} }
@@ -0,0 +1,216 @@
package dev.relism.flash.ext.data.jdbc;
import dev.relism.flash.ext.data.core.*;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.sql.Connection;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* Transaction synchronization semantics for the JDBC manager — the same contract {@code
* HibernateTxManagerSynchronizationTest} pins down for the Hibernate one, kept deliberately
* parallel: the two managers are interchangeable behind {@code TxManager}, so a callback must not
* observe a different lifecycle depending on which one is installed.
*/
class JdbcTxManagerSynchronizationTest {
private final JdbcTxManager manager = new JdbcTxManager(new TestDataSource());
@AfterEach
void cleanup() {
ResourceRegistry.clear();
}
/** Records which callbacks ran, in order — order is part of the contract, not just the fact they ran. */
private static final class Recorder implements TxSynchronization {
final List<String> calls = new ArrayList<>();
@Override public void beforeCommit(boolean readOnly) { calls.add("beforeCommit:" + readOnly); }
@Override public void afterCommit() { calls.add("afterCommit"); }
@Override public void afterRollback() { calls.add("afterRollback"); }
@Override public void afterCompletion(TxOutcome outcome) { calls.add("afterCompletion:" + outcome); }
}
private static final List<String> COMMITTED = List.of("beforeCommit:false", "afterCommit", "afterCompletion:COMMITTED");
private static final List<String> ROLLED_BACK = List.of("afterRollback", "afterCompletion:ROLLED_BACK");
@Test
void afterCommit_fires_on_commit() {
Recorder recorder = new Recorder();
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
ResourceRegistry.addSynchronization(recorder);
manager.commit(tx);
assertEquals(COMMITTED, recorder.calls);
}
@Test
void afterRollback_fires_on_rollback() {
Recorder recorder = new Recorder();
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
ResourceRegistry.addSynchronization(recorder);
manager.rollback(tx);
assertEquals(ROLLED_BACK, recorder.calls);
}
@Test
void commit_of_a_rollback_only_tx_rolls_back_without_running_beforeCommit() {
Recorder recorder = new Recorder();
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
tx.markRollbackOnly();
ResourceRegistry.addSynchronization(recorder);
manager.commit(tx);
assertEquals(ROLLED_BACK, recorder.calls);
}
/** beforeCommit runs inside the transaction: connection still bound, nothing committed yet. */
@Test
void beforeCommit_runs_while_the_transaction_is_still_active() {
List<Connection> connectionSeen = new ArrayList<>();
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
Connection connection = tx.resource(Connection.class);
ResourceRegistry.addSynchronization(new TxSynchronization() {
@Override
public void beforeCommit(boolean readOnly) {
// MANDATORY only succeeds while a transaction is bound — proof this runs inside it.
TxStatus joined = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.MANDATORY));
connectionSeen.add(joined.resource(Connection.class));
manager.commit(joined);
}
});
manager.commit(tx);
assertSame(connection, connectionSeen.get(0), "the same connection must still be bound, so writes land in this commit");
}
@Test
void beforeCommit_is_told_whether_the_transaction_is_read_only() {
Recorder readWrite = new Recorder();
TxStatus rw = manager.begin(TxDefinition.DEFAULTS);
ResourceRegistry.addSynchronization(readWrite);
manager.commit(rw);
Recorder readOnly = new Recorder();
TxStatus ro = manager.begin(TxDefinition.DEFAULTS.asReadOnly());
ResourceRegistry.addSynchronization(readOnly);
manager.commit(ro);
assertEquals("beforeCommit:false", readWrite.calls.get(0));
assertEquals("beforeCommit:true", readOnly.calls.get(0));
}
/** Throwing from beforeCommit is a veto: no commit, the rollback callbacks run, the exception propagates. */
@Test
void a_throwing_beforeCommit_vetoes_the_commit() {
Recorder recorder = new Recorder();
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
ResourceRegistry.addSynchronization(new TxSynchronization() {
@Override public void beforeCommit(boolean readOnly) { throw new IllegalStateException("veto"); }
});
ResourceRegistry.addSynchronization(recorder);
IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> manager.commit(tx));
assertEquals("veto", thrown.getMessage());
assertEquals(ROLLED_BACK, recorder.calls, "the surviving callbacks must hear ROLLED_BACK, not silence");
}
/** Post-completion callbacks run after the committed connection is unbound, so opening a transaction gets a fresh one. */
@Test
void a_synchronization_may_open_its_own_transaction() {
List<Connection> connectionsSeen = new ArrayList<>();
List<Boolean> wasNewTransaction = new ArrayList<>();
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
Connection committedConnection = outer.resource(Connection.class);
ResourceRegistry.addSynchronization(new TxSynchronization() {
@Override
public void afterCommit() {
TxStatus own = manager.begin(TxDefinition.DEFAULTS);
connectionsSeen.add(own.resource(Connection.class));
wasNewTransaction.add(own.isNewTransaction());
manager.commit(own);
}
});
manager.commit(outer);
assertEquals(1, connectionsSeen.size(), "the callback must have run");
assertEquals(List.of(true), wasNewTransaction, "must start its own transaction, not join the committed one");
assertNotSame(committedConnection, connectionsSeen.get(0));
}
@Test
void a_joined_commit_defers_synchronizations_to_the_outermost_commit() {
Recorder recorder = new Recorder();
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
ResourceRegistry.addSynchronization(recorder);
manager.commit(inner);
assertEquals(List.of(), recorder.calls, "the joined commit did not commit anything yet");
manager.commit(outer);
assertEquals(COMMITTED, recorder.calls);
}
@Test
void synchronizations_do_not_leak_into_the_next_transaction() {
Recorder recorder = new Recorder();
TxStatus first = manager.begin(TxDefinition.DEFAULTS);
ResourceRegistry.addSynchronization(recorder);
manager.commit(first);
recorder.calls.clear();
TxStatus second = manager.begin(TxDefinition.DEFAULTS);
manager.commit(second);
assertEquals(List.of(), recorder.calls);
}
@Test
void a_requires_new_commit_leaves_the_suspended_transactions_synchronizations_alone() {
Recorder outerSync = new Recorder();
Recorder innerSync = new Recorder();
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
ResourceRegistry.addSynchronization(outerSync);
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW));
ResourceRegistry.addSynchronization(innerSync);
manager.commit(inner);
assertEquals(COMMITTED, innerSync.calls);
assertEquals(List.of(), outerSync.calls, "the outer transaction has not committed yet");
manager.commit(outer);
assertEquals(COMMITTED, outerSync.calls);
}
@Test
void a_requires_new_rollback_leaves_the_suspended_transactions_synchronizations_alone() {
Recorder outerSync = new Recorder();
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
ResourceRegistry.addSynchronization(outerSync);
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW));
manager.rollback(inner);
assertEquals(List.of(), outerSync.calls, "the outer transaction is still open");
manager.commit(outer);
assertEquals(COMMITTED, outerSync.calls);
}
}
@@ -4,15 +4,12 @@ import dev.relism.flash.ext.data.core.*;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import javax.sql.DataSource;
import java.sql.Connection; import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
class JdbcTxManagerTest { class JdbcTxManagerTest {
private final JdbcTxManager manager = new JdbcTxManager(dataSource()); private final JdbcTxManager manager = new JdbcTxManager(new TestDataSource());
@AfterEach @AfterEach
void cleanup() { void cleanup() {
@@ -53,52 +50,38 @@ class JdbcTxManagerTest {
manager.rollback(outer); manager.rollback(outer);
} }
private static DataSource dataSource() { /**
return new DataSource() { * SUPPORTS without an active transaction yields a connectionless status — it must not be a
@Override * transaction, and asking it for a connection must say so rather than NPE. Both propagations
public Connection getConnection() throws SQLException { * that produce one used to throw {@link NullPointerException} straight out of {@code begin()}.
return DriverManager.getConnection("jdbc:h2:mem:tx-jdbc;DB_CLOSE_DELAY=-1"); */
} @Test
void supports_without_active_transaction_is_a_connectionless_no_op() {
TxStatus s = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.SUPPORTS));
@Override assertFalse(s.isNewTransaction());
public Connection getConnection(String username, String password) throws SQLException { assertThrows(IllegalStateException.class, () -> s.resource(Connection.class));
return DriverManager.getConnection("jdbc:h2:mem:tx-jdbc;DB_CLOSE_DELAY=-1", username, password); assertDoesNotThrow(() -> manager.commit(s));
} }
@Override @Test
public <T> T unwrap(Class<T> iface) { void not_supported_suspends_the_active_transaction_and_restores_it_on_commit() {
throw new UnsupportedOperationException(); TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
} Connection outerConnection = outer.resource(Connection.class);
@Override TxStatus suspended = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.NOT_SUPPORTED));
public boolean isWrapperFor(Class<?> iface) { assertFalse(suspended.isNewTransaction());
return false; assertThrows(IllegalStateException.class, () -> suspended.resource(Connection.class));
} manager.commit(suspended);
@Override TxStatus rejoined = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
public java.io.PrintWriter getLogWriter() { assertSame(outerConnection, rejoined.resource(Connection.class), "the suspended transaction must be back");
throw new UnsupportedOperationException(); manager.rollback(outer);
} }
@Override @Test
public void setLogWriter(java.io.PrintWriter out) { void mandatory_without_active_transaction_is_rejected() {
throw new UnsupportedOperationException(); assertThrows(IllegalStateException.class,
} () -> manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.MANDATORY)));
@Override
public void setLoginTimeout(int seconds) {
throw new UnsupportedOperationException();
}
@Override
public int getLoginTimeout() {
return 0;
}
@Override
public java.util.logging.Logger getParentLogger() {
throw new UnsupportedOperationException();
}
};
} }
} }
@@ -0,0 +1,37 @@
package dev.relism.flash.ext.data.jdbc;
import javax.sql.DataSource;
import java.io.PrintWriter;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.logging.Logger;
/**
* A bare in-memory H2 {@link DataSource} — one fresh connection per {@code getConnection()}, which
* is all {@code JdbcTxManager} needs to exercise real commit/rollback and suspension. Every method
* outside the two {@code getConnection} overloads throws: nothing under test calls them, and a
* loud failure beats a silent stub if that ever changes.
*/
final class TestDataSource implements DataSource {
static final String URL = "jdbc:h2:mem:tx-jdbc;DB_CLOSE_DELAY=-1";
@Override
public Connection getConnection() throws SQLException {
return DriverManager.getConnection(URL);
}
@Override
public Connection getConnection(String username, String password) throws SQLException {
return DriverManager.getConnection(URL, username, password);
}
@Override public <T> T unwrap(Class<T> iface) { throw new UnsupportedOperationException(); }
@Override public boolean isWrapperFor(Class<?> iface) { return false; }
@Override public PrintWriter getLogWriter() { throw new UnsupportedOperationException(); }
@Override public void setLogWriter(PrintWriter out) { throw new UnsupportedOperationException(); }
@Override public void setLoginTimeout(int seconds) { throw new UnsupportedOperationException(); }
@Override public int getLoginTimeout() { return 0; }
@Override public Logger getParentLogger() { throw new UnsupportedOperationException(); }
}
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId> <artifactId>flash-extensions</artifactId>
<version>2.0.0</version> <version>2.1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>flash-ext-jackson</artifactId> <artifactId>flash-ext-jackson</artifactId>
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.databind.json.JsonMapper;
import dev.relism.flash.extension.FlashContext; import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.extension.FlashExtension; import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.routing.Middleware; import dev.relism.flash.routing.Middleware;
@@ -12,7 +13,8 @@ import dev.relism.flash.routing.Middleware;
* *
* <p>Exposes a {@link Json} utility instance in the {@link FlashContext} under * <p>Exposes a {@link Json} utility instance in the {@link FlashContext} under
* {@code Json.class}. Any handler or extension can retrieve it via {@code ctx.require(Json.class)} * {@code Json.class}. Any handler or extension can retrieve it via {@code ctx.require(Json.class)}
* inside {@code onInit()} (class-based) or inside {@link FlashExtension#routes} (extensions). * inside {@code onInit()} (class-based) or from a {@link FlashContext#onReady(Runnable)}
* callback (extensions).
* *
* <p>The raw {@link ObjectMapper} is also registered under {@code ObjectMapper.class} * <p>The raw {@link ObjectMapper} is also registered under {@code ObjectMapper.class}
* for extensions that need direct mapper access (e.g. OpenAPI schema generation). * for extensions that need direct mapper access (e.g. OpenAPI schema generation).
@@ -83,7 +85,7 @@ public class JacksonExtension implements FlashExtension {
} }
@Override @Override
public void provide(FlashContext ctx) { public void configure(FlashRegistrar<?> app, FlashContext ctx) {
Json json = new Json(mapper); Json json = new Json(mapper);
ctx.provide(Json.class, json); ctx.provide(Json.class, json);
ctx.provide(ObjectMapper.class, mapper); ctx.provide(ObjectMapper.class, mapper);
@@ -19,12 +19,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class JacksonExtensionTest { class JacksonExtensionTest {
@Test @Test
void provide_registers_json_mapper_and_middleware() { void configure_registers_json_mapper_and_middleware() {
FlashContext ctx = new FlashContext(); FlashContext ctx = new FlashContext();
ObjectMapper mapper = new ObjectMapper(); ObjectMapper mapper = new ObjectMapper();
JacksonExtension ext = new JacksonExtension(mapper); JacksonExtension ext = new JacksonExtension(mapper);
ext.provide(ctx); ext.configure(null, ctx);
ctx.complete();
assertNotNull(ctx.require(Json.class)); assertNotNull(ctx.require(Json.class));
assertNotNull(ctx.require(JacksonMiddleware.class)); assertNotNull(ctx.require(JacksonMiddleware.class));
@@ -5,7 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.flash.exceptions.HttpException; 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.models.HeaderMap; import dev.relism.flash.models.Http1HeaderMap;
import dev.relism.flash.models.Request; import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestLine; import dev.relism.flash.models.RequestLine;
import dev.relism.flash.models.Response; import dev.relism.flash.models.Response;
@@ -82,7 +82,7 @@ class JsonTest {
new FastPathViews.StringByteView("/json"), new FastPathViews.StringByteView("/json"),
null, null,
new FastPathViews.StringByteView("HTTP/1.1"), new FastPathViews.StringByteView("HTTP/1.1"),
new HeaderMap() new Http1HeaderMap()
); );
return new Request(line, body); return new Request(line, body);
} }
+1 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId> <artifactId>flash-extensions</artifactId>
<version>2.0.0</version> <version>2.1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>flash-ext-limiter</artifactId> <artifactId>flash-ext-limiter</artifactId>
@@ -5,12 +5,13 @@ import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
import dev.relism.flash.ext.openapi.OpenApiOperationContribution; import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
import dev.relism.flash.ext.openapi.OpenApiResponseContribution; import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
import dev.relism.flash.extension.AnnotationProcessor; import dev.relism.flash.extension.AnnotationProcessor;
import dev.relism.flash.extension.ExtensionPhase; import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.extension.FlashContext; import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension; import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.http.HttpStatus; import dev.relism.flash.http.HttpStatus;
import dev.relism.flash.routing.Middleware; import dev.relism.flash.routing.Middleware;
import dev.relism.flash.routing.MiddlewareKey;
import dev.relism.flash.routing.MiddlewareNode;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.List; import java.util.List;
@@ -44,21 +45,16 @@ import java.util.Map;
* app.install(new LimiterExtension( * app.install(new LimiterExtension(
* new LimiterConfig().registerResolver("auth_user", req -> ClaimsHolder.user().sub()))); * new LimiterConfig().registerResolver("auth_user", req -> ClaimsHolder.user().sub())));
* *
* // inside FlashExtension.routes() or after install(): * // inside a FlashContext.onReady(...) callback:
* Guard guard = ctx.require(Guard.class); * Guard guard = ctx.require(Guard.class);
* app.get("/api/search", handler, guard.limit("ip", 50, 1, TimeUnit.SECONDS)); * app.get("/api/search", handler, guard.limit("ip", 50, 1, TimeUnit.SECONDS));
* }</pre> * }</pre>
*/ */
public final class LimiterExtension implements FlashExtension { public final class LimiterExtension implements FlashExtension {
private static final MiddlewareKey LIMIT = MiddlewareKey.of("flash.limiter.limit");
private final LimiterConfig config; private final LimiterConfig config;
/**
* Rate limiting runs before authentication — cheaper check rejects over-limit
* requests before any token validation occurs.
*/
@Override public int priority() { return ExtensionPhase.EARLY.value; }
/** Installs with default config (only the built-in {@code "ip"} resolver). */ /** Installs with default config (only the built-in {@code "ip"} resolver). */
public LimiterExtension() { public LimiterExtension() {
this(new LimiterConfig()); this(new LimiterConfig());
@@ -70,7 +66,7 @@ public final class LimiterExtension implements FlashExtension {
} }
@Override @Override
public void provide(FlashContext ctx) { public void configure(FlashRegistrar<?> app, FlashContext ctx) {
BucketStore store = new BucketStore(); BucketStore store = new BucketStore();
Guard guard = new Guard(config, store); Guard guard = new Guard(config, store);
@@ -90,17 +86,15 @@ public final class LimiterExtension implements FlashExtension {
ann.strategy().create() ann.strategy().create()
); );
return List.of(buildMiddleware(resolver, cfg, store)); return List.of(MiddlewareNode.of(LIMIT, buildMiddleware(resolver, cfg, store)));
});
ctx.onReady(() -> {
try {
OpenApiIntegration.register(ctx);
} catch (NoClassDefFoundError ignored) {
// flash-ext-openapi not available — OpenAPI integration disabled
}
}); });
}
@Override
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
try {
OpenApiIntegration.register(ctx);
} catch (NoClassDefFoundError ignored) {
// flash-ext-openapi not available — OpenAPI integration disabled
}
} }
// ── Package-private helper — shared with Guard ──────────────────────────── // ── Package-private helper — shared with Guard ────────────────────────────
@@ -41,7 +41,8 @@ class LimiterOpenApiInteropTest {
OpenApiContributorRegistry registry = new OpenApiContributorRegistry(); OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
ctx.provide(OpenApiContributorRegistry.class, registry); ctx.provide(OpenApiContributorRegistry.class, registry);
new LimiterExtension().routes(null, ctx); new LimiterExtension().configure(null, ctx);
ctx.complete();
assertEquals(1, registry.contributors().size()); assertEquals(1, registry.contributors().size());
} }
@@ -51,7 +52,8 @@ class LimiterOpenApiInteropTest {
FlashContext ctx = new FlashContext(); FlashContext ctx = new FlashContext();
OpenApiContributorRegistry registry = new OpenApiContributorRegistry(); OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
ctx.provide(OpenApiContributorRegistry.class, registry); ctx.provide(OpenApiContributorRegistry.class, registry);
new LimiterExtension().routes(null, ctx); new LimiterExtension().configure(null, ctx);
ctx.complete();
OpenApiContributor contributor = registry.contributors().getFirst(); OpenApiContributor contributor = registry.contributors().getFirst();
OpenApiOperationContribution operation = contributor.operationFor(LimitedHandler.class); OpenApiOperationContribution operation = contributor.operationFor(LimitedHandler.class);
@@ -73,7 +75,8 @@ class LimiterOpenApiInteropTest {
FlashContext ctx = new FlashContext(); FlashContext ctx = new FlashContext();
OpenApiContributorRegistry registry = new OpenApiContributorRegistry(); OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
ctx.provide(OpenApiContributorRegistry.class, registry); ctx.provide(OpenApiContributorRegistry.class, registry);
new LimiterExtension().routes(null, ctx); new LimiterExtension().configure(null, ctx);
ctx.complete();
OpenApiContributor contributor = registry.contributors().getFirst(); OpenApiContributor contributor = registry.contributors().getFirst();
OpenApiOperationContribution operation = contributor.operationFor(PlainHandler.class); OpenApiOperationContribution operation = contributor.operationFor(PlainHandler.class);
@@ -0,0 +1,58 @@
# flash-ext-mcp
`flash-ext-mcp` turns a Flash5 app into an [MCP](https://modelcontextprotocol.io) (Model Context
Protocol) server: JSON-RPC 2.0 over the Streamable HTTP transport, tools/resources/prompts
declared as plain classes and discovered at boot, optional OAuth2 protection built on
`flash-ext-oidc`.
## Quick Start
```java
FlashApp.create(8080)
.install(new McpExtension(McpConfig.builder("my-mcp-server")
.toolsPackage("com.example.tools")
.build()))
.start();
```
```java
@Tool(name = "get_weather", description = "Get current weather for a city",
args = @ToolArg(name = "city", description = "City name", required = true))
public class GetWeatherTool extends McpTool {
private WeatherService weatherService;
@Override
protected void onInit() {
weatherService = require(WeatherService.class);
}
@Override
public ToolResponse call(ToolArguments args) {
return ToolResponse.success(new TextContent(weatherService.fetch(args.getString("city"))));
}
}
```
## Operating Model
- **One class per tool/resource/prompt** — mirrors `RequestHandler`: a no-arg constructor,
`onInit()` to cache services from `FlashContext`, one hot-path method
(`call`/`read`/`render`). No CDI, no field injection, no reflection on the hot path.
- **Boot-time precompilation** — `tools/list`/`resources/list`/`prompts/list` JSON payloads
(including JSON Schema) are built once at boot and spliced verbatim into responses. See
`tools-resources-prompts.md`.
- **Transport**: Streamable HTTP, `POST`-only, stateless in this revision — see `transport.md`
for exactly what that means and why.
- **Security**: optional, policy-driven OAuth2 via `flash-ext-oidc` — see `security.md`.
- **JSON**: this extension owns its JSON handling independently of `flash-ext-jackson` — see
`jackson-interop.md` for why, and how a future opt-in reuse could work.
## Documents
- [`tools-resources-prompts.md`](tools-resources-prompts.md) — defining tools, resources, prompts
- [`transport.md`](transport.md) — Streamable HTTP scope, session/SSE limitations, Origin validation
- [`security.md`](security.md) — `McpSecurity` policy, OAuth2 resolution, RFC 9728 / RFC 8707
- [`keycloak.md`](keycloak.md) — Keycloak-specific setup cookbook: Dynamic Client Registration,
the RFC 8707 audience mapper gotcha, and how to verify/debug it
- [`jackson-interop.md`](jackson-interop.md) — why this extension does not depend on `flash-ext-jackson`
@@ -0,0 +1,51 @@
# Why no `flash-ext-jackson` interop (yet)
## The decision
`flash-ext-mcp` does not depend on, or integrate with, `flash-ext-jackson`. It brings its own
JSON handling (`jackson-databind`/`jackson-core` as a plain library dependency, wrapped by the
internal `McpJson` utility) and never touches `flash-ext-jackson`'s `Json`/`JacksonMiddleware`/
shared `ObjectMapper`, even if the host app has `flash-ext-jackson` installed. This was a
deliberate choice, discussed and made explicitly — not an oversight — and is written down here
so it isn't accidentally "fixed" later without re-litigating the trade-off.
## Why
`flash-ext-jackson`'s `Json` class is built around full databinding:
`mapper.readValue(bytes, SomeDto.class)` / `mapper.writeValueAsBytes(obj)` — reflection-driven
property matching in both directions. The MCP JSON-RPC envelope has a **fixed, known shape**
(`{jsonrpc, id, method, params}` in, `{jsonrpc, id, result|error}` out) defined by a spec, not by
application DTOs. Given that, hand-writing it with `JsonGenerator` directly is both simpler and
strictly cheaper than round-tripping through databinding: no property-name matching, no
reflection, no intermediate POJO graph for the parts of the response this extension controls
(the envelope itself, `tools/list`/`resources/list`/`prompts/list` — precompiled once at boot,
see `tools-resources-prompts.md` — and the fixed `TextContent`/`TextResourceContents`/
`PromptMessage` shapes). `ToolArguments`/`PromptArguments` read the incoming `arguments` object
as a `JsonNode` tree, not as a databound class, for the same reason — a JSON-RPC tool call's
arguments aren't a DTO with getters/setters, they're a dynamic, per-tool-defined bag of values.
This mirrors how `flash-ext-oidc` already handles its own internal JSON needs (`json-smart` for
token-endpoint responses) independently of `flash-ext-jackson` — extensions with protocol-level
JSON needs that are shaped by a spec, not by user code, own that JSON handling themselves rather
than routing it through the app's general-purpose JSON extension.
## What this means practically
- Installing `flash-ext-mcp` never requires installing `flash-ext-jackson`. A pure MCP server
with no other JSON REST routes has zero unrelated dependencies to configure.
- If the host app *does* have `flash-ext-jackson` installed for its own REST routes, that
`ObjectMapper`'s configuration (custom modules, date formatting, naming strategy, etc.) is
**not** consulted by `flash-ext-mcp` — the two JSON paths are entirely independent today.
## What a future opt-in reuse could look like
Nothing here rules out a later, additive convenience layer: `McpExtension.routes()` could check
`ctx.find(ObjectMapper.class)` (populated by `JacksonExtension.provide()`) and, if present, use
that shared mapper as the backing for an escape hatch such as `ToolArguments.as(Class<T>)` or
for a tool that wants to `ToolResponse.success(someRecord)` and have it serialized with the
app's own conventions — falling back to a locally-constructed default `ObjectMapper` when
`flash-ext-jackson` isn't installed, the same "prefer shared, degrade to sane default" shape
already used for `McpSecurity.AUTO`. That would be purely additive on top of the
`JsonGenerator`-based envelope/content writing described above, not a replacement for it — the
fixed-shape protocol plumbing has no reason to ever go through databinding, regardless of what
convenience layer gets added around it.
@@ -0,0 +1,107 @@
# Keycloak cookbook
`security.md` covers the OAuth2 mechanics `McpOidcIntegration` implements against any
`flash-ext-oidc`-compatible provider. This is the Keycloak-specific setup: the exact Admin
Console configuration for a working MCP OAuth2 flow with open Dynamic Client Registration
(DCR) — no pre-registered clients, any MCP client self-registers on first connect.
## 1. Allow Dynamic Client Registration
MCP clients (Claude Desktop, Claude.ai, MCP Inspector, others) don't share one static OAuth
client — each has its own `redirect_uri` and none know your realm in advance. They self-register
on first connect via `POST {issuer}/clients-registrations/openid-connect` (the
`registration_endpoint` from the AS metadata document, reached via the RFC 9728 Protected
Resource Metadata document `McpExtension` publishes).
**Clients → Client registration**: remove the **Trusted Hosts** policy — it rejects anonymous
registration from hosts not on an explicit allowlist (`403` / `"Host not trusted"`), which
doesn't scale to arbitrary future agents. This does not weaken end-user authentication — DCR
only grants an app a `client_id`; every user still authenticates against Keycloak's real login
screen regardless of which client asked. Lighter hygiene policies (**Max Clients Limit**,
**Consent Required**) can stay, they don't interfere.
## 2. RFC 8707 audience: mapper on `basic`, not a custom scope
`McpOidcIntegration` rejects (403) any token whose `aud` doesn't include the MCP endpoint's
canonical URL. Keycloak doesn't add this by default. The obvious fix — a custom client scope
with an Audience mapper, marked Default, added to Allowed Client Scopes — **does not work**:
clients created via the `openid-connect` DCR endpoint only ever get scopes they explicitly
request, and most MCP clients (including MCP Inspector) don't request anything beyond what a
server tells them to via `scopes_supported` (step 3). Default-scope auto-attachment, which is
how a normal manually-created client would pick up a custom Default scope, doesn't apply to
DCR-created clients at all.
`basic` is the one built-in scope Keycloak attaches to every client unconditionally, regardless
of what it registered with. Put the audience mapper there:
1. **Client scopes → `basic`****Mappers****Add mapper****By configuration**
**Audience**.
2. **Included Custom Audience** = the exact value your server expects — check
`GET {parent-of-rootPath}/.well-known/oauth-protected-resource{rootPath}` on the running
server for the `resource` field it publishes (auto-derived from the request's
forwarded/`Host` headers — see `security.md`). Leave **Included Client Audience** empty (that
targets another Keycloak client, not a resource URL).
3. **Add to access token** = ON.
4. **Save.**
This is unconditional and works regardless of client cooperation — keep it even after step 3
below gets other claims flowing normally, since audience binding is a hard spec requirement
that shouldn't depend on a client bothering to request the right scope.
## 3. Other claims (username, email...): `scopes_supported` + Allowed Client Scopes
`OidcUser.username()`/`.email()`/`.name()` read `preferred_username`/`email`/`name` — normally
from the `profile`/`email` client scopes, which DCR clients don't get either, same root cause.
Unlike audience, this **is** fixable the "normal" way, because it doesn't need to survive a
completely uncooperative client:
`McpConfig.scopesSupported("openid", "profile", "email")` publishes those scopes in the PRM
document. MCP clients that read it (confirmed for MCP Inspector) echo them back in their DCR
registration request — `"scope": "openid profile email offline_access"` (`offline_access` is
Inspector's own addition, for refresh tokens). For that request to actually succeed, **Allowed
Client Scopes** needs, exactly:
- **`openid` listed explicitly.** The one genuinely non-obvious step: `openid` is not covered by
**Allow Default Scopes** (On by default) the way other realm-Default scopes are, even though
every OIDC request includes it. Until it's listed here, registration fails with a generic
`403 insufficient_scope` / `"Not permitted to use specified clientScope"` regardless of
whether everything else is configured correctly.
- **`offline_access` listed explicitly** — it's Optional, not Default, so `ALLOW_DEFAULT_SCOPES`
doesn't cover it either.
- **`profile`/`email` — do not list them here.** Mark them **Default** on the **Client scopes**
page (Assigned Type column) instead, and leave **Allow Default Scopes** = On. Adding an
already-Default scope to this list explicitly gets rejected on save
(`"Client scopes not allowed: [...]"`) — the list is for *additional* Optional scopes only.
With that, a real client's token comes back with `preferred_username`/`email` populated
normally.
### Fallback for anything else
For a claim not covered by `openid profile email` (a custom attribute, a role) — or for a client
that ignores `scopes_supported` entirely — add a **User Property** mapper to `basic` too
(Property `username` → Token Claim Name `preferred_username`, or whatever's needed), same as the
audience mapper in step 2. Unconditional, works regardless of client cooperation, costs one
mapper per claim, once, at the realm level — not per tool.
## Verifying without a full OAuth round-trip
**Clients → (any client) → Client scopes → Evaluate**: pick a user, run it — Default scopes
(including `basic`) apply automatically and won't appear in the "Select scope parameters"
picker, which only lists Optional ones — and check the **Generated Access Token** preview.
Confirms mappers work without a browser + real MCP client round-trip each time.
## If a real client still gets rejected
`McpOidcIntegration.audienceGuard` logs the actual mismatch at `WARN`:
```
[flash-ext-mcp] Rejecting token (RFC 8707): aud=<token's actual aud> does not include expected
resource identifier "<what this server expects>" — ...
```
`aud=null` → the `basic` mapper produced nothing (most common cause: **Included Custom
Audience** left blank — the mapper saves fine and silently does nothing without it). A non-null
`aud` that still doesn't match → compare byte-for-byte — the expected side is derived from the
request's own forwarded/`Host` headers, so scheme/host/trailing-slash mismatches show up here
directly, as does a proxy hop that drops `X-Forwarded-Host`.
@@ -0,0 +1,170 @@
# Security
Provider-specific setup steps (not generic OAuth2 mechanics) live in separate cookbooks —
[`keycloak.md`](keycloak.md) for Keycloak: enabling Dynamic Client Registration, why the RFC 8707
audience mapper needs to go on the built-in `basic` scope instead of a custom one, and the exact
Allowed Client Scopes configuration `scopes_supported` needs to actually work.
## `McpSecurity`
`McpConfig.security(...)` controls how the MCP endpoint reacts to `flash-ext-oidc` being
installed (`ctx.find(OidcMiddleware.class)`), resolved once at boot in `McpExtension.routes()`:
| Policy | `flash-ext-oidc` installed | `flash-ext-oidc` absent |
|---|---|---|
| `REQUIRED` | protected | **boot fails** (`IllegalStateException`) |
| `AUTO` (default) | protected | runs unprotected, logs a warning |
| `NONE` | never protected, even if oidc is installed elsewhere in the app | runs unprotected |
Use `REQUIRED` for anything you intend to run in production reachable over the network — it
turns "someone forgot to wire up OAuth2" into a startup crash instead of a silently open
endpoint. `AUTO` is meant for local development, where spinning up a real identity provider is
friction you don't want yet.
## Why `flash-ext-oidc` is an *optional* Maven dependency, concretely
Maven's `<optional>true</optional>` only affects **transitive** propagation: consumers of
`flash-ext-mcp` don't get `flash-ext-oidc` pulled in automatically unless they add it themselves.
Within `flash-ext-mcp` itself, `flash-ext-oidc`'s classes are on the compile/test classpath as
normal — this extension can (and does) reference `OidcMiddleware`/`ClaimsHolder` directly in
source.
That reference is isolated in its own class, `McpOidcIntegration`, invoked only from inside a
`catch (NoClassDefFoundError)` block. A bare class-literal like `OidcMiddleware.class` (which
`ctx.find(OidcMiddleware.class)` needs) forces the JVM to resolve that type the moment it's
evaluated — if `flash-ext-oidc` is not on the *runtime* classpath at all (a genuinely
MCP-only install, no OAuth2 anywhere in the app), the first such reference throws
`NoClassDefFoundError`. Keeping that reference inside a separate, lazily-loaded class means
`McpExtension` itself loads and works fine standalone; only the attempt to actually use OIDC
fails, and only when there's something to fail. This mirrors `OidcExtension`'s own lazy bridge to
`flash-ext-openapi` — same technique, same reason.
## OAuth2 resolution details — zero-config by default
When oidc is available and `security() != NONE`, `McpOidcIntegration` (an isolated,
lazily-loaded bridge — see its javadoc) derives everything an MCP OAuth2 resource server needs
straight from the installed `OidcMiddleware`, with no additional `McpConfig` calls required:
1. The MCP route is wrapped with `flash-ext-oidc`'s own `OidcMiddleware.protect(resourceMetadataPath)`
— the same Bearer-token/JWKS validation path used everywhere else in Flash5, plus a
`resource_metadata` challenge parameter (see below). No JWT parsing or JWKS handling is
reimplemented here.
2. An audience guard always runs after `protect(...)`: it reads the validated claims from
`ClaimsHolder` and rejects (`403`) any token whose `aud` claim does not include the resource
identifier — **RFC 8707 Resource Indicators / audience binding**, enforced unconditionally,
not opt-in. `OidcMiddleware` itself validates `aud` against its own `clientId` for ID
tokens, but deliberately does not enforce audience on access tokens (it varies by provider)
— the MCP extension adds that check on top, scoped to its own resource identifier.
3. The resource identifier is the canonical URI of the MCP endpoint, resolved **per request** by
`OidcMiddleware#selfOrigin` + `rootPath` — the same scheme/host resolution `OidcExtension`
uses for its own redirect URIs: `X-Forwarded-Host`/`X-Forwarded-Proto` when the request came
through a reverse proxy, otherwise `{selfScheme()}://{Host header}`. Behind a proxy the
`Host` alone is the upstream address the proxy dialled, which would publish a resource
identifier no client can reach. `McpConfig.resourceIdentifier(...)` still overrides it
outright for a proxy that forwards neither header.
4. The authorization server issuer is read from `OidcMiddleware#issuer()` unless
`McpConfig.authorizationServerIssuer(...)` overrides it.
## RFC 9728 Protected Resource Metadata
Whenever the endpoint ends up protected, `flash-ext-mcp` publishes a Protected Resource Metadata
document at `/.well-known/oauth-protected-resource{rootPath}` — no explicit `resourceIdentifier`/
`authorizationServerIssuer` configuration required, both are auto-derived as described above:
```json
{ "resource": "https://mcp.example.com/mcp", "authorization_servers": ["https://auth.example.com/realms/myrealm"] }
```
`resource` is computed per request from the incoming request's forwarded/`Host` headers (see
above), so the document is correct without hardcoding the server's own public URL.
### `scopes_supported`
Optional per RFC 9728, omitted from the document entirely unless set via
`McpConfig.scopesSupported("openid", "profile", "email")`:
```json
{ "resource": "...", "authorization_servers": ["..."], "scopes_supported": ["openid", "profile", "email"] }
```
This is pure advertisement — token validation doesn't change based on it — but it matters in
practice: a client that ignores it and requests no scope at all (many do — see `keycloak.md`)
only gets back whatever the authorization server treats as always-included regardless of
request, which for Keycloak is just its built-in `basic` scope. A client that *does* read
`scopes_supported` and echoes it back in its authorization/token requests gets a token with the
claims those scopes actually provide (`profile``preferred_username`/`name`, etc.), without
needing every one of those claims hand-mapped onto `basic`. Set it to whatever scopes your
`McpTool`s actually read off `ClaimsHolder`/`OidcUser` — there's no way to auto-derive this list,
it depends entirely on what your tools do with the claims.
## `WWW-Authenticate: resource_metadata` (RFC 9728 §5.1)
The MCP Authorization spec **requires** a `401` to carry `resource_metadata` in
`WWW-Authenticate`, pointing at the Protected Resource Metadata document above — this is how a
spec-compliant client discovers the authorization server without out-of-band configuration.
`OidcMiddleware.protect(String resourceMetadataPath)` (an overload added specifically for this)
builds that challenge automatically:
```
WWW-Authenticate: Bearer realm="...", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
```
The plain `OidcMiddleware.protect()` (no argument), used by every other Flash5 app, is
unaffected — this parameter is additive and MCP-specific.
## Per-tool `@RolesAllowed`/`@ScopesAllowed`
`McpTool` subclasses can carry `flash-ext-oidc`'s `@RolesAllowed`/`@ScopesAllowed`:
```java
@Tool(name = "delete_route", description = "Delete a route")
@RolesAllowed("admin")
public class DeleteRouteTool extends McpTool {
@Override public ToolResponse call(ToolArguments args) { ... }
}
```
This does **not** reuse `flash-ext-oidc`'s per-route middleware mechanism (`ctx.addAnnotationProcessor`,
the thing that makes these annotations work on a `RequestHandler`) — it can't: every tool shares
one HTTP route (`POST {rootPath}`), already wrapped by whatever `McpSecurity` resolved above, so
there is no per-tool route to attach a different middleware chain to. Instead,
`McpOidcIntegration.compileToolPolicy` reads the annotations once at boot (`McpRegistry.scan`)
and compiles them into a closure (`McpAuthPolicy`) that `McpDispatcher` runs *after* the
route-wide auth has already succeeded and *before* invoking the specific tool named in the
`tools/call` request — narrowing what's already-authenticated, not replacing it. A denial is a
normal `isError: true` tool result (see `ToolResponse.error`), not an HTTP-level rejection — the
model sees why, the same as any other tool failure.
Roles are read via `OidcUser#hasRole` against `McpConfig.rolesClaimPath(...)` (default
`"realm_access.roles"`, matching `OidcConfig`'s own default — set this explicitly if the two
diverge; there's no way to read `OidcConfig`'s actual configured value from here). Scopes use
`OidcUser#hasScope`'s built-in default claim paths (`scope`/`scp`), no extra config needed.
`@ScopesAllowed(match = ScopesAllowed.Match.ANY)` and multi-role `@RolesAllowed({"admin",
"editor"})` (OR semantics) both work exactly as they do on a `RequestHandler`.
**`@Authenticated` alone has no effect and fails boot.** Once oidc is active for a server, every
tool call is already authenticated — there's no per-tool public/authenticated split the way
there is for HTTP routes, so a bare `@Authenticated` on a tool can't mean anything and would
silently do nothing if allowed to compile. Boot fails instead, with a message pointing at
`@RolesAllowed`/`@ScopesAllowed` as the actual narrowing mechanism.
**Annotating a tool without active OAuth2 also fails boot**, not silently at request time: if
`@RolesAllowed`/`@ScopesAllowed`/`@Authenticated` shows up on a tool while `McpSecurity` resolved
to unprotected (`NONE`, or `AUTO` with no oidc installed), that's very likely a forgotten
`OidcExtension` install or a `McpSecurity.NONE` left over from local dev — `IllegalStateException`
at `app.start()`.
## The `HttpException` safety net
`flash-ext-oidc`'s middleware throws `HttpException.unauthorized()`/`forbidden()` on auth
failure. Flash5's core does **not** special-case `HttpException` in the default exception
handler — the out-of-the-box `AbstractRouter` default always returns a generic `500`, regardless
of the thrown exception's embedded status code; only an app that explicitly calls
`FlashApp#onException(...)` (or installs something that does) gets `HttpException.status()`
honored.
To keep the MCP endpoint correct regardless of what the rest of the app configures,
`McpTransportGuards.httpExceptionGuard()` wraps the whole route and translates `HttpException`
into the right HTTP status itself, rather than letting it fall through to the app's (possibly
unconfigured) global handler. This is scoped entirely to the MCP route — it does not touch or
override the app's `onException` for any other route.
@@ -0,0 +1,107 @@
# Tools, Resources, Prompts
## One class per feature
Every tool, resource, and prompt is its own class — the same shape as a Flash `RequestHandler`,
minus the HTTP-specific bits:
```java
public abstract class McpTool {
protected void onInit() {} // cache services here, once, at boot
protected <T> T require(Class<T> type) { ... } // FlashContext lookup
public abstract ToolResponse call(ToolArguments args) throws Exception; // hot path
}
```
`McpResource` (`read()`) and `McpPrompt` (`render(PromptArguments)`) follow the exact same
shape. There is deliberately no CDI-style `@Inject` and no method-per-tool bean class — Flash5
handlers are classes, and MCP features follow that convention.
## Declaring metadata
Metadata (name, description, input schema) lives entirely in the annotation, not in reflected
method signatures — the whole JSON Schema is known at scan time and compiled once:
```java
@Tool(
name = "get_weather",
description = "Get current weather for a city",
args = {
@ToolArg(name = "city", description = "City name", required = true),
@ToolArg(name = "days", type = ToolArgType.INTEGER, description = "Forecast horizon")
}
)
public class GetWeatherTool extends McpTool {
@Override
public ToolResponse call(ToolArguments args) {
String city = args.getString("city");
int days = args.getInt("days", 1);
...
}
}
```
`ToolArgType` maps directly to JSON Schema primitive types: `STRING`, `INTEGER`, `NUMBER`,
`BOOLEAN`, `OBJECT`, `ARRAY`. Nested object/array schemas beyond the primitive type keyword are
not modeled in this revision — declare those tools with a looser `OBJECT`/`ARRAY` type and parse
the raw shape via `ToolArguments.raw(name)`.
`ToolArguments`/`PromptArguments` are thin typed accessors over the already-parsed JSON — no
databinding, no reflection, no intermediate DTO:
```java
args.getString("city");
args.getInt("days", 1);
args.getBoolean("metric", true);
args.raw("filters"); // escape hatch: JsonNode for nested/array arguments
```
## Discovery
`McpConfig.toolsPackage("com.example.tools")` scans that package (and subpackages) for concrete
`McpTool`/`McpResource`/`McpPrompt` subclasses carrying `@Tool`/`@Resource`/`@Prompt`. Same
fail-fast contract as `FlashApp.scan()`: missing package, missing no-arg constructor, or a class
that fails to load aborts startup immediately with a clear message. Duplicate names/URIs also
fail fast at boot.
## Resources and Prompts
```java
@Resource(uri = "config://app-settings", description = "Application settings", mimeType = "application/json")
public class AppSettingsResource extends McpResource {
@Override
public ResourceContents read() {
return TextResourceContents.of(uri(), "application/json", settingsJson());
}
}
@Prompt(name = "summarize", args = @PromptArg(name = "text", required = true))
public class SummarizePrompt extends McpPrompt {
@Override
public PromptMessage render(PromptArguments args) {
return PromptMessage.withUserRole(new TextContent("Summarize: " + args.getString("text")));
}
}
```
`McpResource.uri()` returns the URI declared on `@Resource`, cached at bind time — no repeated
annotation lookups on the hot path.
## Content types
`Content` and `ResourceContents` are `sealed`, currently permitting only `TextContent` and
`TextResourceContents` respectively. This is a deliberate v1 scope cut, not an oversight — image
content, embedded resources, and blob resources are extension points for a future revision
(extend the `permits` clause and `McpContentWriter`).
## Tool failures vs. protocol errors
A `McpTool.call(...)` that throws is caught by the dispatcher and turned into
`ToolResponse.error(message)` — per the MCP specification this is a normal JSON-RPC *result*
with `isError: true`, not a JSON-RPC error, so the calling model can see and react to it. Prefer
returning `ToolResponse.error(...)` explicitly when you can produce a better message than the
raw exception text.
`McpResource.read()`/`McpPrompt.render(...)` failures, by contrast, surface as JSON-RPC errors
(`-32603 Internal error`) — the specification does not define a soft-failure content convention
for those two.
@@ -0,0 +1,45 @@
# Transport
`flash-ext-mcp` implements the **Streamable HTTP** transport from the MCP specification
(revision `2025-11-25`). `stdio` is out of scope — Flash5 is an HTTP framework, and a
subprocess-stdio transport doesn't fit its model.
## What this revision implements
- A single `POST {rootPath}` endpoint (default `/mcp`) accepting one JSON-RPC 2.0 message per
request and responding with a plain JSON object — the "standard JSON object response" mode the
specification allows as an alternative to opening a Server-Sent Events stream per request.
- `Origin` header validation (DNS-rebinding protection), configurable via
`McpConfig.allowedOrigins(...)`.
- Full JSON-RPC lifecycle: `initialize`, `notifications/initialized` (and any other
`notifications/*`/id-less message — answered with a bare `202 Accepted`, no body, per
JSON-RPC's notification semantics), `ping`, `tools/list`, `tools/call`, `resources/list`,
`resources/read`, `prompts/list`, `prompts/get`.
## What this revision deliberately does not implement
- **No `Mcp-Session-Id` / session state.** The specification says a server "MAY assign a session
ID at initialization time" — it is optional, not mandatory. This server is stateless: every
`POST` is handled independently, with no server-side session store. `initialize` does not need
to precede other calls for the server to function (there's no session to be "not initialized"
yet), which is a looser contract than a session-aware server would enforce — acceptable for a
static, boot-time-defined tool/resource/prompt catalog.
- **No Server-Sent Events stream.** `GET {rootPath}` (used by session-aware servers to open a
standing SSE stream for server-initiated pushes) is not registered — MCP clients that only
speak the request/response half of Streamable HTTP work unaffected; clients that require a
standing SSE connection are not supported by this revision.
Both are real, intentional scope cuts for a first version — not just to keep the surface area
small: a static, precompiled tool catalog (see `tools-resources-prompts.md`) has no
`listChanged` events to push and no long-running server-initiated messages to stream, so the
stateful half of the transport buys little for the common case this extension targets. Sessions
and SSE are natural extension points if a future revision needs server push (e.g. dynamic tool
registration, elicitation, or sampling requests initiated by the server).
## Why `POST`, not the new `QUERY` HTTP method
Flash5's core recently gained `HttpMethod.QUERY` (safe, idempotent, carries a body — a good
semantic fit for JSON-RPC-over-HTTP in general). It is **not** used here: the MCP Streamable
HTTP specification mandates `POST` for the client-to-server message path. Real MCP clients send
`POST`; using `QUERY` instead would break interoperability with every existing client for a
semantic nicety this extension doesn't need standalone.
+48
View File
@@ -0,0 +1,48 @@
<?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-mcp</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-oidc</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</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>
@@ -0,0 +1,8 @@
package dev.relism.flash.ext.mcp;
/**
* MCP tool/prompt content block. {@code sealed} to the variants this extension currently
* writes on the wire — extend the permits clause (and {@link McpContentWriter}) to add
* {@code ImageContent}, {@code EmbeddedResource}, etc. in a future revision.
*/
public sealed interface Content permits TextContent {}
@@ -0,0 +1,13 @@
package dev.relism.flash.ext.mcp;
/** Standard JSON-RPC 2.0 error codes used by the MCP transport. */
final class JsonRpcErrorCode {
private JsonRpcErrorCode() {}
static final int PARSE_ERROR = -32700;
static final int INVALID_REQUEST = -32600;
static final int METHOD_NOT_FOUND = -32601;
static final int INVALID_PARAMS = -32602;
static final int INTERNAL_ERROR = -32603;
}
@@ -0,0 +1,23 @@
package dev.relism.flash.ext.mcp;
import java.util.function.Supplier;
/**
* Compiled per-tool authorization requirement, built once at boot by {@link McpOidcIntegration}
* from {@code @RolesAllowed}/{@code @ScopesAllowed} on an {@link McpTool} subclass — {@code null}
* on {@link McpRegistry.RegisteredTool} means no restriction beyond whatever {@link McpSecurity}
* already enforces route-wide.
*
* <p>{@code check} is a closure, not a raw role/scope list — this is what lets this record (and
* its only caller, {@link McpDispatcher}) stay free of any compile-time reference to a {@code
* flash-ext-oidc} type, preserving the same classload isolation {@link McpOidcIntegration}'s
* javadoc describes for the rest of the OIDC bridge. Only the plain-JDK {@link Supplier}
* signature crosses the boundary; the closure itself, built once inside {@code
* McpOidcIntegration}, is the only place that ever touches {@code OidcUser}/{@code ClaimsHolder}.
*
* <p>Returns {@code null} from {@link #check()}{@code .get()} when authorized, or a
* human-readable denial reason otherwise — invoked once per {@code tools/call} against an
* annotated tool, never allocated on that path (the closure and its captured role/scope arrays
* are built exactly once, at boot).
*/
record McpAuthPolicy(Supplier<String> check) {}
@@ -0,0 +1,143 @@
package dev.relism.flash.ext.mcp;
import java.util.ArrayList;
import java.util.List;
/**
* Immutable configuration for {@link McpExtension}.
*
* <pre>{@code
* McpConfig.builder("my-mcp-server")
* .version("1.0.0")
* .rootPath("/mcp")
* .toolsPackage("com.example.tools")
* .security(McpSecurity.REQUIRED)
* .scopesSupported("openid", "profile", "email")
* .build();
* }</pre>
*/
public final class McpConfig {
private final String name;
private final String version;
private final String instructions;
private final String rootPath;
private final String toolsPackage;
private final McpSecurity security;
private final String resourceIdentifier;
private final String authorizationServerIssuer;
private final List<String> allowedOrigins;
private final List<String> scopesSupported;
private McpConfig(Builder b) {
this.name = b.name;
this.version = b.version;
this.instructions = b.instructions;
this.rootPath = b.rootPath;
this.toolsPackage = b.toolsPackage;
this.security = b.security;
this.resourceIdentifier = b.resourceIdentifier;
this.authorizationServerIssuer = b.authorizationServerIssuer;
this.allowedOrigins = List.copyOf(b.allowedOrigins);
this.scopesSupported = List.copyOf(b.scopesSupported);
}
String name() { return name; }
String version() { return version; }
String instructions() { return instructions; }
String rootPath() { return rootPath; }
String toolsPackage() { return toolsPackage; }
McpSecurity security() { return security; }
String resourceIdentifier() { return resourceIdentifier; }
String authorizationServerIssuer() { return authorizationServerIssuer; }
List<String> allowedOrigins() { return allowedOrigins; }
List<String> scopesSupported() { return scopesSupported; }
public static Builder builder(String name) { return new Builder(name); }
public static final class Builder {
private final String name;
private String version = "1.0.0";
private String instructions;
private String rootPath = "/mcp";
private String toolsPackage;
private McpSecurity security = McpSecurity.AUTO;
private String resourceIdentifier;
private String authorizationServerIssuer;
private final List<String> allowedOrigins = new ArrayList<>();
private final List<String> scopesSupported = new ArrayList<>();
private Builder(String name) {
if (name == null || name.isBlank())
throw new IllegalArgumentException("McpConfig server name cannot be blank");
this.name = name;
}
/** Server version reported in {@code initialize}'s {@code serverInfo}. Default {@code "1.0.0"}. */
public Builder version(String version) { this.version = version; return this; }
/** Free-text instructions surfaced to the client at {@code initialize} time. */
public Builder instructions(String instructions) { this.instructions = instructions; return this; }
/** HTTP path for the Streamable HTTP endpoint. Default {@code "/mcp"}. */
public Builder rootPath(String rootPath) { this.rootPath = normalize(rootPath); return this; }
/** Package scanned for {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} classes. Required. */
public Builder toolsPackage(String toolsPackage) { this.toolsPackage = toolsPackage; return this; }
/** OAuth2 requirement policy. Default {@link McpSecurity#AUTO}. */
public Builder security(McpSecurity security) { this.security = security; return this; }
/**
* Canonical URI of this MCP endpoint, used for RFC 8707 audience binding: tokens whose
* {@code aud} claim does not include this value are rejected. Optional — when
* {@code flash-ext-oidc} is installed, this is auto-derived per request from the
* forwarded/{@code Host} headers (same resolution {@code OidcExtension} uses for its own
* redirect URIs) and audience binding is enforced unconditionally. Set this explicitly
* only to override that guess — a reverse proxy that forwards neither
* {@code X-Forwarded-Host} nor {@code X-Forwarded-Proto}.
*/
public Builder resourceIdentifier(String resourceIdentifier) { this.resourceIdentifier = resourceIdentifier; return this; }
/**
* Authorization server issuer URL, published in the RFC 9728 Protected Resource
* Metadata document at {@code /.well-known/oauth-protected-resource{rootPath}}. Optional
* — when {@code flash-ext-oidc} is installed, this is auto-derived from its configured
* issuer. Set this explicitly only to override that (e.g. publishing a different issuer
* than the one actually validating tokens).
*/
public Builder authorizationServerIssuer(String issuer) { this.authorizationServerIssuer = issuer; return this; }
/**
* Origins allowed to call the MCP endpoint (DNS-rebinding protection, per the Streamable
* HTTP transport spec). If never set, {@code Origin} validation is skipped and a warning
* is logged at boot.
*/
public Builder allowedOrigins(String... origins) { this.allowedOrigins.addAll(List.of(origins)); return this; }
/**
* OAuth2 scopes this server expects clients to request, published as {@code
* scopes_supported} in the RFC 9728 Protected Resource Metadata document. Optional per
* the spec — omitted from the document entirely if never set. A spec-compliant client
* reads this to know what to put in its authorization/token requests instead of
* requesting nothing; see {@code docs/keycloak.md}'s "same story for any other claim"
* section for why this matters in practice (a client that requests no scope only gets
* whatever your authorization server treats as always-included, e.g. Keycloak's `basic`).
* Purely advertisement — this server still validates whatever token it actually receives
* the same way regardless of what a client requested.
*/
public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; }
public McpConfig build() {
if (toolsPackage == null || toolsPackage.isBlank())
throw new IllegalStateException(
"McpConfig.toolsPackage(...) is required — declare at least one @Tool/@Resource/@Prompt class");
return new McpConfig(this);
}
private static String normalize(String path) {
if (path == null || path.isBlank()) throw new IllegalArgumentException("rootPath cannot be blank");
return path.startsWith("/") ? path : "/" + path;
}
}
}
@@ -0,0 +1,54 @@
package dev.relism.flash.ext.mcp;
import com.fasterxml.jackson.core.JsonGenerator;
import java.io.IOException;
import java.util.List;
import java.util.Locale;
/**
* Direct {@link JsonGenerator} writers for the fixed, known shapes of {@link Content},
* {@link ResourceContents} and {@link PromptMessage} — no databinding, one {@code switch}
* per call, matching the fixed wire shape defined by the MCP specification.
*/
final class McpContentWriter {
private McpContentWriter() {}
static void writeContentArray(JsonGenerator gen, List<Content> items) throws IOException {
gen.writeStartArray();
for (Content c : items) writeContent(gen, c);
gen.writeEndArray();
}
static void writeContent(JsonGenerator gen, Content content) throws IOException {
if (content instanceof TextContent tc) {
gen.writeStartObject();
gen.writeStringField("type", "text");
gen.writeStringField("text", tc.text());
gen.writeEndObject();
return;
}
throw new IllegalStateException("Unhandled Content variant: " + content.getClass());
}
static void writeResourceContents(JsonGenerator gen, ResourceContents contents) throws IOException {
if (contents instanceof TextResourceContents trc) {
gen.writeStartObject();
gen.writeStringField("uri", trc.uri());
gen.writeStringField("mimeType", trc.mimeType());
gen.writeStringField("text", trc.text());
gen.writeEndObject();
return;
}
throw new IllegalStateException("Unhandled ResourceContents variant: " + contents.getClass());
}
static void writePromptMessage(JsonGenerator gen, PromptMessage message) throws IOException {
gen.writeStartObject();
gen.writeStringField("role", message.role().name().toLowerCase(Locale.ROOT));
gen.writeFieldName("content");
writeContent(gen, message.content());
gen.writeEndObject();
}
}
@@ -0,0 +1,253 @@
package dev.relism.flash.ext.mcp;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonNode;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.Response;
import java.io.IOException;
/**
* JSON-RPC 2.0 dispatcher for the MCP Streamable HTTP endpoint — one instance per
* {@link McpExtension}, built once at boot from a resolved {@link McpRegistry}.
*
* <p>Per the MCP specification, a {@code tools/call} failure is a normal JSON-RPC
* <em>result</em> with {@code isError: true} (see {@link ToolResponse#error}), not a JSON-RPC
* error — the model needs to see it. Everything else that goes wrong (bad params, unknown
* tool/resource/prompt name, resource/prompt handler exceptions) is a JSON-RPC error object,
* always returned with HTTP 200: the HTTP request itself succeeded, only the RPC did not. Only
* malformed HTTP-level input (unparsable JSON, not a JSON object) gets HTTP 400. A
* {@code @RolesAllowed}/{@code @ScopesAllowed} denial (see {@link McpAuthPolicy}) is the same
* category — {@code isError: true}, tool never invoked — not a transport-level rejection; the
* route-wide 401/403 for "not authenticated at all" already happened earlier, in the {@code
* OidcMiddleware}/audience-guard middleware chain, before this dispatcher ever runs.
*/
final class McpDispatcher {
/** Protocol revision this dispatcher implements. */
static final String PROTOCOL_VERSION = "2025-11-25";
private final McpRegistry registry;
private final String serverName;
private final String serverVersion;
private final String instructions;
McpDispatcher(McpRegistry registry, String serverName, String serverVersion, String instructions) {
this.registry = registry;
this.serverName = serverName;
this.serverVersion = serverVersion;
this.instructions = instructions;
}
void handle(Request req, Response res) {
byte[] body = req.body().bytes();
JsonNode root;
try {
root = McpJson.parse(body);
} catch (IOException e) {
writeError(res, 400, null, JsonRpcErrorCode.PARSE_ERROR, "Parse error: " + e.getMessage());
return;
}
if (root == null || !root.isObject()) {
writeError(res, 400, null, JsonRpcErrorCode.INVALID_REQUEST, "Request must be a JSON object");
return;
}
JsonNode idNode = root.get("id");
boolean isNotification = idNode == null;
String method = root.path("method").asText(null);
JsonNode params = root.path("params");
if (method == null || method.isBlank()) {
if (isNotification) { res.status(202); return; }
writeError(res, 400, idNode, JsonRpcErrorCode.INVALID_REQUEST, "Missing \"method\"");
return;
}
try {
switch (method) {
case "initialize" -> handleInitialize(res, idNode);
case "notifications/initialized", "notifications/cancelled" -> res.status(202);
case "ping" -> handlePing(res, idNode);
case "tools/list" -> handleToolsList(res, idNode);
case "tools/call" -> handleToolsCall(res, idNode, params);
case "resources/list" -> handleResourcesList(res, idNode);
case "resources/read" -> handleResourcesRead(res, idNode, params);
case "prompts/list" -> handlePromptsList(res, idNode);
case "prompts/get" -> handlePromptsGet(res, idNode, params);
default -> {
if (isNotification) { res.status(202); return; }
throw McpProtocolException.methodNotFound(method);
}
}
} catch (McpProtocolException e) {
writeError(res, 200, idNode, e.code, e.getMessage());
} catch (Exception e) {
writeError(res, 200, idNode, JsonRpcErrorCode.INTERNAL_ERROR, "Internal error: " + e.getMessage());
}
}
// ── Method handlers ──────────────────────────────────────────────────────
private void handleInitialize(Response res, JsonNode id) {
writeResult(res, id, gen -> {
gen.writeStartObject();
gen.writeStringField("protocolVersion", PROTOCOL_VERSION);
gen.writeObjectFieldStart("capabilities");
if (registry.hasTools()) writeEmptyCapability(gen, "tools");
if (registry.hasResources()) writeEmptyCapability(gen, "resources");
if (registry.hasPrompts()) writeEmptyCapability(gen, "prompts");
gen.writeEndObject();
gen.writeObjectFieldStart("serverInfo");
gen.writeStringField("name", serverName);
gen.writeStringField("version", serverVersion);
gen.writeEndObject();
if (instructions != null && !instructions.isBlank())
gen.writeStringField("instructions", instructions);
gen.writeEndObject();
});
}
private static void writeEmptyCapability(JsonGenerator gen, String field) throws IOException {
gen.writeObjectFieldStart(field);
gen.writeBooleanField("listChanged", false);
gen.writeEndObject();
}
private void handlePing(Response res, JsonNode id) {
writeResult(res, id, gen -> { gen.writeStartObject(); gen.writeEndObject(); });
}
private void handleToolsList(Response res, JsonNode id) {
writeResult(res, id, gen -> {
gen.writeStartObject();
gen.writeFieldName("tools");
gen.writeRawValue(registry.toolsListJson());
gen.writeEndObject();
});
}
private void handleToolsCall(Response res, JsonNode id, JsonNode params) {
String name = params.path("name").asText(null);
if (name == null || name.isBlank())
throw McpProtocolException.invalidParams("\"name\" is required");
McpRegistry.RegisteredTool tool = registry.tool(name);
if (tool == null)
throw McpProtocolException.invalidParams("Unknown tool: " + name);
ToolResponse result;
String denied = tool.policy() != null ? tool.policy().check().get() : null;
if (denied != null) {
result = ToolResponse.error("Tool \"" + name + "\" denied: " + denied);
} else {
ToolArguments args = new ToolArguments(params.path("arguments"));
try {
result = tool.instance().call(args);
} catch (Exception e) {
result = ToolResponse.error("Tool \"" + name + "\" failed: " + e.getMessage());
}
}
ToolResponse finalResult = result;
writeResult(res, id, gen -> {
gen.writeStartObject();
gen.writeBooleanField("isError", finalResult.isError());
gen.writeFieldName("content");
McpContentWriter.writeContentArray(gen, finalResult.content());
gen.writeEndObject();
});
}
private void handleResourcesList(Response res, JsonNode id) {
writeResult(res, id, gen -> {
gen.writeStartObject();
gen.writeFieldName("resources");
gen.writeRawValue(registry.resourcesListJson());
gen.writeEndObject();
});
}
private void handleResourcesRead(Response res, JsonNode id, JsonNode params) throws Exception {
String uri = params.path("uri").asText(null);
if (uri == null || uri.isBlank())
throw McpProtocolException.invalidParams("\"uri\" is required");
McpRegistry.RegisteredResource resource = registry.resource(uri);
if (resource == null)
throw McpProtocolException.invalidParams("Unknown resource: " + uri);
ResourceContents contents = resource.instance().read();
writeResult(res, id, gen -> {
gen.writeStartObject();
gen.writeArrayFieldStart("contents");
McpContentWriter.writeResourceContents(gen, contents);
gen.writeEndArray();
gen.writeEndObject();
});
}
private void handlePromptsList(Response res, JsonNode id) {
writeResult(res, id, gen -> {
gen.writeStartObject();
gen.writeFieldName("prompts");
gen.writeRawValue(registry.promptsListJson());
gen.writeEndObject();
});
}
private void handlePromptsGet(Response res, JsonNode id, JsonNode params) throws Exception {
String name = params.path("name").asText(null);
if (name == null || name.isBlank())
throw McpProtocolException.invalidParams("\"name\" is required");
McpRegistry.RegisteredPrompt prompt = registry.prompt(name);
if (prompt == null)
throw McpProtocolException.invalidParams("Unknown prompt: " + name);
PromptArguments args = new PromptArguments(params.path("arguments"));
PromptMessage message = prompt.instance().render(args);
writeResult(res, id, gen -> {
gen.writeStartObject();
gen.writeArrayFieldStart("messages");
McpContentWriter.writePromptMessage(gen, message);
gen.writeEndArray();
gen.writeEndObject();
});
}
// ── Envelope writers ─────────────────────────────────────────────────────
private void writeResult(Response res, JsonNode id, McpJson.JsonWriter resultWriter) {
String body = McpJson.buildString(gen -> {
gen.writeStartObject();
gen.writeStringField("jsonrpc", "2.0");
gen.writeFieldName("id");
writeId(gen, id);
gen.writeFieldName("result");
resultWriter.write(gen);
gen.writeEndObject();
});
res.status(200).type(ContentType.JSON).body(body);
}
private void writeError(Response res, int httpStatus, JsonNode id, int code, String message) {
String body = McpJson.buildString(gen -> {
gen.writeStartObject();
gen.writeStringField("jsonrpc", "2.0");
gen.writeFieldName("id");
writeId(gen, id);
gen.writeObjectFieldStart("error");
gen.writeNumberField("code", code);
gen.writeStringField("message", message);
gen.writeEndObject();
gen.writeEndObject();
});
res.status(httpStatus).type(ContentType.JSON).body(body);
}
private static void writeId(JsonGenerator gen, JsonNode id) throws IOException {
if (id == null || id.isNull() || id.isMissingNode()) { gen.writeNull(); return; }
if (id.isTextual()) gen.writeString(id.asText());
else if (id.isIntegralNumber()) gen.writeNumber(id.asLong());
else if (id.isFloatingPointNumber()) gen.writeNumber(id.asDouble());
else gen.writeNull();
}
}
@@ -0,0 +1,120 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.routing.Middleware;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.List;
/**
* MCP (Model Context Protocol) server extension. Streamable HTTP transport — a single
* {@code POST} JSON-RPC endpoint, stateless in this revision (no session, no SSE stream; see
* {@code docs/transport.md}) — dispatch precompiled at boot from classes annotated with
* {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} under
* {@link McpConfig#toolsPackage(String)}.
*
* <pre>{@code
* // Standalone, no OAuth2
* FlashApp.create(8080)
* .install(new McpExtension(McpConfig.builder("my-mcp-server")
* .toolsPackage("com.example.tools")
* .build()))
* .start();
*
* // With flash-ext-oidc as the OAuth2 resource server — zero extra config: issuer, canonical
* // resource identifier, RFC 8707 audience binding and RFC 9728 metadata are all derived from
* // the installed OidcExtension.
* FlashApp.create(8080)
* .install(new OidcExtension(oidcConfig))
* .install(new McpExtension(McpConfig.builder("my-mcp-server")
* .toolsPackage("com.example.tools")
* .security(McpSecurity.REQUIRED)
* .build()))
* .start();
* }</pre>
*
* <p>One server per {@code McpExtension} instance — install multiple instances (distinct
* {@code rootPath}, distinct {@code toolsPackage}) for multiple MCP servers on one app,
* mirroring the {@code OidcExtension} multi-tenant pattern. See {@code docs/security.md} for
* the full OAuth2 resolution rules.
*/
@Slf4j
public class McpExtension implements FlashExtension {
private final McpConfig config;
public McpExtension(McpConfig config) {
this.config = config;
}
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
ctx.onReady(() -> registerRoutes(app, ctx));
}
private void registerRoutes(FlashRegistrar<?> app, FlashContext ctx) {
// Resolved before scanning so McpRegistry knows, per tool, whether @RolesAllowed/
// @ScopesAllowed are backed by real OAuth2 protection or a boot-time misconfiguration
// (see McpOidcIntegration#compileToolPolicy) — must run first, not after.
McpOidcIntegration.Resolved secured = resolveSecurity(ctx);
McpRegistry registry = McpRegistry.scan(config.toolsPackage(), ctx, secured != null,
secured == null ? null : secured.rolesClaimPath());
McpDispatcher dispatcher = new McpDispatcher(registry, config.name(), config.version(), config.instructions());
List<Middleware> chain = new ArrayList<>(3);
chain.add(McpTransportGuards.httpExceptionGuard());
chain.add(McpTransportGuards.originGuard(config.allowedOrigins()));
if (secured != null) chain.add(secured.security());
app.post(config.rootPath(), (req, res) -> { dispatcher.handle(req, res); return null; },
chain.toArray(Middleware[]::new));
registerResourceMetadata(app, secured);
}
private McpOidcIntegration.Resolved resolveSecurity(FlashContext ctx) {
if (config.security() == McpSecurity.NONE) return null;
McpOidcIntegration.Resolved resolved;
try {
resolved = McpOidcIntegration.resolve(ctx, config);
} catch (NoClassDefFoundError e) {
resolved = null; // flash-ext-oidc not on the classpath at all
}
if (resolved != null) return resolved;
if (config.security() == McpSecurity.REQUIRED) {
throw new IllegalStateException(
"McpSecurity.REQUIRED but flash-ext-oidc is not installed for MCP server \"" + config.name() +
"\" — install an OidcExtension before this McpExtension, or relax security to " +
"McpSecurity.AUTO/NONE if this server is meant to be public.");
}
log.warn("[flash-ext-mcp] MCP server \"{}\" is running WITHOUT OAuth2 protection — " +
"flash-ext-oidc is not installed and McpSecurity.AUTO degrades to unprotected. " +
"Install flash-ext-oidc or set McpSecurity.REQUIRED to make this a hard failure instead.",
config.name());
return null;
}
/**
* RFC 9728 Protected Resource Metadata, built once security is resolved — no longer
* conditioned on {@code resourceIdentifier}/{@code authorizationServerIssuer} being set
* explicitly, since {@link McpOidcIntegration#resolve} now derives both by default. The
* {@code resource} field is computed per request (it depends on that request's own
* forwarded/{@code Host} headers) via {@link McpOidcIntegration.Resolved#resourceIdentifier()}.
*/
private void registerResourceMetadata(FlashRegistrar<?> app, McpOidcIntegration.Resolved secured) {
if (secured == null) return;
String path = "/.well-known/oauth-protected-resource" + config.rootPath();
app.get(path, (req, res) -> {
res.type(ContentType.JSON);
return McpResourceMetadata.build(
secured.resourceIdentifier().apply(req), secured.issuer(), config.scopesSupported());
});
}
}
@@ -0,0 +1,59 @@
package dev.relism.flash.ext.mcp;
import com.fasterxml.jackson.core.JsonEncoding;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
/**
* Internal JSON access shared by the whole extension. Deliberately tree/streaming only —
* no {@code readValue(bytes, Class)} databinding anywhere in this extension. Request bodies
* are parsed once into a {@link JsonNode} (no reflection, no property matching against a
* target class); responses are written directly with {@link JsonGenerator} against the
* envelope's fixed, known shape (also no reflection).
*
* <p>Not wired to {@code flash-ext-jackson} on purpose: the MCP JSON-RPC envelope is internal
* protocol plumbing, not a user-facing serialization concern, so this extension owns its
* mapper independently — same reasoning {@code flash-ext-oidc} applies to its own JSON needs
* (see {@code json-smart} there). See {@code docs/jackson-interop.md} for the full rationale
* and how a future opt-in reuse of a shared {@code ObjectMapper} could work.
*/
final class McpJson {
private static final ObjectMapper MAPPER = new ObjectMapper();
private McpJson() {}
static JsonNode parse(byte[] body) throws IOException {
return MAPPER.readTree(body);
}
static JsonGenerator generator(OutputStream out) throws IOException {
return MAPPER.getFactory().createGenerator(out, JsonEncoding.UTF8);
}
/** Builds a small JSON document in one shot; used only for boot-time precompilation. */
static byte[] build(JsonWriter writer) {
ByteArrayOutputStream buf = new ByteArrayOutputStream(256);
try (JsonGenerator gen = generator(buf)) {
writer.write(gen);
} catch (IOException e) {
throw new IllegalStateException("Failed to build MCP JSON fragment", e);
}
return buf.toByteArray();
}
static String buildString(JsonWriter writer) {
return new String(build(writer), StandardCharsets.UTF_8);
}
@FunctionalInterface
interface JsonWriter {
void write(JsonGenerator gen) throws IOException;
}
}
@@ -0,0 +1,180 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.ext.oidc.Authenticated;
import dev.relism.flash.ext.oidc.ClaimsHolder;
import dev.relism.flash.ext.oidc.OidcMiddleware;
import dev.relism.flash.ext.oidc.OidcUser;
import dev.relism.flash.ext.oidc.RolesAllowed;
import dev.relism.flash.ext.oidc.ScopesAllowed;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.models.Request;
import dev.relism.flash.routing.Middleware;
import lombok.extern.slf4j.Slf4j;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.function.Supplier;
/**
* Lazy, isolated bridge to {@code flash-ext-oidc}.
*
* <p>References to OIDC types only ever resolve when {@link #resolve}/{@link #compileToolPolicy}
* are actually invoked — never at {@link McpExtension} class-load time — because they live in
* this separate nested class. The caller wraps the invocation in {@code catch
* (NoClassDefFoundError)}, exactly like {@code OidcExtension}'s own lazy bridge to {@code
* flash-ext-openapi}. This is what lets {@code flash-ext-mcp} run standalone (MCP-only, no
* OAuth2) when {@code flash-ext-oidc} is not even on the classpath. {@link Resolved}/{@link
* McpAuthPolicy} carry only oidc-free types back out ({@link Middleware}, {@link String}, a
* {@link Function}, a {@link Supplier}) so no other class in this package ever has to reference
* an OIDC type.
*
* <p>Zero-config by design: when {@code flash-ext-oidc} is installed, everything an MCP OAuth2
* resource server needs — issuer, canonical resource identifier, RFC 8707 audience binding, and
* a spec-compliant {@code WWW-Authenticate} challenge (RFC 9728 §5.1) — is derived straight from
* the installed {@link OidcMiddleware}, with no additional {@link McpConfig} calls.
* {@link McpConfig#resourceIdentifier(String)}/{@link McpConfig#authorizationServerIssuer(String)}
* remain as explicit overrides for the rare case where that guess is wrong.
*/
@Slf4j
final class McpOidcIntegration {
private static final String[] NO_VALUES = new String[0];
private McpOidcIntegration() {}
/** Everything {@link McpExtension} needs once oidc security is resolved. */
record Resolved(Middleware security, String issuer, String rolesClaimPath,
Function<Request, String> resourceIdentifier) {}
/** Returns the resolved security bundle, or {@code null} if oidc is not installed. */
static Resolved resolve(FlashContext ctx, McpConfig config) {
Optional<OidcMiddleware> oidc = ctx.find(OidcMiddleware.class);
if (oidc.isEmpty()) return null;
OidcMiddleware oidcMw = oidc.get();
String resourceMetadataPath = "/.well-known/oauth-protected-resource" + config.rootPath();
String issuer = config.authorizationServerIssuer() != null
? config.authorizationServerIssuer() : oidcMw.issuer();
Function<Request, String> resourceId = req -> config.resourceIdentifier() != null
? config.resourceIdentifier()
: OidcMiddleware.selfOrigin(req, oidcMw.selfScheme()) + config.rootPath();
Middleware protect = oidcMw.protect(resourceMetadataPath);
Middleware secured = Middleware.of(protect, audienceGuard(resourceId));
return new Resolved(secured, issuer, oidcMw.rolesClaimPath(), resourceId);
}
/**
* RFC 8707 audience binding, unconditionally enforced once oidc is protecting the MCP
* route — no longer opt-in behind an explicit {@code resourceIdentifier(...)} call.
*/
private static Middleware audienceGuard(Function<Request, String> resourceIdentifier) {
return next -> (req, res) -> {
Map<String, Object> claims = ClaimsHolder.get();
String expected = resourceIdentifier.apply(req);
if (claims != null && !audienceMatches(claims.get("aud"), expected)) {
log.warn("[flash-ext-mcp] Rejecting token (RFC 8707): aud={} does not include expected " +
"resource identifier \"{}\" — the authorization server must include this exact " +
"value in the access token's aud claim (e.g. an Audience protocol mapper in " +
"Keycloak) for this MCP server to accept it.", claims.get("aud"), expected);
throw HttpException.forbidden();
}
return next.handle(req, res);
};
}
private static boolean audienceMatches(Object aud, String expected) {
if (aud instanceof String s) return s.equals(expected);
if (aud instanceof Iterable<?> it) {
for (Object o : it) if (expected.equals(String.valueOf(o))) return true;
}
return false;
}
/**
* Compiles {@code @RolesAllowed}/{@code @ScopesAllowed} on a tool class into a {@link
* McpAuthPolicy}, or returns {@code null} if the tool carries none of the three OIDC
* annotations. Called once per tool at boot ({@link McpRegistry#scan}), never on the
* request hot path — the {@link Supplier} it returns is what runs per {@code tools/call},
* closing over the already-normalized role/scope arrays so the hot path itself allocates
* nothing beyond what {@link OidcUser#hasRole}/{@link OidcUser#hasScope} already do.
*
* <p>Fails fast at boot, not silently at request time, for the two ways this can be
* misconfigured: the annotation present without OAuth2 actually protecting this MCP server
* ({@code oidcActive == false}), and {@code @Authenticated} — which has no per-tool meaning
* here (see below) — used at all.
*/
static McpAuthPolicy compileToolPolicy(Class<? extends McpTool> toolClass, boolean oidcActive,
String rolesClaimPath) {
Authenticated auth = toolClass.getAnnotation(Authenticated.class);
RolesAllowed roles = toolClass.getAnnotation(RolesAllowed.class);
ScopesAllowed scopes = toolClass.getAnnotation(ScopesAllowed.class);
if (auth == null && roles == null && scopes == null) return null;
if (!oidcActive) {
throw new IllegalStateException(
"MCP tool \"" + toolClass.getSimpleName() + "\" declares @Authenticated/@RolesAllowed/" +
"@ScopesAllowed, but this MCP server has no active OAuth2 protection — flash-ext-oidc " +
"is not installed for it, or McpSecurity is NONE. These annotations require " +
"McpSecurity.AUTO/REQUIRED with an OidcExtension installed; install one, or remove the " +
"annotation from " + toolClass.getSimpleName() + ".");
}
if (auth != null) {
throw new IllegalStateException(
"MCP tool \"" + toolClass.getSimpleName() + "\" is annotated @Authenticated, which has " +
"no effect on an McpTool: the whole MCP endpoint is already all-or-nothing " +
"authenticated once oidc is active (McpSecurity.AUTO/REQUIRED) — unlike a RequestHandler " +
"route, there is no per-tool public/authenticated split to opt into. Remove it, or use " +
"@RolesAllowed/@ScopesAllowed to narrow further.");
}
String[] requiredRoles = roles != null ? normalizeRequired("RolesAllowed", roles.value()) : NO_VALUES;
String[] requiredScopes = scopes != null ? normalizeRequired("ScopesAllowed", scopes.value()) : NO_VALUES;
ScopesAllowed.Match scopeMatch = scopes != null ? scopes.match() : ScopesAllowed.Match.ALL;
Supplier<String> check = () -> {
OidcUser user = ClaimsHolder.user();
if (user == null) return "not authenticated";
if (requiredRoles.length > 0 && !hasAnyRole(user, rolesClaimPath, requiredRoles))
return "missing required role (any of: " + String.join(", ", requiredRoles) + ")";
if (requiredScopes.length > 0 && !hasScopes(user, requiredScopes, scopeMatch))
return "missing required scope (" + scopeMatch + " of: " + String.join(", ", requiredScopes) + ")";
return null;
};
return new McpAuthPolicy(check);
}
private static boolean hasAnyRole(OidcUser user, String claimPath, String[] roles) {
for (String role : roles) if (user.hasRole(claimPath, role)) return true;
return false;
}
private static boolean hasScopes(OidcUser user, String[] scopes, ScopesAllowed.Match match) {
if (match == ScopesAllowed.Match.ALL) {
for (String scope : scopes) if (!user.hasScope(scope)) return false;
return true;
}
for (String scope : scopes) if (user.hasScope(scope)) return true;
return false;
}
/** Mirrors {@code OidcAuthPolicy}'s own normalization — trim, dedupe, require non-blank. */
private static String[] normalizeRequired(String annotationName, String[] values) {
if (values == null || values.length == 0)
throw new IllegalStateException("@" + annotationName + " requires at least one value");
LinkedHashSet<String> normalized = new LinkedHashSet<>(values.length);
for (String raw : values) {
if (raw == null) continue;
String trimmed = raw.trim();
if (!trimmed.isEmpty()) normalized.add(trimmed);
}
if (normalized.isEmpty())
throw new IllegalStateException("@" + annotationName + " requires at least one non-empty value");
return normalized.toArray(String[]::new);
}
}
@@ -0,0 +1,86 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.exceptions.InitializationException;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.List;
import dev.relism.flash.extension.PackageScanner;
/**
* Minimal classpath scanner used by {@link McpConfig#toolsPackage(String)}. Finds
* {@link McpTool}/{@link McpResource}/{@link McpPrompt} subclasses carrying the matching
* annotation ({@link Tool @Tool}, {@link Resource @Resource}, {@link Prompt @Prompt}).
* Supports both exploded directories (development) and fat JARs (deployment).
*
* <p>Deliberately not shared with {@code dev.relism.flash.extension.PackageScanner}: that
* scanner is package-private and hardcoded to {@code RequestHandler}/{@code WebSocketEndpoint}.
* The directory/JAR walking logic below intentionally mirrors it — same fail-fast contract,
* same anonymous-class filtering.
*
* <p><b>Fail-fast:</b> if the package does not exist, contains no matching class, or a class
* cannot be loaded, an {@link InitializationException} is thrown immediately at boot.
*/
final class McpPackageScanner {
private McpPackageScanner() {}
record ScanResult(List<Class<? extends McpTool>> tools,
List<Class<? extends McpResource>> resources,
List<Class<? extends McpPrompt>> prompts) {}
static ScanResult scan(String packageName) {
if (packageName == null || packageName.isBlank())
throw new InitializationException("McpConfig.toolsPackage() called with null or blank package name");
List<Class<? extends McpTool>> tools = new ArrayList<>();
List<Class<? extends McpResource>> resources = new ArrayList<>();
List<Class<? extends McpPrompt>> prompts = new ArrayList<>();
List<String> errors = new ArrayList<>();
for (Class<?> cls : PackageScanner.discover(packageName)) tryLoad(cls, tools, resources, prompts, errors);
if (!errors.isEmpty())
throw new InitializationException(
"McpConfig.toolsPackage(\"" + packageName + "\") — failed to load " + errors.size() + " class(es):\n • " +
String.join("\n • ", errors));
if (tools.isEmpty() && resources.isEmpty() && prompts.isEmpty())
throw new InitializationException(
"McpConfig.toolsPackage(\"" + packageName + "\") — no @Tool/@Resource/@Prompt classes found. " +
"Ensure classes extend McpTool/McpResource/McpPrompt, carry the matching annotation, " +
"are not abstract, and have a public no-arg constructor.");
return new ScanResult(List.copyOf(tools), List.copyOf(resources), List.copyOf(prompts));
}
@SuppressWarnings("unchecked")
private static void tryLoad(Class<?> cls,
List<Class<? extends McpTool>> tools,
List<Class<? extends McpResource>> resources,
List<Class<? extends McpPrompt>> prompts,
List<String> errors) {
try {
if (Modifier.isAbstract(cls.getModifiers())) return;
if (McpTool.class.isAssignableFrom(cls) && cls.isAnnotationPresent(Tool.class)) {
assertNoArgConstructor(cls, errors);
tools.add((Class<? extends McpTool>) cls);
return;
}
if (McpResource.class.isAssignableFrom(cls) && cls.isAnnotationPresent(Resource.class)) {
assertNoArgConstructor(cls, errors);
resources.add((Class<? extends McpResource>) cls);
return;
}
if (McpPrompt.class.isAssignableFrom(cls) && cls.isAnnotationPresent(Prompt.class)) {
assertNoArgConstructor(cls, errors);
prompts.add((Class<? extends McpPrompt>) cls);
}
} catch (LinkageError e) { errors.add(cls.getName() + " — linkage error: " + e.getMessage()); }
}
private static void assertNoArgConstructor(Class<?> cls, List<String> errors) {
try { cls.getDeclaredConstructor(); }
catch (NoSuchMethodException e) { errors.add(cls.getName() + " — missing public no-arg constructor"); }
}
}
@@ -0,0 +1,60 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.extension.FlashContext;
import java.util.Optional;
/**
* Base class for a single MCP prompt template — one class per prompt, mirroring {@link McpTool}.
* Declare metadata with {@link Prompt @Prompt}, cache services in {@link #onInit()}, implement
* {@link #render(PromptArguments)} for the hot path.
*
* <pre>{@code
* @Prompt(name = "summarize", args = @PromptArg(name = "text", required = true))
* public class SummarizePrompt extends McpPrompt {
* @Override public PromptMessage render(PromptArguments args) {
* return PromptMessage.withUserRole(new TextContent("Summarize: " + args.getString("text")));
* }
* }
* }</pre>
*/
public abstract class McpPrompt {
private FlashContext ctx;
/**
* Called once by the framework after instantiation, before the first {@code prompts/get}.
* <b>Infrastructure method</b> — do not call from user code.
*/
public final void bind(FlashContext ctx) {
this.ctx = ctx;
onInit();
}
protected void onInit() {}
protected <T> T require(Class<T> type) {
checkBound();
return ctx.require(type);
}
protected <T> Optional<T> find(Class<T> type) {
checkBound();
return ctx.find(type);
}
protected <T> Optional<T> optional(Class<T> type) {
checkBound();
return ctx.optional(type);
}
private void checkBound() {
if (ctx == null)
throw new IllegalStateException(
getClass().getSimpleName() + " has not been bound to a FlashContext — " +
"register via McpConfig.toolsPackage(), not by instantiating directly");
}
/** Invoked on every matching {@code prompts/get} request (hot path). */
public abstract PromptMessage render(PromptArguments args) throws Exception;
}
@@ -0,0 +1,24 @@
package dev.relism.flash.ext.mcp;
/** Internal signal carrying a JSON-RPC error code, caught by {@link McpDispatcher} to build the error response. */
final class McpProtocolException extends RuntimeException {
final int code;
private McpProtocolException(int code, String message) {
super(message);
this.code = code;
}
static McpProtocolException invalidRequest(String message) {
return new McpProtocolException(JsonRpcErrorCode.INVALID_REQUEST, message);
}
static McpProtocolException methodNotFound(String method) {
return new McpProtocolException(JsonRpcErrorCode.METHOD_NOT_FOUND, "Method not found: " + method);
}
static McpProtocolException invalidParams(String message) {
return new McpProtocolException(JsonRpcErrorCode.INVALID_PARAMS, message);
}
}
@@ -0,0 +1,204 @@
package dev.relism.flash.ext.mcp;
import com.fasterxml.jackson.core.JsonGenerator;
import dev.relism.flash.exceptions.InitializationException;
import dev.relism.flash.extension.FlashContext;
import java.io.IOException;
import java.lang.reflect.Constructor;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Boot-time-built registry of tools/resources/prompts for one MCP server.
*
* <p>Everything static about the catalog — the {@code tools/list}/{@code resources/list}/
* {@code prompts/list} JSON payloads — is assembled exactly once here via
* {@link McpJson#buildString}, then spliced verbatim into responses at request time
* ({@link McpDispatcher}) with {@link JsonGenerator#writeRawValue(String)}: no
* re-serialization, no reflection, no databinding, and no per-request byte[]→String
* conversion on the hot path — the string is already sitting in memory, built once at boot.
*/
final class McpRegistry {
private static final String EMPTY_ARRAY = "[]";
/** {@code policy} is {@code null} unless the tool carries @RolesAllowed/@ScopesAllowed. */
record RegisteredTool(String name, McpTool instance, McpAuthPolicy policy) {}
record RegisteredResource(String uri, McpResource instance) {}
record RegisteredPrompt(String name, McpPrompt instance) {}
private final Map<String, RegisteredTool> tools = new LinkedHashMap<>();
private final Map<String, RegisteredResource> resources = new LinkedHashMap<>();
private final Map<String, RegisteredPrompt> prompts = new LinkedHashMap<>();
private String toolsListJson = EMPTY_ARRAY;
private String resourcesListJson = EMPTY_ARRAY;
private String promptsListJson = EMPTY_ARRAY;
private McpRegistry() {}
/**
* @param oidcActive whether this MCP server's route is actually OAuth2-protected right
* now (see {@link McpOidcIntegration#resolve}) — gates whether
* {@code @RolesAllowed}/{@code @ScopesAllowed} on a tool are honored or
* rejected at boot as a misconfiguration; see
* {@link McpOidcIntegration#compileToolPolicy}.
* @param rolesClaimPath claim path resolved from the installed OIDC extension.
*/
static McpRegistry scan(String packageName, FlashContext ctx, boolean oidcActive, String rolesClaimPath) {
McpPackageScanner.ScanResult found = McpPackageScanner.scan(packageName);
McpRegistry registry = new McpRegistry();
for (Class<? extends McpTool> cls : found.tools()) {
Tool ann = cls.getAnnotation(Tool.class);
McpTool instance = instantiate(cls);
instance.bind(ctx);
McpAuthPolicy policy = compileToolPolicy(cls, oidcActive, rolesClaimPath);
if (registry.tools.putIfAbsent(ann.name(), new RegisteredTool(ann.name(), instance, policy)) != null)
throw new InitializationException("Duplicate MCP tool name: \"" + ann.name() + "\"");
}
for (Class<? extends McpResource> cls : found.resources()) {
Resource ann = cls.getAnnotation(Resource.class);
McpResource instance = instantiate(cls);
instance.bind(ctx);
if (registry.resources.putIfAbsent(ann.uri(), new RegisteredResource(ann.uri(), instance)) != null)
throw new InitializationException("Duplicate MCP resource uri: \"" + ann.uri() + "\"");
}
for (Class<? extends McpPrompt> cls : found.prompts()) {
Prompt ann = cls.getAnnotation(Prompt.class);
McpPrompt instance = instantiate(cls);
instance.bind(ctx);
if (registry.prompts.putIfAbsent(ann.name(), new RegisteredPrompt(ann.name(), instance)) != null)
throw new InitializationException("Duplicate MCP prompt name: \"" + ann.name() + "\"");
}
if (!found.tools().isEmpty())
registry.toolsListJson = McpJson.buildString(gen -> writeToolsArray(gen, found.tools()));
if (!found.resources().isEmpty())
registry.resourcesListJson = McpJson.buildString(gen -> writeResourcesArray(gen, found.resources()));
if (!found.prompts().isEmpty())
registry.promptsListJson = McpJson.buildString(gen -> writePromptsArray(gen, found.prompts()));
return registry;
}
boolean hasTools() { return !tools.isEmpty(); }
boolean hasResources() { return !resources.isEmpty(); }
boolean hasPrompts() { return !prompts.isEmpty(); }
String toolsListJson() { return toolsListJson; }
String resourcesListJson() { return resourcesListJson; }
String promptsListJson() { return promptsListJson; }
RegisteredTool tool(String name) { return tools.get(name); }
RegisteredResource resource(String uri) { return resources.get(uri); }
RegisteredPrompt prompt(String name) { return prompts.get(name); }
// ── Boot-time JSON Schema / descriptor precompilation ───────────────────────
private static void writeToolsArray(JsonGenerator gen, List<Class<? extends McpTool>> classes) throws IOException {
gen.writeStartArray();
for (Class<? extends McpTool> cls : classes) writeTool(gen, cls.getAnnotation(Tool.class));
gen.writeEndArray();
}
private static void writeTool(JsonGenerator gen, Tool ann) throws IOException {
gen.writeStartObject();
gen.writeStringField("name", ann.name());
if (!ann.title().isBlank()) gen.writeStringField("title", ann.title());
if (!ann.description().isBlank()) gen.writeStringField("description", ann.description());
gen.writeFieldName("inputSchema");
writeInputSchema(gen, ann.args());
gen.writeEndObject();
}
private static void writeInputSchema(JsonGenerator gen, ToolArg[] args) throws IOException {
gen.writeStartObject();
gen.writeStringField("type", "object");
gen.writeObjectFieldStart("properties");
for (ToolArg arg : args) {
gen.writeObjectFieldStart(arg.name());
gen.writeStringField("type", arg.type().jsonSchemaType());
if (!arg.description().isBlank()) gen.writeStringField("description", arg.description());
gen.writeEndObject();
}
gen.writeEndObject();
if (hasRequired(args)) {
gen.writeArrayFieldStart("required");
for (ToolArg arg : args) if (arg.required()) gen.writeString(arg.name());
gen.writeEndArray();
}
gen.writeEndObject();
}
private static boolean hasRequired(ToolArg[] args) {
for (ToolArg arg : args) if (arg.required()) return true;
return false;
}
private static void writeResourcesArray(JsonGenerator gen, List<Class<? extends McpResource>> classes) throws IOException {
gen.writeStartArray();
for (Class<? extends McpResource> cls : classes) {
Resource ann = cls.getAnnotation(Resource.class);
gen.writeStartObject();
gen.writeStringField("uri", ann.uri());
gen.writeStringField("name", !ann.name().isBlank() ? ann.name() : ann.uri());
if (!ann.description().isBlank()) gen.writeStringField("description", ann.description());
gen.writeStringField("mimeType", ann.mimeType());
gen.writeEndObject();
}
gen.writeEndArray();
}
private static void writePromptsArray(JsonGenerator gen, List<Class<? extends McpPrompt>> classes) throws IOException {
gen.writeStartArray();
for (Class<? extends McpPrompt> cls : classes) {
Prompt ann = cls.getAnnotation(Prompt.class);
gen.writeStartObject();
gen.writeStringField("name", ann.name());
if (!ann.description().isBlank()) gen.writeStringField("description", ann.description());
gen.writeArrayFieldStart("arguments");
for (PromptArg arg : ann.args()) {
gen.writeStartObject();
gen.writeStringField("name", arg.name());
if (!arg.description().isBlank()) gen.writeStringField("description", arg.description());
gen.writeBooleanField("required", arg.required());
gen.writeEndObject();
}
gen.writeEndArray();
gen.writeEndObject();
}
gen.writeEndArray();
}
/**
* Isolated the same way {@link McpOidcIntegration#resolve} is — {@code
* NoClassDefFoundError} here means {@code flash-ext-oidc} genuinely isn't on the runtime
* classpath, in which case a tool couldn't have been compiled against
* {@code @RolesAllowed}/{@code @ScopesAllowed} in the first place, so there's nothing to
* check (and nothing lost: {@code oidcActive} is only ever {@code true} once {@link
* McpOidcIntegration#resolve} has already succeeded once this boot, which proves those
* types resolve fine).
*/
private static McpAuthPolicy compileToolPolicy(Class<? extends McpTool> cls, boolean oidcActive,
String rolesClaimPath) {
try {
return McpOidcIntegration.compileToolPolicy(cls, oidcActive, rolesClaimPath);
} catch (NoClassDefFoundError e) {
return null;
}
}
private static <T> T instantiate(Class<T> cls) {
try {
Constructor<T> ctor = cls.getDeclaredConstructor();
return ctor.newInstance();
} catch (Exception e) {
throw new InitializationException(
"Failed to instantiate " + cls.getName() +
" — ensure it has a public no-arg constructor", e);
}
}
}
@@ -0,0 +1,66 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.extension.FlashContext;
import java.util.Optional;
/**
* Base class for a single MCP resource — one class per resource, mirroring {@link McpTool}.
* Declare metadata with {@link Resource @Resource}, cache services in {@link #onInit()},
* implement {@link #read()} for the hot path.
*
* <pre>{@code
* @Resource(uri = "config://app-settings", description = "Application settings", mimeType = "application/json")
* public class AppSettingsResource extends McpResource {
* @Override public ResourceContents read() {
* return TextResourceContents.of(uri(), "application/json", settingsJson());
* }
* }
* }</pre>
*/
public abstract class McpResource {
private FlashContext ctx;
private String uri;
/**
* Called once by the framework after instantiation, before the first {@code resources/read}.
* <b>Infrastructure method</b> — do not call from user code.
*/
public final void bind(FlashContext ctx) {
this.ctx = ctx;
Resource ann = getClass().getAnnotation(Resource.class);
this.uri = ann != null ? ann.uri() : null;
onInit();
}
protected void onInit() {}
protected <T> T require(Class<T> type) {
checkBound();
return ctx.require(type);
}
protected <T> Optional<T> find(Class<T> type) {
checkBound();
return ctx.find(type);
}
protected <T> Optional<T> optional(Class<T> type) {
checkBound();
return ctx.optional(type);
}
/** URI declared via {@link Resource @Resource}, cached at bind time. */
protected final String uri() { return uri; }
private void checkBound() {
if (ctx == null)
throw new IllegalStateException(
getClass().getSimpleName() + " has not been bound to a FlashContext — " +
"register via McpConfig.toolsPackage(), not by instantiating directly");
}
/** Invoked on every matching {@code resources/read} request (hot path). */
public abstract ResourceContents read() throws Exception;
}
@@ -0,0 +1,26 @@
package dev.relism.flash.ext.mcp;
import java.util.List;
/** RFC 9728 OAuth 2.0 Protected Resource Metadata document, built once at boot. */
final class McpResourceMetadata {
private McpResourceMetadata() {}
/** {@code scopesSupported} is optional per RFC 9728 — omitted from the document if empty. */
static String build(String resourceIdentifier, String authorizationServerIssuer, List<String> scopesSupported) {
return McpJson.buildString(gen -> {
gen.writeStartObject();
gen.writeStringField("resource", resourceIdentifier);
gen.writeArrayFieldStart("authorization_servers");
gen.writeString(authorizationServerIssuer);
gen.writeEndArray();
if (!scopesSupported.isEmpty()) {
gen.writeArrayFieldStart("scopes_supported");
for (String scope : scopesSupported) gen.writeString(scope);
gen.writeEndArray();
}
gen.writeEndObject();
});
}
}
@@ -0,0 +1,17 @@
package dev.relism.flash.ext.mcp;
/**
* OAuth2 requirement policy for the MCP endpoint, resolved against whether
* {@code flash-ext-oidc} is installed ({@code ctx.find(OidcMiddleware.class)}).
*/
public enum McpSecurity {
/** Fail fast at boot if {@code flash-ext-oidc} is not installed — never expose an unprotected MCP endpoint. */
REQUIRED,
/** Protect the endpoint if {@code flash-ext-oidc} is installed; otherwise run unprotected and log a warning. */
AUTO,
/** Never protect the endpoint, even if {@code flash-ext-oidc} is installed elsewhere in the app. */
NONE
}
@@ -0,0 +1,74 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.extension.FlashContext;
import java.util.Optional;
/**
* Base class for a single MCP tool — one class per tool, mirroring
* {@link dev.relism.flash.models.RequestHandler}: declare metadata with {@link Tool @Tool},
* cache services in {@link #onInit()}, implement {@link #call(ToolArguments)} for the hot path.
*
* <p>Discovered via {@link McpConfig#toolsPackage(String)} — instantiated with its public
* no-arg constructor and bound once at boot, before the first {@code tools/call} request.
*
* <pre>{@code
* @Tool(name = "get_weather", description = "Get current weather for a city",
* args = @ToolArg(name = "city", required = true))
* public class GetWeatherTool extends McpTool {
* private WeatherService weatherService;
*
* @Override protected void onInit() {
* weatherService = require(WeatherService.class);
* }
*
* @Override public ToolResponse call(ToolArguments args) {
* return ToolResponse.success(new TextContent(weatherService.fetch(args.getString("city"))));
* }
* }
* }</pre>
*/
public abstract class McpTool {
private FlashContext ctx;
/**
* Called once by the framework after instantiation, before the first {@code tools/call}.
* <b>Infrastructure method</b> — do not call from user code.
*/
public final void bind(FlashContext ctx) {
this.ctx = ctx;
onInit();
}
/** Override to cache services at boot time. See {@link #require}/{@link #find}. */
protected void onInit() {}
protected <T> T require(Class<T> type) {
checkBound();
return ctx.require(type);
}
protected <T> Optional<T> find(Class<T> type) {
checkBound();
return ctx.find(type);
}
protected <T> Optional<T> optional(Class<T> type) {
checkBound();
return ctx.optional(type);
}
private void checkBound() {
if (ctx == null)
throw new IllegalStateException(
getClass().getSimpleName() + " has not been bound to a FlashContext — " +
"register via McpConfig.toolsPackage(), not by instantiating directly");
}
/**
* Invoked on every matching {@code tools/call} request (hot path). {@code args} is a thin
* accessor over the already-parsed JSON arguments — no databinding.
*/
public abstract ToolResponse call(ToolArguments args) throws Exception;
}
@@ -0,0 +1,62 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.routing.Middleware;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
/** Transport-level guards for the MCP Streamable HTTP endpoint. */
@Slf4j
final class McpTransportGuards {
private McpTransportGuards() {}
/**
* Validates the {@code Origin} header per the Streamable HTTP transport's DNS-rebinding
* protection requirement. Non-browser clients that omit {@code Origin} entirely are always
* allowed through — only a <em>present but disallowed</em> value is rejected.
*
* <p>If {@code allowedOrigins} is empty, validation is skipped and a boot-time warning is
* logged — same graceful-degradation shape as {@link McpSecurity#AUTO}.
*/
static Middleware originGuard(List<String> allowedOrigins) {
if (allowedOrigins.isEmpty()) {
log.warn("[flash-ext-mcp] No allowedOrigins configured — Origin header validation " +
"(DNS-rebinding protection) is DISABLED. Configure McpConfig.allowedOrigins(...) for production use.");
return next -> next::handle;
}
return next -> (req, res) -> {
String origin = req.header("Origin");
if (origin != null && !allowedOrigins.contains(origin)) {
throw HttpException.forbidden();
}
return next.handle(req, res);
};
}
/**
* Safety net around the whole MCP route: translates {@link HttpException} (thrown by
* {@link #originGuard} or by {@code flash-ext-oidc}'s middleware) into a proper HTTP status
* directly, instead of relying on the app's global exception handler — which defaults to a
* generic 500 for every exception type unless the app owner overrides it (see
* {@code AbstractRouter}'s default {@code exceptionHandler}). Keeps the MCP endpoint
* correct out of the box regardless of what the rest of the app configures.
*/
static Middleware httpExceptionGuard() {
return next -> (req, res) -> {
try {
return next.handle(req, res);
} catch (HttpException e) {
String body = McpJson.buildString(gen -> {
gen.writeStartObject();
gen.writeStringField("error", e.getMessage());
gen.writeEndObject();
});
res.status(e.status()).type(ContentType.JSON).body(body);
return null;
}
};
}
}
@@ -0,0 +1,32 @@
package dev.relism.flash.ext.mcp;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Marks a {@link McpPrompt} subclass as an MCP prompt template and declares its metadata,
* discovered by {@link McpConfig#toolsPackage(String)}.
*
* <pre>{@code
* @Prompt(name = "summarize", args = @PromptArg(name = "text", required = true))
* public class SummarizePrompt extends McpPrompt {
* @Override
* public PromptMessage render(PromptArguments args) {
* return PromptMessage.withUserRole(new TextContent("Summarize: " + args.getString("text")));
* }
* }
* }</pre>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Prompt {
/** Unique prompt name (used by clients in {@code prompts/get}). */
String name();
String description() default "";
/** Arguments accepted by the prompt template — always strings per the MCP specification. */
PromptArg[] args() default {};
}
@@ -0,0 +1,11 @@
package dev.relism.flash.ext.mcp;
/**
* Declares one argument of a {@link Prompt}. Per the MCP specification, prompt arguments are
* always strings. Used inside {@link Prompt#args()}.
*/
public @interface PromptArg {
String name();
String description() default "";
boolean required() default false;
}
@@ -0,0 +1,21 @@
package dev.relism.flash.ext.mcp;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.MissingNode;
/**
* Typed accessor over a {@code prompts/get} request's {@code arguments} object.
* Per the MCP specification, prompt arguments are always strings.
*/
public final class PromptArguments {
private final JsonNode node;
PromptArguments(JsonNode node) {
this.node = node != null ? node : MissingNode.getInstance();
}
public boolean has(String name) { return node.has(name); }
public String getString(String name) { return node.path(name).asText(null); }
public String getString(String name, String defaultValue) { return node.path(name).asText(defaultValue); }
}
@@ -0,0 +1,15 @@
package dev.relism.flash.ext.mcp;
/** A single message returned by a {@link McpPrompt}. */
public record PromptMessage(Role role, Content content) {
public enum Role { USER, ASSISTANT }
public static PromptMessage withUserRole(Content content) {
return new PromptMessage(Role.USER, content);
}
public static PromptMessage withAssistantRole(Content content) {
return new PromptMessage(Role.ASSISTANT, content);
}
}
@@ -0,0 +1,31 @@
package dev.relism.flash.ext.mcp;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Marks a {@link McpResource} subclass as an MCP resource and declares its metadata, discovered
* by {@link McpConfig#toolsPackage(String)}.
*
* <pre>{@code
* @Resource(uri = "config://app-settings", description = "Application settings")
* public class AppSettingsResource extends McpResource {
* @Override
* public ResourceContents read() {
* return TextResourceContents.of(uri(), "application/json", settingsJson());
* }
* }
* }</pre>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Resource {
/** Unique resource URI (used by clients in {@code resources/read}). */
String uri();
String name() default "";
String description() default "";
String mimeType() default "text/plain";
}
@@ -0,0 +1,8 @@
package dev.relism.flash.ext.mcp;
/**
* MCP resource contents. {@code sealed} to the variants this extension currently writes on
* the wire — extend the permits clause (and {@link McpContentWriter}) to add
* {@code BlobResourceContents} in a future revision.
*/
public sealed interface ResourceContents permits TextResourceContents {}
@@ -0,0 +1,4 @@
package dev.relism.flash.ext.mcp;
/** Plain-text content block ({@code type: "text"} on the wire). */
public record TextContent(String text) implements Content {}
@@ -0,0 +1,9 @@
package dev.relism.flash.ext.mcp;
/** Text resource contents returned from {@code resources/read}. */
public record TextResourceContents(String uri, String mimeType, String text) implements ResourceContents {
public static TextResourceContents of(String uri, String mimeType, String text) {
return new TextResourceContents(uri, mimeType, text);
}
}
@@ -0,0 +1,40 @@
package dev.relism.flash.ext.mcp;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Marks a {@link McpTool} subclass as an MCP tool and declares its metadata, discovered by
* {@link McpConfig#toolsPackage(String)}.
*
* <pre>{@code
* @Tool(
* name = "get_weather",
* description = "Get current weather for a city",
* args = @ToolArg(name = "city", description = "City name", required = true)
* )
* public class GetWeatherTool extends McpTool {
* @Override
* public ToolResponse call(ToolArguments args) {
* return ToolResponse.success(new TextContent(weatherService.fetch(args.getString("city"))));
* }
* }
* }</pre>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Tool {
/** Unique tool name (used by clients in {@code tools/call}). */
String name();
/** Human/model-readable description of what the tool does. */
String description() default "";
/** Optional display title, distinct from {@link #name()}. */
String title() default "";
/** Input arguments — assembled into the tool's JSON Schema {@code inputSchema} once at boot. */
ToolArg[] args() default {};
}
@@ -0,0 +1,12 @@
package dev.relism.flash.ext.mcp;
/**
* Declares one input argument of a {@link Tool}. Used inside {@link Tool#args()} — the whole
* input JSON Schema is assembled once at scan time from these, never at call time.
*/
public @interface ToolArg {
String name();
ToolArgType type() default ToolArgType.STRING;
String description() default "";
boolean required() default false;
}
@@ -0,0 +1,11 @@
package dev.relism.flash.ext.mcp;
/** JSON Schema primitive types available for {@link ToolArg#type()}. */
public enum ToolArgType {
STRING, INTEGER, NUMBER, BOOLEAN, OBJECT, ARRAY;
/** JSON Schema {@code "type"} keyword value. */
String jsonSchemaType() {
return name().toLowerCase(java.util.Locale.ROOT);
}
}
@@ -0,0 +1,48 @@
package dev.relism.flash.ext.mcp;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.MissingNode;
/**
* Typed accessor over a {@code tools/call} request's {@code arguments} object.
*
* <p>Wraps the already-parsed {@link JsonNode} directly — no POJO databinding, no reflection,
* no intermediate copy. Same spirit as {@code QueryParams}/{@code PathParams} in Flash core:
* a thin typed view over data that already exists in memory.
*
* <pre>{@code
* public ToolResponse call(ToolArguments args) {
* String city = args.getString("city");
* int days = args.getInt("days", 1);
* ...
* }
* }</pre>
*/
public final class ToolArguments {
private final JsonNode node;
ToolArguments(JsonNode node) {
this.node = node != null ? node : MissingNode.getInstance();
}
public boolean has(String name) { return node.has(name); }
public String getString(String name) { return node.path(name).asText(null); }
public String getString(String name, String defaultValue) { return node.path(name).asText(defaultValue); }
public int getInt(String name) { return node.path(name).asInt(); }
public int getInt(String name, int defaultValue) { return node.path(name).asInt(defaultValue); }
public long getLong(String name) { return node.path(name).asLong(); }
public long getLong(String name, long defaultValue) { return node.path(name).asLong(defaultValue); }
public double getDouble(String name) { return node.path(name).asDouble(); }
public double getDouble(String name, double defaultValue) { return node.path(name).asDouble(defaultValue); }
public boolean getBoolean(String name) { return node.path(name).asBoolean(); }
public boolean getBoolean(String name, boolean defaultValue) { return node.path(name).asBoolean(defaultValue); }
/** Escape hatch for nested/array arguments not covered by the typed accessors above. */
public JsonNode raw(String name) { return node.path(name); }
}
@@ -0,0 +1,32 @@
package dev.relism.flash.ext.mcp;
import java.util.List;
/** Result of a {@link McpTool#call(ToolArguments)} invocation. */
public final class ToolResponse {
private final List<Content> content;
private final boolean isError;
private ToolResponse(List<Content> content, boolean isError) {
this.content = content;
this.isError = isError;
}
/** Successful tool result carrying one or more content blocks. */
public static ToolResponse success(Content... content) {
return new ToolResponse(List.of(content), false);
}
/**
* Tool-level failure — per the MCP specification this is still a normal JSON-RPC
* <em>result</em> (not a JSON-RPC error) with {@code isError: true}, so the model can see
* and react to it.
*/
public static ToolResponse error(String message) {
return new ToolResponse(List.of(new TextContent(message)), true);
}
List<Content> content() { return content; }
boolean isError() { return isError; }
}
@@ -0,0 +1,109 @@
package dev.relism.flash.ext.mcp;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.KeyUse;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import com.sun.net.httpserver.HttpServer;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Instant;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Minimal, self-contained fake OIDC provider for tests: real discovery document, real JWKS
* endpoint, real RS256-signed tokens — no network dependency beyond localhost, no mocking
* framework. Exercises {@code flash-ext-oidc}'s actual discovery + JWKS + JWT validation path.
*/
final class FakeOidcProvider implements AutoCloseable {
private final HttpServer server;
private final String issuer;
private final RSAKey rsaKey;
FakeOidcProvider() throws Exception {
KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
gen.initialize(2048);
KeyPair kp = gen.generateKeyPair();
this.rsaKey = new RSAKey.Builder((RSAPublicKey) kp.getPublic())
.privateKey((RSAPrivateKey) kp.getPrivate())
.keyUse(KeyUse.SIGNATURE)
.algorithm(JWSAlgorithm.RS256)
.keyID(UUID.randomUUID().toString())
.build();
this.server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
this.issuer = "http://127.0.0.1:" + server.getAddress().getPort();
server.createContext("/.well-known/openid-configuration", ex -> respond(ex, discoveryDocument()));
server.createContext("/jwks", ex -> respond(ex, new JWKSet(rsaKey.toPublicJWK()).toJSONObject().toString()));
server.setExecutor(null);
server.start();
}
String issuer() { return issuer; }
/** Mints a valid RS256 access token — bearer-validation only, no full authorization-code round-trip needed. */
String signToken(String subject, String audience) {
return signToken(subject, audience, null, NO_ROLES);
}
/**
* Same as {@link #signToken(String, String)}, plus a {@code scope} claim (space-delimited,
* matching {@link dev.relism.flash.ext.oidc.OidcUser#hasScope}'s default claim path) and a
* Keycloak-shaped {@code realm_access.roles} claim (matching {@code McpConfig}'s default
* {@code rolesClaimPath}) when {@code roles} is non-empty.
*/
String signToken(String subject, String audience, String scope, String... roles) {
try {
JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder()
.issuer(issuer)
.subject(subject)
.audience(audience)
.issueTime(Date.from(Instant.now()))
.expirationTime(Date.from(Instant.now().plusSeconds(300)));
if (scope != null) builder.claim("scope", scope);
if (roles.length > 0) builder.claim("realm_access", Map.of("roles", List.of(roles)));
SignedJWT jwt = new SignedJWT(
new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID()).build(), builder.build());
jwt.sign(new RSASSASigner(rsaKey));
return jwt.serialize();
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
private static final String[] NO_ROLES = new String[0];
private String discoveryDocument() {
return "{"
+ "\"issuer\":\"" + issuer + "\","
+ "\"authorization_endpoint\":\"" + issuer + "/auth\","
+ "\"token_endpoint\":\"" + issuer + "/token\","
+ "\"jwks_uri\":\"" + issuer + "/jwks\""
+ "}";
}
private static void respond(com.sun.net.httpserver.HttpExchange ex, String body) throws java.io.IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().add("Content-Type", "application/json");
ex.sendResponseHeaders(200, bytes.length);
try (OutputStream os = ex.getResponseBody()) { os.write(bytes); }
}
@Override
public void close() { server.stop(0); }
}
@@ -0,0 +1,141 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.ext.oidc.OidcConfig;
import dev.relism.flash.ext.oidc.OidcExtension;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.testing.FlashResponse;
import dev.relism.flash.testing.FlashTest;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* {@code @RolesAllowed}/{@code @ScopesAllowed} on an {@link McpTool} — see
* {@link McpOidcIntegration#compileToolPolicy}. Same real-discovery/real-JWKS/real-RS256-token
* approach as {@link McpExtensionSecurityTest}, against {@code fixtures.secured}'s tools.
*/
class McpAuthPolicyTest {
private static final String SECURED_TOOLS = "dev.relism.flash.ext.mcp.authfixtures.secured";
private static final String AUTHENTICATED_ONLY_TOOLS = "dev.relism.flash.ext.mcp.authfixtures.authenticatedonly";
private static final FakeOidcProvider provider = newProvider();
@RegisterExtension
static FlashTest secured = FlashTest.of(app -> {
app.install(new OidcExtension(OidcConfig.builder(
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
app.install(new McpExtension(McpConfig.builder("secure-server")
.toolsPackage(SECURED_TOOLS)
.security(McpSecurity.REQUIRED)
.build()));
});
/** Tokens are audience-bound to this server, so the port has to be read back after boot. */
private static String resourceId() {
return "http://127.0.0.1:" + secured.port() + "/mcp";
}
@AfterAll
static void closeProvider() {
provider.close();
}
// ── Tool policy ──────────────────────────────────────────────────────────
@Test
void rolesAllowed_deniesWithoutRole_allowsWithRole() throws Exception {
callTool("admin_only", provider.signToken("user-1", resourceId(), null))
.expectStatus(200)
.expectBodyContains("\"isError\":true")
.expectBodyContains("missing required role");
callTool("admin_only", provider.signToken("user-1", resourceId(), null, "admin"))
.expectStatus(200)
.expectBodyContains("\"isError\":false")
.expectBodyContains("ok");
}
@Test
void scopesAllowed_deniesWithoutScope_allowsWithScope() throws Exception {
callTool("write_only", provider.signToken("user-1", resourceId(), "read"))
.expectStatus(200)
.expectBodyContains("\"isError\":true")
.expectBodyContains("missing required scope");
callTool("write_only", provider.signToken("user-1", resourceId(), "read write"))
.expectStatus(200)
.expectBodyContains("\"isError\":false")
.expectBodyContains("written");
}
@Test
void unannotatedTool_unaffectedByOtherToolsPolicies() throws Exception {
callTool("open", provider.signToken("user-1", resourceId(), null))
.expectStatus(200)
.expectBodyContains("\"isError\":false")
.expectBodyContains("open");
}
private static FlashResponse callTool(String toolName, String token) {
return secured.request()
.header("Accept", "application/json")
.header("Authorization", "Bearer " + token)
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\""
+ toolName + "\"}}")
.post("/mcp");
}
// ── Boot-time rejection ──────────────────────────────────────────────────
// These assert that start() throws, so they build the app directly rather than through
// FlashTest — a harness whose job is to boot an app is the wrong tool for asserting that
// booting fails. Port 0 still removes the old free-port dance.
private FlashApp bootFailure;
@AfterEach
void releaseBootFailureListener() {
if (bootFailure != null) bootFailure.stop().join();
}
@Test
void toolAnnotated_butSecurityNone_failsAtBoot() {
bootFailure = mcpApp(SECURED_TOOLS, McpSecurity.NONE);
IllegalStateException error = assertThrows(IllegalStateException.class, bootFailure::start);
assertTrue(error.getMessage().contains("no active OAuth2 protection"), error.getMessage());
}
@Test
void bareAuthenticated_hasNoEffect_failsAtBoot() {
bootFailure = mcpApp(AUTHENTICATED_ONLY_TOOLS, McpSecurity.REQUIRED);
IllegalStateException error = assertThrows(IllegalStateException.class, bootFailure::start);
assertTrue(error.getMessage().contains("no effect"), error.getMessage());
}
private static FlashApp mcpApp(String toolsPackage, McpSecurity security) {
FlashApp app = FlashApp.create(FlashConfiguration.builder()
.port(0).host("127.0.0.1").shutdownDrainTimeoutMs(250).build());
app.install(new OidcExtension(OidcConfig.builder(
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
app.install(new McpExtension(McpConfig.builder("secure-server")
.toolsPackage(toolsPackage)
.security(security)
.build()));
return app;
}
private static FakeOidcProvider newProvider() {
try {
return new FakeOidcProvider();
} catch (Exception failure) {
throw new IllegalStateException("Could not start the fake OIDC provider", failure);
}
}
}
@@ -0,0 +1,108 @@
package dev.relism.flash.ext.mcp;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.flash.testing.FlashResponse;
import dev.relism.flash.testing.FlashTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** End-to-end JSON-RPC lifecycle over the real Streamable HTTP endpoint — no OAuth2 involved. */
class McpExtensionIntegrationTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
// Every test here is a stateless JSON-RPC call against the same server, so one boot for
// the class rather than one per test.
@RegisterExtension
static FlashTest mcp = FlashTest.of(app -> app.install(new McpExtension(
McpConfig.builder("test-server")
.version("9.9.9")
.toolsPackage("dev.relism.flash.ext.mcp.fixtures")
.security(McpSecurity.NONE)
.build())));
@Test
void initialize_returnsProtocolVersionCapabilitiesAndServerInfo() throws Exception {
JsonNode result = call(1, "initialize", "{}").get("result");
assertTrue(result.has("protocolVersion"));
assertEquals("test-server", result.get("serverInfo").get("name").asText());
assertEquals("9.9.9", result.get("serverInfo").get("version").asText());
assertTrue(result.get("capabilities").has("tools"));
assertTrue(result.get("capabilities").has("resources"));
assertTrue(result.get("capabilities").has("prompts"));
}
@Test
void toolsList_containsRegisteredTools() throws Exception {
JsonNode tools = call(2, "tools/list", "{}").get("result").get("tools");
assertEquals(2, tools.size());
}
@Test
void toolsCall_echo_returnsContent() throws Exception {
JsonNode result = call(3, "tools/call", "{\"name\":\"echo\",\"arguments\":{\"text\":\"hi there\"}}").get("result");
assertFalse(result.get("isError").asBoolean());
assertEquals("hi there", result.get("content").get(0).get("text").asText());
}
@Test
void toolsCall_failingTool_returnsIsErrorResultNotProtocolError() throws Exception {
JsonNode response = call(4, "tools/call", "{\"name\":\"boom\",\"arguments\":{}}");
assertFalse(response.has("error"));
JsonNode result = response.get("result");
assertTrue(result.get("isError").asBoolean());
assertTrue(result.get("content").get(0).get("text").asText().contains("kaboom"));
}
@Test
void toolsCall_unknownTool_returnsJsonRpcInvalidParamsError() throws Exception {
JsonNode response = call(5, "tools/call", "{\"name\":\"nope\",\"arguments\":{}}");
assertEquals(-32602, response.get("error").get("code").asInt());
}
@Test
void resourcesRead_returnsTextContents() throws Exception {
JsonNode result = call(6, "resources/read", "{\"uri\":\"greeting://hello\"}").get("result");
assertEquals("hello world", result.get("contents").get(0).get("text").asText());
}
@Test
void promptsGet_rendersMessage() throws Exception {
JsonNode result = call(7, "prompts/get", "{\"name\":\"summarize\",\"arguments\":{\"text\":\"foo\"}}").get("result");
assertEquals("Summarize: foo", result.get("messages").get(0).get("content").get("text").asText());
}
@Test
void notification_returns202WithEmptyBody() {
post("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}").expectStatus(202);
}
@Test
void malformedJson_returns400ParseError() throws Exception {
JsonNode json = MAPPER.readTree(post("not json").expectStatus(400).body());
assertEquals(-32700, json.get("error").get("code").asInt());
}
@Test
void unknownMethod_returnsJsonRpcMethodNotFound() throws Exception {
JsonNode response = call(8, "not/a/method", "{}");
assertEquals(-32601, response.get("error").get("code").asInt());
}
// ── Helpers ──────────────────────────────────────────────────────────────
private JsonNode call(int id, String method, String paramsJson) throws Exception {
String body = "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"" + method + "\",\"params\":" + paramsJson + "}";
return MAPPER.readTree(post(body).expectStatus(200).body());
}
private FlashResponse post(String body) {
return mcp.request().json(body).post("/mcp");
}
}
@@ -0,0 +1,179 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.ext.oidc.OidcConfig;
import dev.relism.flash.ext.oidc.OidcExtension;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashApplication;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.testing.FlashRequest;
import dev.relism.flash.testing.FlashResponse;
import dev.relism.flash.testing.FlashTest;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Exercises the actual OAuth2 resolution rules against a real {@code flash-ext-oidc}
* installation backed by {@link FakeOidcProvider} — real discovery, real JWKS, real RS256
* tokens — plus the fail-fast/degrade behavior when oidc is absent.
*
* <p>Four server configurations differ only in how MCP security is declared, so each gets its
* own {@link FlashTest} and they share one provider.
*/
class McpExtensionSecurityTest {
private static final String TOOLS_PACKAGE = "dev.relism.flash.ext.mcp.fixtures";
private static final String EXPLICIT_RESOURCE_ID = "https://mcp.example.com/mcp";
private static final FakeOidcProvider provider = newProvider();
/** MCP asked for AUTO security with no oidc installed — should degrade to public. */
@RegisterExtension
static FlashTest degraded = FlashTest.of(app -> app.install(new McpExtension(
McpConfig.builder("auto-server")
.toolsPackage(TOOLS_PACKAGE)
.security(McpSecurity.AUTO)
.build())));
/** REQUIRED with oidc, resource identifier derived from the request. */
@RegisterExtension
static FlashTest secured = FlashTest.of(securedApp(null, null));
/** REQUIRED with oidc and an explicitly declared resource identifier. */
@RegisterExtension
static FlashTest securedWithResourceId = FlashTest.of(securedApp(EXPLICIT_RESOURCE_ID, null));
/** REQUIRED with oidc and advertised scopes. */
@RegisterExtension
static FlashTest securedWithScopes =
FlashTest.of(securedApp(null, new String[] {"openid", "profile", "email"}));
@AfterAll
static void closeProvider() {
provider.close();
}
// ── No oidc installed ────────────────────────────────────────────────────
@Test
void required_withoutOidc_throwsAtBoot() {
// Asserting that boot fails, so this one builds its app directly rather than through
// the harness; port(0) still removes the old free-port dance.
FlashApp app = FlashApp.create(FlashConfiguration.builder()
.port(0).host("127.0.0.1").shutdownDrainTimeoutMs(250).build());
app.install(new McpExtension(McpConfig.builder("secure-server")
.toolsPackage(TOOLS_PACKAGE)
.security(McpSecurity.REQUIRED)
.build()));
try {
assertThrows(IllegalStateException.class, app::start);
} finally {
app.stop().join();
}
}
@Test
void auto_withoutOidc_degradesToPublic() {
post(degraded, initializeBody(), null).expectStatus(200);
}
// ── REQUIRED with oidc ───────────────────────────────────────────────────
@Test
void required_withOidc_rejectsMissingToken() {
post(secured, initializeBody(), null).expectStatus(401);
}
@Test
void required_withOidc_rejectsWrongAudience() throws Exception {
String token = provider.signToken("user-1", "https://someone-else.example.com/resource");
post(securedWithResourceId, initializeBody(), token).expectStatus(403);
}
@Test
void required_withOidc_acceptsValidAudience() throws Exception {
String token = provider.signToken("user-1", EXPLICIT_RESOURCE_ID);
post(securedWithResourceId, initializeBody(), token)
.expectStatus(200)
.expectBodyContains("\"protocolVersion\"");
}
@Test
void required_withOidc_noExplicitResourceIdentifier_derivesFromRequestAndEnforcesAudience() throws Exception {
String derivedResourceId = "http://127.0.0.1:" + secured.port() + "/mcp";
post(secured, initializeBody(), provider.signToken("user-1", derivedResourceId))
.expectStatus(200);
post(secured, initializeBody(), provider.signToken("user-1", "https://someone-else.example.com/resource"))
.expectStatus(403);
}
@Test
void required_withOidc_missingToken_challengeIncludesResourceMetadata() {
FlashResponse response = post(secured, initializeBody(), null).expectStatus(401);
String challenge = response.header("WWW-Authenticate");
assertTrue(challenge != null && challenge.contains("resource_metadata=\"http://127.0.0.1:"
+ secured.port() + "/.well-known/oauth-protected-resource/mcp\""),
"WWW-Authenticate: " + challenge);
}
// ── Protected resource metadata ──────────────────────────────────────────
@Test
void required_withOidc_noExplicitConfig_publishesProtectedResourceMetadata() {
FlashResponse response = secured.get("/.well-known/oauth-protected-resource/mcp")
.expectStatus(200)
.expectBodyContains("\"resource\":\"http://127.0.0.1:" + secured.port() + "/mcp\"")
.expectBodyContains("\"authorization_servers\":[\"" + provider.issuer() + "\"]");
assertTrue(!response.body().contains("scopes_supported"),
"scopes_supported must be omitted when unset: " + response.body());
}
@Test
void scopesSupported_published_inProtectedResourceMetadata() {
securedWithScopes.get("/.well-known/oauth-protected-resource/mcp")
.expectStatus(200)
.expectBodyContains("\"scopes_supported\":[\"openid\",\"profile\",\"email\"]");
}
// ── Helpers ──────────────────────────────────────────────────────────────
private static FlashApplication securedApp(String resourceIdentifier, String[] scopesSupported) {
return app -> {
app.install(new OidcExtension(OidcConfig.builder(
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
McpConfig.Builder mcp = McpConfig.builder("secure-server")
.toolsPackage(TOOLS_PACKAGE)
.security(McpSecurity.REQUIRED);
if (resourceIdentifier != null) mcp.resourceIdentifier(resourceIdentifier);
if (scopesSupported != null) mcp.scopesSupported(scopesSupported);
app.install(new McpExtension(mcp.build()));
};
}
private static FlashResponse post(FlashTest server, String body, String bearerToken) {
FlashRequest request = server.request().header("Accept", "application/json").json(body);
if (bearerToken != null) request.header("Authorization", "Bearer " + bearerToken);
return request.post("/mcp");
}
private static String initializeBody() {
return "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}";
}
private static FakeOidcProvider newProvider() {
try {
return new FakeOidcProvider();
} catch (Exception failure) {
throw new IllegalStateException("Could not start the fake OIDC provider", failure);
}
}
}
@@ -0,0 +1,57 @@
package dev.relism.flash.ext.mcp;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.flash.exceptions.InitializationException;
import dev.relism.flash.extension.FlashContext;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class McpRegistryTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Test
void scan_findsAndPrecompilesToolsResourcesPrompts() throws Exception {
McpRegistry registry = McpRegistry.scan("dev.relism.flash.ext.mcp.fixtures", new FlashContext(), false, "realm_access.roles");
assertTrue(registry.hasTools());
assertTrue(registry.hasResources());
assertTrue(registry.hasPrompts());
JsonNode tools = MAPPER.readTree(registry.toolsListJson());
assertEquals(2, tools.size()); // echo + boom
JsonNode echo = findByField(tools, "name", "echo");
assertEquals("Echoes the given text", echo.get("description").asText());
assertEquals("object", echo.get("inputSchema").get("type").asText());
assertEquals("string", echo.get("inputSchema").get("properties").get("text").get("type").asText());
assertEquals("text", echo.get("inputSchema").get("required").get(0).asText());
JsonNode resources = MAPPER.readTree(registry.resourcesListJson());
assertEquals(1, resources.size());
assertEquals("greeting://hello", resources.get(0).get("uri").asText());
JsonNode prompts = MAPPER.readTree(registry.promptsListJson());
assertEquals(1, prompts.size());
assertEquals("summarize", prompts.get(0).get("name").asText());
assertTrue(prompts.get(0).get("arguments").get(0).get("required").asBoolean());
assertEquals("echo", registry.tool("echo").name());
assertEquals("greeting://hello", registry.resource("greeting://hello").uri());
assertEquals("summarize", registry.prompt("summarize").name());
}
@Test
void scan_emptyPackage_throwsInitializationException() {
assertThrows(InitializationException.class,
() -> McpRegistry.scan("dev.relism.flash.ext.mcp.doesnotexist", new FlashContext(), false, "realm_access.roles"));
}
private static JsonNode findByField(JsonNode array, String field, String value) {
for (JsonNode n : array) if (value.equals(n.path(field).asText())) return n;
throw new AssertionError("No entry with " + field + "=" + value);
}
}
@@ -0,0 +1,48 @@
package dev.relism.flash.ext.mcp;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ToolArgumentsTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private ToolArguments of(String json) throws Exception {
return new ToolArguments(MAPPER.readTree(json));
}
@Test
void readsTypedFields() throws Exception {
ToolArguments args = of("{\"city\":\"Rome\",\"days\":3,\"temp\":21.5,\"metric\":true}");
assertEquals("Rome", args.getString("city"));
assertEquals(3, args.getInt("days"));
assertEquals(21.5, args.getDouble("temp"));
assertTrue(args.getBoolean("metric"));
assertTrue(args.has("city"));
assertFalse(args.has("missing"));
}
@Test
void missingFieldsFallBackToDefaults() throws Exception {
ToolArguments args = of("{}");
assertNull(args.getString("missing"));
assertEquals("fallback", args.getString("missing", "fallback"));
assertEquals(0, args.getInt("missing"));
assertEquals(42, args.getInt("missing", 42));
assertFalse(args.getBoolean("missing"));
}
@Test
void nullArgumentsNodeBehavesAsEmpty() {
ToolArguments args = new ToolArguments(null);
assertFalse(args.has("anything"));
assertNull(args.getString("anything"));
}
}
@@ -0,0 +1,20 @@
package dev.relism.flash.ext.mcp.authfixtures.authenticatedonly;
import dev.relism.flash.ext.mcp.McpTool;
import dev.relism.flash.ext.mcp.TextContent;
import dev.relism.flash.ext.mcp.Tool;
import dev.relism.flash.ext.mcp.ToolArguments;
import dev.relism.flash.ext.mcp.ToolResponse;
import dev.relism.flash.ext.oidc.Authenticated;
/** Deliberately misconfigured fixture: bare @Authenticated has no effect on an McpTool — see
* McpOidcIntegration#compileToolPolicy. Boot must fail with a clear message, not silently no-op. */
@Tool(name = "pointless", description = "Exists only to prove @Authenticated alone fails boot")
@Authenticated
public class PointlessAuthTool extends McpTool {
@Override
public ToolResponse call(ToolArguments args) {
return ToolResponse.success(new TextContent("unreachable"));
}
}
@@ -0,0 +1,18 @@
package dev.relism.flash.ext.mcp.authfixtures.secured;
import dev.relism.flash.ext.mcp.McpTool;
import dev.relism.flash.ext.mcp.TextContent;
import dev.relism.flash.ext.mcp.Tool;
import dev.relism.flash.ext.mcp.ToolArguments;
import dev.relism.flash.ext.mcp.ToolResponse;
import dev.relism.flash.ext.oidc.RolesAllowed;
@Tool(name = "admin_only", description = "Only callable with the admin role")
@RolesAllowed("admin")
public class AdminOnlyTool extends McpTool {
@Override
public ToolResponse call(ToolArguments args) {
return ToolResponse.success(new TextContent("ok"));
}
}
@@ -0,0 +1,17 @@
package dev.relism.flash.ext.mcp.authfixtures.secured;
import dev.relism.flash.ext.mcp.McpTool;
import dev.relism.flash.ext.mcp.TextContent;
import dev.relism.flash.ext.mcp.Tool;
import dev.relism.flash.ext.mcp.ToolArguments;
import dev.relism.flash.ext.mcp.ToolResponse;
/** No role/scope annotation — any authenticated caller, confirms unrelated tools are unaffected. */
@Tool(name = "open", description = "Callable by anyone already authenticated")
public class OpenTool extends McpTool {
@Override
public ToolResponse call(ToolArguments args) {
return ToolResponse.success(new TextContent("open"));
}
}
@@ -0,0 +1,18 @@
package dev.relism.flash.ext.mcp.authfixtures.secured;
import dev.relism.flash.ext.mcp.McpTool;
import dev.relism.flash.ext.mcp.TextContent;
import dev.relism.flash.ext.mcp.Tool;
import dev.relism.flash.ext.mcp.ToolArguments;
import dev.relism.flash.ext.mcp.ToolResponse;
import dev.relism.flash.ext.oidc.ScopesAllowed;
@Tool(name = "write_only", description = "Only callable with the write scope")
@ScopesAllowed("write")
public class WriteScopeTool extends McpTool {
@Override
public ToolResponse call(ToolArguments args) {
return ToolResponse.success(new TextContent("written"));
}
}
@@ -0,0 +1,18 @@
package dev.relism.flash.ext.mcp.fixtures;
import dev.relism.flash.ext.mcp.McpTool;
import dev.relism.flash.ext.mcp.TextContent;
import dev.relism.flash.ext.mcp.Tool;
import dev.relism.flash.ext.mcp.ToolArg;
import dev.relism.flash.ext.mcp.ToolArguments;
import dev.relism.flash.ext.mcp.ToolResponse;
@Tool(name = "echo", description = "Echoes the given text",
args = @ToolArg(name = "text", description = "Text to echo", required = true))
public class EchoTool extends McpTool {
@Override
public ToolResponse call(ToolArguments args) {
return ToolResponse.success(new TextContent(args.getString("text")));
}
}

Some files were not shown because too many files have changed in this diff Show More