64 Commits
Author SHA1 Message Date
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
github-actions[bot] f941eb63f4 chore(release): 2.0.0 2026-05-11 13:54:16 +00:00
Relism 8af81ac28c Merge pull request #1 from Relism/feature/ci-cd/setup
ci: setup CI/CD pipelines, versioning and agent guidelines
2026-05-11 14:24:13 +02:00
Relism 945633e5bd ci: setup CI/CD pipelines, versioning and agent guidelines 2026-05-11 14:19:54 +02:00
Relism f310646868 fix: enhance global key validation and update template parameters 2026-04-29 10:31:14 +02:00
Relism 0e2dad23e5 chore: add .idea/inspectionProfiles to gitignore 2026-04-26 13:01:55 +02:00
Relism a1cf4ada49 fix: merge jte extension refactor with static serving features
- Move JteStaticServing to dev.relism.flash package
- Fix imports in HttpStatus and test files
- Add fluent config methods to JteExtension (templateRoot, serveStatics, staticPrefix, etc.)
- Implement routes() for static asset serving with HEAD support
- Include Main.java entry point
2026-04-26 13:00:08 +02:00
Relism 5b3b887acd Merge remote-tracking branch 'origin/master'
# Conflicts:
#	.idea/workspace.xml
#	flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.class
#	flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteExtension.java
2026-04-26 12:36:26 +02:00
Relism 8a52c4f143 refactor: rename packages and files to use 'flash' prefix for consistency 2026-04-26 12:34:49 +02:00
Relism c2e3f98ea2 add static asset serving support with configurable options 2026-04-26 12:27:59 +02:00
Relism 6ef11ca595 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	.idea/workspace.xml
2026-04-21 09:34:54 +02:00
Relism 87d02aceb7 add workspace.xml to .gitignore 2026-04-21 09:34:35 +02:00
Relism 9e19f439be add core view extension with JTE and Thymeleaf support 2026-04-21 00:32:09 +02:00
Relism 34fd74068a implement OpenAPI contributor integration for rate limiting and response headers 2026-04-19 23:42:50 +02:00
Relism e161497f2c i spent the last year just spinning 2026-04-17 18:56:06 +02:00
Relism 9efbe38c0c weeks of bullshit 2026-04-17 08:18:11 +02:00
Relism b5d4481502 preparing for another refactoring... 2026-03-29 23:16:41 +02:00
Relism 2edd68b0aa preparing for a conceptual refactoring... 2026-03-28 14:11:12 +01:00
Relism 7b996b552b pre-major refactoring + ext api. 2026-03-26 14:10:53 +01:00
RelismandClaude Sonnet 4.6 f8c86e315f Untrack .claude/; placeholder README; ignore local config dirs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:46:18 +01:00
RelismandClaude Sonnet 4.6 7c1edffd6e Add Middleware API; untrack Main.java and local artifacts
- Add Middleware.java: @FunctionalInterface with Middleware.of() chain
  composition and andThen() helper; zero allocations on hot-path
- Remove flash/Main.java from versioning (scratch/test entrypoint)
- Update .gitignore: exclude flash-bench/, nuxt-shadcn-dashboard/,
  /dev/, /docs/, Main.java, *.text — all root-level local artifacts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:43:13 +01:00
RelismandClaude Sonnet 4.6 5f0681a922 Remove flash-bench, docs, stray .class files; rewrite README for library
- Remove flash-bench module from git and root pom.xml (kept locally)
- Remove docs/ directory (lifecycle markdown files)
- Remove stray compiled fpr-core .class files tracked by mistake
- Rewrite README.md: concise library overview, quick-start, handler styles, Maven dependency — no wrk/benchmarking content

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:39:33 +01:00
Relism 9a30c5ab16 enhanced router middleware support; added pre-fused middleware handling and improved handler registration 2026-03-19 21:35:54 +01:00
Relism 16b5f8ac15 multipart parsing, request body access, and chunked input stream support 2026-03-19 12:45:34 +01:00
Relism 96afbf665d enhanced HTTP server configuration and response handling; added acceptorThreads, improved header management, and refined error page titles 2026-03-19 12:44:33 +01:00
Relism 94d029a631 optimized dynamic body size impl, decluttering javadocs/comments 2026-03-15 17:14:17 +01:00
Relism b0d606cb5f refactored, pre-buffer reuse 2026-03-15 14:31:52 +01:00
Relism f1beb160aa pre-module refactor 2026-03-15 02:04:36 +01:00
Relism 79e4578731 Initial 2026-03-15 01:54:35 +01:00
1427 changed files with 34247 additions and 159779 deletions
View File
+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
+17
View File
@@ -0,0 +1,17 @@
<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">
<servers>
<server>
<id>Personal</id>
<username>${env.MAVEN_USERNAME}</username>
<password>${env.MAVEN_PASSWORD}</password>
</server>
<server>
<id>Personal-snapshots</id>
<username>${env.MAVEN_USERNAME}</username>
<password>${env.MAVEN_PASSWORD}</password>
</server>
</servers>
</settings>
+47
View File
@@ -0,0 +1,47 @@
name: CI
on:
push:
branches:
- master
- 'feature/**'
- 'fix/**'
- 'hotfix/**'
tags-ignore:
- 'v*'
pull_request:
branches:
- master
jobs:
build:
name: Build & Test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Temurin 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: maven
server-id: Personal
server-username: MAVEN_USERNAME
server-password: MAVEN_PASSWORD
- name: Build and test
run: mvn -B --settings .github/settings.xml clean verify
env:
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
- name: Publish test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: '**/target/surefire-reports/*.xml'
retention-days: 7
+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
+69
View File
@@ -0,0 +1,69 @@
name: Prepare Release
on:
workflow_dispatch:
inputs:
version:
description: 'Release version (e.g. 2.0.0)'
required: true
type: string
next_version:
description: 'Next development version without -SNAPSHOT (e.g. 2.1.0)'
required: true
type: string
jobs:
prepare:
name: Bump, Tag & Push
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Set up Temurin 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: maven
- name: Configure Git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Set release version
run: mvn -B --settings .github/settings.xml versions:set -DnewVersion=${{ inputs.version }}
env:
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
- name: Commit release version
run: |
git add -A
git commit -m "chore(release): ${{ inputs.version }}"
- name: Tag release
run: git tag v${{ inputs.version }}
- name: Set next snapshot version
run: mvn -B --settings .github/settings.xml versions:set -DnewVersion=${{ inputs.next_version }}-SNAPSHOT
env:
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
- name: Commit next snapshot version
run: |
git add -A
git commit -m "chore(release): prepare ${{ inputs.next_version }}-SNAPSHOT"
- name: Push commits and tag
run: |
git push origin master
git push origin v${{ inputs.version }}
+70
View File
@@ -0,0 +1,70 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
# ── 1. Build, GPG-sign, deploy to Maven releases ──────────────────────────
release:
name: Build, Sign & Deploy
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
version: ${{ steps.version.outputs.VERSION }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract version from tag
id: version
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Set up Temurin 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: maven
- name: Import GPG key
run: |
echo "${{ secrets.GPG_PRIVATE_KEY }}" | gpg --batch --import
GPG_KEY_ID=$(gpg --list-secret-keys --with-colons | grep '^sec' | cut -d: -f5 | head -1)
echo "GPG_KEY_ID=$GPG_KEY_ID" >> $GITHUB_ENV
- name: Build, sign and deploy to releases
run: |
mvn -B --settings .github/settings.xml \
-DperformRelease=true \
-Dgpg.keyname=$GPG_KEY_ID \
clean deploy
env:
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
generate_release_notes: true
draft: 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 }}
+30
View File
@@ -0,0 +1,30 @@
name: Publish Snapshot
on:
push:
branches:
- master
tags-ignore:
- 'v*'
jobs:
snapshot:
name: Deploy Snapshot
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Temurin 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: maven
- name: Deploy snapshot
run: mvn -B --settings .github/settings.xml clean deploy -DskipTests
env:
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
+57
View File
@@ -0,0 +1,57 @@
target/
*.versionsBackup
.mvn/timing.properties
*.class
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
.kotlin
### IntelliJ IDEA ###
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
.idea/workspace.xml
.idea/inspectionProfiles/
*.iws
*.iml
*.ipr
### Eclipse ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
### Mac OS ###
.DS_Store
CLAUDE.md
.claude/
### Local / scratch ###
flash/src/main/java/dev/relism/Main.java
flash-bench/
nuxt-shadcn-dashboard/
/dev/
/docs/
jmh-result.text
*.text
/flash-extensions/flash-ext-routeviewer/routeviewer-ui/node_modules/
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AskMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Ask2AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EditMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
+43
View File
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/flash-bench/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-bench/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-api/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-api/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<excludedPredefinedLibrary name="Flash/nuxt-shadcn-dashboard/node_modules" />
</component>
</project>
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EntryPointsManager">
<writeAnnotations>
<writeAnnotation name="lombok.Getter" />
</writeAnnotations>
</component>
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/pom.xml" />
<option value="$PROJECT_DIR$/flash-bench/pom.xml" />
</list>
</option>
<option name="ignoredFiles">
<set>
<option value="$PROJECT_DIR$/flash-bench/pom.xml" />
</set>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+480
View File
@@ -0,0 +1,480 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="fc757130-fe3e-4ea9-8d69-c26ffb8545a4" name="Changes" comment="feat: introduce WebSocket support with new endpoints and transaction propagation enhancements">
<change beforePath="$PROJECT_DIR$/.github/workflows/release.yml" beforeDir="false" afterPath="$PROJECT_DIR$/.github/workflows/release.yml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</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">
<persistenceIdMap>
<entry key="_C:/Users/elorc/Documents/Coding/Java/practice/Flash" value="3Axc6mzLyNvh4TgGFvNSCSq83xw" />
<entry key="_C:/Users/elorc/Documents/Coding/Java/practice/FlashPractice" value="3AoiAx4zfxMcuO6fDI9oALOPcq1" />
<entry key="_C:/Users/relis/Documents/coding/personale/Java/Flash5" value="3CTWwiHEXMdUOOMmV3iz3FrRc6h" />
</persistenceIdMap>
</component>
<component name="EmbeddingIndexingInfo">
<option name="cachedIndexableFilesCount" value="448" />
<option name="fileBasedEmbeddingIndicesEnabled" value="true" />
</component>
<component name="FileTemplateManagerImpl">
<option name="RECENT_TEMPLATES">
<list>
<option value="Enum" />
<option value="Interface" />
<option value="Class" />
</list>
</option>
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
<option name="ROOT_SYNC" value="DONT_SYNC" />
</component>
<component name="GitHubPullRequestSearchHistory">{
&quot;lastFilter&quot;: {
&quot;state&quot;: &quot;OPEN&quot;,
&quot;assignee&quot;: &quot;Relism&quot;
}
}</component>
<component name="GithubPullRequestsUISettings">{
&quot;selectedUrlAndAccountId&quot;: {
&quot;url&quot;: &quot;https://github.com/Relism/Flash5.git&quot;,
&quot;accountId&quot;: &quot;86d8a39c-af27-4b79-8d5d-dc74375c3348&quot;
}
}</component>
<component name="McpProjectServerCommands">
<commands />
<urls />
</component>
<component name="ProblemsViewState">
<option name="selectedTabId" value="ProjectErrors" />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 3
}</component>
<component name="ProjectId" id="3AoiAx4zfxMcuO6fDI9oALOPcq1" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;Application.(dev) flash-bench.executor&quot;: &quot;Run&quot;,
&quot;Application.ExternalBenchmark (1).executor&quot;: &quot;Run&quot;,
&quot;Application.ExternalBenchmark.executor&quot;: &quot;Run&quot;,
&quot;Application.Main.executor&quot;: &quot;Run&quot;,
&quot;Application.MainAlt.executor&quot;: &quot;Run&quot;,
&quot;Application.dev.relism.bench.Main.executor&quot;: &quot;Run&quot;,
&quot;JUnit.RequestParserTest.executor&quot;: &quot;Run&quot;,
&quot;JUnit.RequestParserTest.headers_caseInsensitive.executor&quot;: &quot;Debug&quot;,
&quot;Maven.FlashPractice [test].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash [compile].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash [install].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash [test].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash [verify].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-bench [clean].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-bench [install].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-bench [package].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-bench [validate].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-ext-limiter [install].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-ext-limiter [package].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-ext-view [verify].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-parent [clean].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-parent [compile].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-parent [deploy].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-parent [install].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-parent [package].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-parent [test].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-parent [validate].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-parent [verify].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-web-bundler [test].executor&quot;: &quot;Run&quot;,
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
&quot;RunOnceActivity.MCP Project settings loaded&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
&quot;SHARE_PROJECT_CONFIGURATION_FILES&quot;: &quot;true&quot;,
&quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
&quot;git-widget-placeholder&quot;: &quot;master&quot;,
&quot;ignore.virus.scanning.warn.message&quot;: &quot;true&quot;,
&quot;kotlin-language-version-configured&quot;: &quot;true&quot;,
&quot;last_opened_file_path&quot;: &quot;C:/Users/elorc/Documents/Coding/Java/practice/Flash&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;npm.build.executor&quot;: &quot;Run&quot;,
&quot;onboarding.tips.debug.path&quot;: &quot;C:/Users/elorc/Documents/Coding/Java/practice/Flash/flash-extensions/flash-ext-data/src/main/java/dev/relism/Main.java&quot;,
&quot;project.structure.last.edited&quot;: &quot;Modules&quot;,
&quot;project.structure.proportion&quot;: &quot;0.15&quot;,
&quot;project.structure.side.proportion&quot;: &quot;0.1150748&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;project.propVCSSupport.DirectoryMappings&quot;,
&quot;ts.external.directory.path&quot;: &quot;C:\\Users\\elorc\\Documents\\Coding\\Java\\practice\\Flash\\nuxt-shadcn-dashboard\\node_modules\\typescript\\lib&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
}
}</component>
<component name="RecentsManager">
<key name="MoveFile.RECENT_KEYS">
<recent name="C:\Users\elorc\Documents\Coding\Java\practice\Flash" />
</key>
<key name="MoveClassesOrPackagesDialog.RECENTS_KEY">
<recent name="dev.relism" />
</key>
</component>
<component name="RunManager" selected="Application.MainAlt">
<configuration name="ExternalBenchmark" type="Application" factoryName="Application" temporary="true" nameIsGenerated="true">
<option name="MAIN_CLASS_NAME" value="dev.relism.bench.ExternalBenchmark" />
<module name="flash" />
<extension name="coverage">
<pattern>
<option name="PATTERN" value="dev.relism.bench.*" />
<option name="ENABLED" value="true" />
</pattern>
</extension>
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
<configuration name="MainAlt" type="Application" factoryName="Application" temporary="true" nameIsGenerated="true">
<option name="MAIN_CLASS_NAME" value="dev.relism.bench.MainAlt" />
<module name="flash-bench" />
<option name="VM_PARAMETERS" value="-Dflash.env=dev" />
<extension name="coverage">
<pattern>
<option name="PATTERN" value="dev.relism.bench.*" />
<option name="ENABLED" value="true" />
</pattern>
</extension>
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
<configuration name="(dev) flash-bench" type="Application" factoryName="Application">
<option name="MAIN_CLASS_NAME" value="dev.relism.bench.Main" />
<module name="flash-bench" />
<option name="VM_PARAMETERS" value="-Dflash.env=dev" />
<extension name="coverage">
<pattern>
<option name="PATTERN" value="dev.relism.bench.*" />
<option name="ENABLED" value="true" />
</pattern>
</extension>
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
<list>
<item itemvalue="Application.(dev) flash-bench" />
<item itemvalue="Application.ExternalBenchmark" />
<item itemvalue="Application.MainAlt" />
</list>
<recent_temporary>
<list>
<item itemvalue="Application.MainAlt" />
</list>
</recent_temporary>
</component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-jdk-30f59d01ecdd-2fc7cc6b9a17-intellij.indexing.shared.core-IU-253.30387.90" />
<option value="bundled-js-predefined-d6986cc7102b-9b0f141eb926-JavaScript-IU-253.30387.90" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="fc757130-fe3e-4ea9-8d69-c26ffb8545a4" name="Changes" comment="" />
<created>1773265186049</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1773265186049</updated>
<workItem from="1773265187409" duration="5496000" />
<workItem from="1773313172181" duration="9617000" />
<workItem from="1773325837582" duration="3918000" />
<workItem from="1773400995673" duration="23805000" />
<workItem from="1773442006686" duration="2722000" />
<workItem from="1773491408276" duration="23095000" />
<workItem from="1773528595525" duration="7400000" />
<workItem from="1773536222450" duration="680000" />
<workItem from="1773536904928" duration="395000" />
<workItem from="1773537302622" duration="150000" />
<workItem from="1773537489622" duration="5048000" />
<workItem from="1773576241900" duration="22639000" />
<workItem from="1773613172976" duration="13157000" />
<workItem from="1773668722120" duration="389000" />
<workItem from="1773680650110" duration="4711000" />
<workItem from="1773701882230" duration="6975000" />
<workItem from="1773744806275" duration="48612000" />
<workItem from="1773831300464" duration="1538000" />
<workItem from="1773837116508" duration="4255000" />
<workItem from="1773847734658" duration="911000" />
<workItem from="1773875652058" duration="5481000" />
<workItem from="1773915392332" duration="36877000" />
<workItem from="1773996650761" duration="101000" />
<workItem from="1774011413085" duration="2519000" />
<workItem from="1774033964550" duration="5379000" />
<workItem from="1774116033963" duration="1364000" />
<workItem from="1774136286683" duration="3816000" />
<workItem from="1774187096932" duration="25000" />
<workItem from="1774458099414" duration="14351000" />
<workItem from="1774523863906" duration="9762000" />
<workItem from="1774555172612" duration="9776000" />
<workItem from="1774604979874" duration="16474000" />
<workItem from="1774628513273" duration="86000" />
<workItem from="1774638461244" duration="5718000" />
<workItem from="1774691772785" duration="3801000" />
<workItem from="1774703412987" duration="25271000" />
<workItem from="1774777423667" duration="2127000" />
<workItem from="1774790933314" duration="10099000" />
<workItem from="1774814271256" duration="6223000" />
<workItem from="1774872087063" duration="23188000" />
<workItem from="1774944423363" duration="2161000" />
<workItem from="1774953226940" duration="4167000" />
<workItem from="1775295922142" duration="5680000" />
<workItem from="1775312662640" duration="1858000" />
<workItem from="1775391482685" duration="1961000" />
<workItem from="1775495346638" duration="596000" />
<workItem from="1775747075572" duration="1237000" />
<workItem from="1776192895352" duration="3823000" />
<workItem from="1776198526994" duration="4187000" />
<workItem from="1776240695245" duration="14368000" />
<workItem from="1776410282788" duration="22340000" />
<workItem from="1776515579524" duration="1172000" />
<workItem from="1776626614705" duration="4066000" />
<workItem from="1776670293634" duration="9127000" />
<workItem from="1776716089200" duration="2010000" />
<workItem from="1776872699811" duration="4161000" />
<workItem from="1776931805422" duration="16122000" />
<workItem from="1777051880577" duration="2650000" />
<workItem from="1777150747725" duration="837000" />
<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 id="LOCAL-00001" summary="Initial">
<option name="closed" value="true" />
<created>1773536078762</created>
<option name="number" value="00001" />
<option name="presentableId" value="LOCAL-00001" />
<option name="project" value="LOCAL" />
<updated>1773536078762</updated>
</task>
<task id="LOCAL-00002" summary="pre-module refactor">
<option name="closed" value="true" />
<created>1773536678416</created>
<option name="number" value="00002" />
<option name="presentableId" value="LOCAL-00002" />
<option name="project" value="LOCAL" />
<updated>1773536678416</updated>
</task>
<task id="LOCAL-00003" summary="refactored, pre-buffer reuse">
<option name="closed" value="true" />
<created>1773581516834</created>
<option name="number" value="00003" />
<option name="presentableId" value="LOCAL-00003" />
<option name="project" value="LOCAL" />
<updated>1773581516834</updated>
</task>
<task id="LOCAL-00004" summary="optimized dynamic body size impl, decluttering javadocs/comments">
<option name="closed" value="true" />
<created>1773591259108</created>
<option name="number" value="00004" />
<option name="presentableId" value="LOCAL-00004" />
<option name="project" value="LOCAL" />
<updated>1773591259108</updated>
</task>
<task id="LOCAL-00005" summary="enhanced HTTP server configuration and response handling; added acceptorThreads, improved header management, and refined error page titles">
<option name="closed" value="true" />
<created>1773920675327</created>
<option name="number" value="00005" />
<option name="presentableId" value="LOCAL-00005" />
<option name="project" value="LOCAL" />
<updated>1773920675327</updated>
</task>
<task id="LOCAL-00006" summary="multipart parsing, request body access, and chunked input stream support">
<option name="closed" value="true" />
<created>1773920735659</created>
<option name="number" value="00006" />
<option name="presentableId" value="LOCAL-00006" />
<option name="project" value="LOCAL" />
<updated>1773920735659</updated>
</task>
<task id="LOCAL-00007" summary="enhanced router middleware support; added pre-fused middleware handling and improved handler registration">
<option name="closed" value="true" />
<created>1773952556190</created>
<option name="number" value="00007" />
<option name="presentableId" value="LOCAL-00007" />
<option name="project" value="LOCAL" />
<updated>1773952556190</updated>
</task>
<task id="LOCAL-00008" summary="pre-major refactoring + ext api.">
<option name="closed" value="true" />
<created>1774530802482</created>
<option name="number" value="00008" />
<option name="presentableId" value="LOCAL-00008" />
<option name="project" value="LOCAL" />
<updated>1774530802482</updated>
</task>
<task id="LOCAL-00009" summary="preparing for a conceptual refactoring...">
<option name="closed" value="true" />
<created>1774703474044</created>
<option name="number" value="00009" />
<option name="presentableId" value="LOCAL-00009" />
<option name="project" value="LOCAL" />
<updated>1774703474044</updated>
</task>
<task id="LOCAL-00010" summary="preparing for another refactoring...">
<option name="closed" value="true" />
<created>1774819003821</created>
<option name="number" value="00010" />
<option name="presentableId" value="LOCAL-00010" />
<option name="project" value="LOCAL" />
<updated>1774819003821</updated>
</task>
<task id="LOCAL-00011" summary="implement OpenAPI contributor integration for rate limiting and response headers">
<option name="closed" value="true" />
<created>1776634972916</created>
<option name="number" value="00011" />
<option name="presentableId" value="LOCAL-00011" />
<option name="project" value="LOCAL" />
<updated>1776634972916</updated>
</task>
<task id="LOCAL-00012" summary="add core view extension with JTE and Thymeleaf support">
<option name="closed" value="true" />
<created>1776724331443</created>
<option name="number" value="00012" />
<option name="presentableId" value="LOCAL-00012" />
<option name="project" value="LOCAL" />
<updated>1776724331443</updated>
</task>
<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 />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="Vcs.Log.Tabs.Properties">
<option name="TAB_STATES">
<map>
<entry key="MAIN">
<value>
<State>
<option name="FILTERS">
<map>
<entry key="branch">
<value>
<list>
<option value="claude/distracted-spence" />
</list>
</value>
</entry>
</map>
</option>
</State>
</value>
</entry>
</map>
</option>
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="Initial" />
<MESSAGE value="pre-module refactor" />
<MESSAGE value="refactored, pre-buffer reuse" />
<MESSAGE value="optimized dynamic body size impl, decluttering javadocs/comments" />
<MESSAGE value="enhanced HTTP server configuration and response handling; added acceptorThreads, improved header management, and refined error page titles" />
<MESSAGE value="multipart parsing, request body access, and chunked input stream support" />
<MESSAGE value="enhanced router middleware support; added pre-fused middleware handling and improved handler registration" />
<MESSAGE value="pre-major refactoring + ext api." />
<MESSAGE value="preparing for a conceptual refactoring..." />
<MESSAGE value="preparing for another refactoring..." />
<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="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 name="XSLT-Support.FileAssociations.UIState">
<expand />
<select />
</component>
<component name="github-copilot-workspace">
<instructionFileLocations>
<option value=".github/instructions" />
</instructionFileLocations>
<promptFileLocations>
<option value=".github/prompts" />
</promptFileLocations>
</component>
</project>
+118
View File
@@ -0,0 +1,118 @@
# Flash — Agent Guidelines
This document defines the conventions and rules that all agents (AI or human) must follow
when working on this repository. Read it entirely before making any change.
---
## Git Workflow
### Branch Naming
| Type | Pattern | Example |
|------|---------|---------|
| New feature | `feature/<scope>/<short-description>` | `feature/ext-oidc/pkce-support` |
| Bug fix | `fix/<scope>/<short-description>` | `fix/core/router-npe` |
| Hotfix on released version | `hotfix/<version>/<short-description>` | `hotfix/2.0.1/auth-bypass` |
| CI/infra changes | `feature/ci/<short-description>` | `feature/ci/add-snapshot-workflow` |
Rules:
- Always branch from `master`.
- Branch names are lowercase, words separated by `-`.
- Never push directly to `master`.
- Never create `develop`, `release/*`, or any other long-lived branch.
### Commit Messages — Conventional Commits
Format: `<type>(<scope>): <short description>`
| Type | When to use |
|------|-------------|
| `feat` | New feature |
| `fix` | Bug fix |
| `refactor` | Code change without feature/fix |
| `test` | Adding or updating tests |
| `docs` | Documentation only |
| `chore` | Build, deps, tooling — no production code |
| `ci` | Changes to GitHub Actions workflows |
Allowed scopes: `core`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`,
`ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-web-bundler`,
`ext-mcp`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`, `release`, `deps`, `ci`.
Examples:
```
feat(ext-oidc): add PKCE support
fix(core): fix NPE in RouteHandler when path is null
chore(deps): upgrade jackson to 2.18.0
ci: add timeout to snapshot workflow
chore(release): 2.1.0
```
### Pull Requests
- Every branch must be merged via PR, never with a direct push.
- PR title must follow Conventional Commits format.
- CI (`ci.yml`) must be green before merging.
- Squash merge is preferred for `feature/*` and `fix/*` to keep history clean.
- Merge commit is preferred for hotfixes (preserves the fix commit intact).
---
## Versioning
- All modules share a single version defined in the root `pom.xml` (`flash-parent`).
- Never change the version in child POMs — always and only in the parent.
- Current scheme: `MAJOR.MINOR.PATCH`
- MAJOR: breaking API changes
- MINOR: new backward-compatible features
- PATCH: backward-compatible bug fixes on an already-released version
- During development, master always carries a `-SNAPSHOT` version.
- **Never manually edit the version** — versions are bumped exclusively by the
`prepare-release` GitHub Actions workflow.
### Release Process (for maintainers only)
1. Ensure `master` is green (CI passing).
2. Go to GitHub Actions → `Prepare Release``Run workflow`.
3. Input `version` (e.g. `2.1.0`) and `next_version` (e.g. `2.2.0`).
4. The workflow handles everything: bump, commit, tag, push.
5. The `release` workflow then triggers automatically on the tag.
---
## Maven & Module Structure
- Root POM: `flash-parent` — defines all dependency versions and plugin config.
- `flash` module: the core framework JAR.
- `flash-extensions` POM: aggregator for all extension modules.
- Extensions live under `flash-extensions/flash-ext-*/`.
- When adding a new extension:
1. Add the module to `flash-extensions/pom.xml` `<modules>`.
2. Add the dependency to `flash-extensions/pom.xml` `<dependencyManagement>`.
3. Add the dependency to the root `pom.xml` `<dependencyManagement>`.
4. Do **not** declare a `<version>` in the new module's POM — it inherits from the parent.
---
## CI/CD Pipelines
| Workflow | Trigger | What it does |
|----------|---------|--------------|
| `ci.yml` | Push to any branch, PR to master | Compile + test — required gate |
| `snapshot.yml` | Push to `master` | Deploy `-SNAPSHOT` to `maven.relism.dev/snapshots` |
| `prepare-release.yml` | Manual `workflow_dispatch` | Bump version, commit, tag, push |
| `release.yml` | Push of tag `v*` | GPG sign, deploy to `/releases`, JavaDoc to GitHub Pages, GitHub Release |
**Agents must never manually trigger `prepare-release` or modify version strings.**
---
## What Agents Must NOT Do
- Push directly to `master` or `gh-pages`.
- Manually edit `<version>` tags in any POM.
- Add new `<repositories>` or `<distributionManagement>` entries without explicit instruction.
- Modify `.github/workflows/*.yml` files without explicit instruction.
- Commit generated files (`target/`, `*.class`, `*.versionsBackup`).
- Use `git push --force` on any branch.
-1
View File
@@ -1 +0,0 @@
flash.javadocs.relism.dev
+289
View File
@@ -0,0 +1,289 @@
# Flash
A high-performance HTTP/1.1 server library for Java 21, built around virtual threads and a zero-allocation FSM router.
## Modules
| Module | Description |
|---|---|
| `flash` | Core server library — router, request parser, HTTP I/O transport |
| `flash-extensions/flash-ext-jackson` | Jackson JSON integration |
| `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-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-jte` | Opinionated jte SSR extension |
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
| `flash-bench` | Demo harness (OIDC + OpenAPI + Jackson) |
## Requirements
- Java 21+
- Maven 3.8+
## Quick start
```java
FlashApp.create(8080)
.get("/ping", (req, res) -> "pong")
.start();
```
With full configuration:
```java
FlashApp.create(
FlashConfiguration.builder()
.port(8080)
.host("0.0.0.0")
.maxHeaderBufferSize(65536)
.build()
)
.get("/ping", (req, res) -> "pong")
.start();
```
## Route registration
### Lambda routes
```java
FlashApp app = FlashApp.create(8080);
app.get("/hello", (req, res) -> "world");
app.post("/echo", (req, res) -> {
byte[] body = req.body().bytes();
return res.status(200).body(body);
});
app.get("/users/{id}", (req, res) -> {
String id = req.pathParam("id");
return "user:" + id;
});
```
### Class-based handlers
Extend `RequestHandler`, annotate it, then scan its package. Dependencies are cached in
`onInit()` after Flash has resolved its complete boot-time service graph:
```java
@GET("/api/users")
public class ListUsers extends RequestHandler {
private UserService users;
@Override protected void onInit() { users = require(UserService.class); }
@Override public Object handle(Request req, Response res) { return users.list(); }
}
app.scan("dev.example.api");
```
### Middleware
Apply middleware at registration. Flash composes the final chain at boot:
```java
Middleware authCheck = next -> (req, res) -> {
if (req.header("Authorization") == null)
return res.status(401).body("Unauthorized");
return next.handle(req, res);
};
app.get("/secure", (req, res) -> "secret data", authCheck);
```
Multiple middlewares are composed outermost-first (left-to-right in the call):
```java
app.get("/admin", handler, logging, auth, rateLimit);
// execution order: logging → auth → rateLimit → handler
```
### Classpath scan
Scans a package for classes that extend `RequestHandler` and carry `@Route`. Each is
instantiated via its public no-arg constructor:
```java
app.scan("dev.example.handlers");
```
### Namespace mounting
Mount a scoped sub-router under a prefix. All routes registered inside the scope get the
prefix prepended automatically. The scope inherits the parent's extension context (annotation
processors, services):
```java
app.mount("/api", scope -> {
scope.get("/health", (req, res) -> "ok"); // → GET /api/health
scope.scan("dev.example.api");
});
```
## Extensions
Extensions have one declarative `configure` method. They declare services, processors and route
callbacks; Flash resolves the complete graph, materialises routes, compiles both routers, then
opens listeners. Extension install order never makes a service “not ready”.
```java
FlashApp.create(8080)
.install(new JacksonExtension())
.install(new OpenApiExtension("/openapi", "My API", "1.0.0"))
.install(new OidcExtension(oidcConfig))
.scan("dev.example.handlers")
.start();
```
See extension-specific READMEs for full details:
- [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md)
- [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/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-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md)
## Error handlers
```java
app.onNotFound((req, res) -> res.status(404).body("Not found: " + req.path()));
app.onException((ex, req, res) -> {
if (ex instanceof IllegalArgumentException)
return res.status(400).body(ex.getMessage());
return res.status(500).body("Internal error");
});
```
## FlashConfiguration
| Field | Default | Description |
|---|---|---|
| `port` | — | TCP port to bind |
| `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) |
## TLS
HTTPS and WSS are a transport-layer concern only: once a listener is bound, the accepted
`Socket` is either plain or an `SSLSocket` indistinguishably from `HttpServer`'s point of view
onward — the request parser, router, and WebSocket upgrade never branch on it. WSS is therefore
not a separate feature; it's a WebSocket upgrade running over whatever transport it was handed.
### 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.
## Architecture
```
ServerSocket.accept()
→ RequestParser.parse() # zero-alloc header parsing, buffer reuse across keep-alive
→ GlobalRouter.route() # two-tier: mounted sub-routers (longest prefix) then FastPathRouterImpl
→ RequestHandler.handle() # user handler; return value sets body
→ Request.drain() # consume unread body for keep-alive
→ HttpServer writes response # status line, headers, then fixed or chunked body
→ loop or close socket # based on Connection header
```
- **Virtual threads** — each accepted socket runs on a virtual thread (`Executors.newVirtualThreadPerTaskExecutor()`). 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.
- **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.
- **TLS is transport-only** — see [TLS](#tls). Listeners bind either a plain `ServerSocket` or an `SSLServerSocket`; nothing downstream of `accept()` branches on which.
## Build & test
```bash
# Build all modules (skip tests)
mvn clean package -DskipTests
# Run all tests
mvn test
# Run a single test class
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.
@@ -0,0 +1,84 @@
<?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-data-core</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash</artifactId>
</dependency>
<dependency>
<groupId>jakarta.transaction</groupId>
<artifactId>jakarta.transaction-api</artifactId>
</dependency>
<dependency>
<groupId>jakarta.annotation</groupId>
<artifactId>jakarta.annotation-api</artifactId>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
</dependency>
</dependencies>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<jacoco.version>0.8.12</jacoco.version>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco.version}</version>
<executions>
<execution>
<id>jacoco-initialize</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>jacoco-site</id>
<phase>verify</phase>
<goals>
<goal>report</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,63 @@
package dev.relism.flash.ext.data;
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.TxManager;
import dev.relism.flash.ext.data.core.TransactionPropagation;
import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.routing.MiddlewareKey;
import dev.relism.flash.routing.MiddlewareNode;
import dev.relism.flash.routing.Middleware;
import jakarta.transaction.Transactional;
import java.util.List;
import java.util.Objects;
public final class DataExtension implements FlashExtension {
private static final MiddlewareKey TRANSACTION = MiddlewareKey.of("flash.data.transaction");
private final TxManager txManager;
private final Tx tx;
private final Data data;
public DataExtension(TxManager txManager) {
this(txManager, null);
}
public DataExtension(TxManager txManager, Data data) {
this.txManager = Objects.requireNonNull(txManager);
this.data = data;
this.tx = data != null ? data.tx() : new Tx(txManager);
}
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
ctx.provide(Tx.class, tx);
ctx.provide(TxManager.class, txManager);
if (data != null) ctx.provide(Data.class, data);
ctx.addAnnotationProcessor(handlerClass -> {
Transactional ann = handlerClass.getAnnotation(Transactional.class);
if (ann == null) {
return List.of();
}
TxDefinition definition = TxDefinition.DEFAULTS
.withPropagation(mapTxType(ann.value()));
Middleware middleware = next -> (req, res) -> {
return tx.call(definition, () -> next.handle(req, res));
};
return List.of(MiddlewareNode.of(TRANSACTION, middleware));
});
}
private TransactionPropagation mapTxType(Transactional.TxType txType) {
return switch (txType) {
case REQUIRED -> TransactionPropagation.REQUIRED;
case REQUIRES_NEW -> TransactionPropagation.REQUIRES_NEW;
case SUPPORTS -> TransactionPropagation.SUPPORTS;
case MANDATORY -> TransactionPropagation.MANDATORY;
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,15 @@
package dev.relism.flash.ext.data.core;
import java.util.List;
public record Page<T>(
List<T> content,
int page,
int size,
long total
) {
public int totalPages() { return size == 0 ? 0 : (int) Math.ceil((double) total / size); }
public boolean hasNext() { return page + 1 < totalPages(); }
public boolean hasPrev() { return page > 0; }
public boolean isEmpty() { return content.isEmpty(); }
}
@@ -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;
}
}
@@ -0,0 +1,121 @@
package dev.relism.flash.ext.data.core;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public abstract class Repository<T, ID> extends RepositorySupport<T, ID> {
protected Repository(Tx tx) {
super(tx);
}
public Optional<T> findById(ID id) {
return roQuery(() -> doFindById(id));
}
public boolean existsById(ID id) {
return roQuery(() -> doExistsById(id));
}
public long count() {
return roQuery(this::doCount);
}
public List<T> findAll() {
return findAll(Query.all());
}
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 List<T> doFind(Query<T> query);
protected abstract Optional<T> doFindOne(Spec<T> spec);
protected abstract Page<T> doFindPage(Query<T> query);
protected abstract boolean doExistsById(ID id);
protected abstract long doCount();
protected abstract T doSave(T entity);
protected abstract List<T> doSaveAll(Iterable<T> entities);
protected abstract T doUpdate(T entity);
protected abstract void doDelete(T entity);
protected abstract void doDeleteById(ID id);
protected abstract int doDeleteAll(Spec<T> spec);
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);
}
}
@@ -0,0 +1,111 @@
package dev.relism.flash.ext.data.core;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public final class ResourceRegistry {
private static final ThreadLocal<Map<TxResourceKey, Object>> RESOURCES =
ThreadLocal.withInitial(HashMap::new);
private static final ThreadLocal<List<TxSynchronization>> SYNCHRONIZATIONS =
ThreadLocal.withInitial(ArrayList::new);
private ResourceRegistry() {}
public static void bind(TxResourceKey key, Object value) {
RESOURCES.get().put(key, Objects.requireNonNull(value));
}
public static boolean isBound(TxResourceKey key) {
return RESOURCES.get().containsKey(key);
}
public static void unbind(TxResourceKey key) {
RESOURCES.get().remove(key);
}
public static void clear() {
RESOURCES.get().clear();
SYNCHRONIZATIONS.get().clear();
}
public static void cleanup() {
RESOURCES.remove();
SYNCHRONIZATIONS.remove();
}
public static <R> R get(TxResourceKey key, Class<R> type) {
Object value = RESOURCES.get().get(key);
if (value == null) {
throw new IllegalStateException("No resource bound for key: " + key);
}
return type.cast(value);
}
public static <R> R getOrNull(TxResourceKey key, Class<R> type) {
Object value = RESOURCES.get().get(key);
return value == null ? null : type.cast(value);
}
public static void addSynchronization(TxSynchronization sync) {
SYNCHRONIZATIONS.get().add(Objects.requireNonNull(sync));
}
/**
* How many synchronizations are registered right now — captured by a transaction manager when
* 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) {
if (outcome == TxOutcome.COMMITTED) {
sync.afterCommit();
} else {
sync.afterRollback();
}
sync.afterCompletion(outcome);
}
}
}
@@ -0,0 +1,26 @@
package dev.relism.flash.ext.data.core;
import java.util.ArrayList;
import java.util.List;
public record Sort(List<Column> columns) {
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 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 Sort then(String column) { return thenBy(column, true); }
public Sort thenDesc(String column) { return thenBy(column, false); }
private Sort thenBy(String column, boolean asc) {
List<Column> next = new ArrayList<>(columns);
next.add(new Column(column, asc));
return new Sort(next);
}
}
@@ -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);
}
@@ -0,0 +1,14 @@
package dev.relism.flash.ext.data.core;
public enum TransactionIsolation {
DEFAULT(-1),
READ_UNCOMMITTED(1),
READ_COMMITTED(2),
REPEATABLE_READ(4),
SERIALIZABLE(8);
private final int level;
TransactionIsolation(int level) { this.level = level; }
public int level() { return level; }
}
@@ -0,0 +1,9 @@
package dev.relism.flash.ext.data.core;
public enum TransactionPropagation {
REQUIRED,
REQUIRES_NEW,
SUPPORTS,
NOT_SUPPORTED,
MANDATORY
}
@@ -0,0 +1,116 @@
package dev.relism.flash.ext.data.core;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Objects;
public final class Tx {
private static final ThreadLocal<Deque<TxStatus>> STATUS_STACK =
ThreadLocal.withInitial(ArrayDeque::new);
private final TxManager manager;
public Tx(TxManager txManager) {
this.manager = Objects.requireNonNull(txManager);
}
public void run(TxRunnable work) {
run(TxDefinition.DEFAULTS, work);
}
public void run(TxDefinition definition, TxRunnable work) {
call(definition, () -> {
work.run();
return null;
});
}
public <T> T call(TxCallable<T> work) {
return call(TxDefinition.DEFAULTS, work);
}
public <T> T call(TxDefinition definition, TxCallable<T> work) {
TxStatus status = manager.begin(definition);
pushStatus(status);
try {
T result = work.call();
if (status.isRollbackOnly()) {
manager.rollback(status);
} else {
manager.commit(status);
}
return result;
} catch (Exception e) {
silentRollback(status);
throw (e instanceof TxException txException) ? txException : new TxException(e);
} catch (Throwable t) {
silentRollback(status);
throw sneakyThrow(t);
} finally {
popStatus();
if (STATUS_STACK.get().isEmpty()) {
STATUS_STACK.remove();
}
}
}
public boolean isActive() {
return !STATUS_STACK.get().isEmpty();
}
public void setRollbackOnly() {
currentStatus().markRollbackOnly();
}
public <R> R resource(Class<R> type) {
return currentStatus().resource(type);
}
public TxDefinition requiresNew() {
return TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW);
}
public TxDefinition readOnly() {
return TxDefinition.DEFAULTS.asReadOnly();
}
private TxStatus currentStatus() {
TxStatus status = STATUS_STACK.get().peek();
if (status == null) {
throw new IllegalStateException("No active transaction");
}
return status;
}
private void pushStatus(TxStatus status) {
STATUS_STACK.get().push(status);
}
private void popStatus() {
Deque<TxStatus> stack = STATUS_STACK.get();
if (!stack.isEmpty()) {
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
public interface TxRunnable {
void run();
}
@FunctionalInterface
public interface TxCallable<T> {
T call() throws Exception;
}
}
@@ -0,0 +1,31 @@
package dev.relism.flash.ext.data.core;
public record TxDefinition(
TransactionPropagation propagation,
TransactionIsolation isolation,
boolean readOnly,
String label
) {
public static final TxDefinition DEFAULTS = new TxDefinition(
TransactionPropagation.REQUIRED,
TransactionIsolation.DEFAULT,
false,
null
);
public TxDefinition withPropagation(TransactionPropagation p) {
return new TxDefinition(p, isolation, readOnly, label);
}
public TxDefinition withIsolation(TransactionIsolation i) {
return new TxDefinition(propagation, i, readOnly, label);
}
public TxDefinition withReadOnly(boolean ro) {
return new TxDefinition(propagation, isolation, ro, label);
}
public TxDefinition asReadOnly() {
return new TxDefinition(propagation, isolation, true, label);
}
}
@@ -0,0 +1,6 @@
package dev.relism.flash.ext.data.core;
public class TxException extends RuntimeException {
public TxException(String message) { super(message); }
public TxException(Throwable cause) { super(cause); }
}
@@ -0,0 +1,7 @@
package dev.relism.flash.ext.data.core;
public interface TxManager {
TxStatus begin(TxDefinition definition);
void commit(TxStatus status);
void rollback(TxStatus status);
}
@@ -0,0 +1,6 @@
package dev.relism.flash.ext.data.core;
public enum TxOutcome {
COMMITTED,
ROLLED_BACK
}
@@ -0,0 +1,32 @@
package dev.relism.flash.ext.data.core;
import java.util.Objects;
public final class TxResourceKey {
private final String name;
private TxResourceKey(String name) {
this.name = Objects.requireNonNull(name);
}
public static TxResourceKey of(String name) {
return new TxResourceKey(name);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof TxResourceKey that)) return false;
return name.equals(that.name);
}
@Override
public int hashCode() {
return name.hashCode();
}
@Override
public String toString() {
return name;
}
}
@@ -0,0 +1,9 @@
package dev.relism.flash.ext.data.core;
public interface TxStatus {
boolean isNewTransaction();
boolean isReadOnly();
boolean isRollbackOnly();
void markRollbackOnly();
<R> R resource(Class<R> type);
}
@@ -0,0 +1,58 @@
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 {
/**
* 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) {}
/** Runs after a successful commit, with the transaction's resource already unbound. */
default void afterCommit() {}
/** Runs after a rollback, with the transaction's resource already unbound. */
default void afterRollback() {}
/** Runs after {@link #afterCommit()}/{@link #afterRollback()}, whichever applied. */
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.
@@ -0,0 +1,71 @@
<?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-data-hibernate</artifactId>
<properties>
<jacoco.version>0.8.12</jacoco.version>
</properties>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-data-core</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash</artifactId>
</dependency>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco.version}</version>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -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);
}
}
@@ -0,0 +1,167 @@
package dev.relism.flash.ext.data.hibernate;
import dev.relism.flash.ext.data.core.*;
import jakarta.persistence.TypedQuery;
import org.hibernate.Session;
import org.hibernate.query.MutationQuery;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.stream.Collectors;
public abstract class HibernateRepository<T, ID extends Serializable> extends Repository<T, ID> {
private final Class<T> type;
protected HibernateRepository(Tx tx, Class<T> type) {
super(tx);
this.type = type;
}
protected Session session() {
return tx().resource(Session.class);
}
@Override
protected Optional<T> doFindById(ID id) {
return Optional.ofNullable(session().get(type, id));
}
@Override
protected List<T> doFind(Query<T> query) {
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
protected Optional<T> doFindOne(Spec<T> spec) {
return doFind(Query.<T>all().where(spec).page(0, 1)).stream().findFirst();
}
@Override
protected Page<T> doFindPage(Query<T> query) {
if (!query.isPaged()) {
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
protected boolean doExistsById(ID id) {
return doFindById(id).isPresent();
}
@Override
protected long doCount() {
return countWhere(Spec.all());
}
@Override
protected T doSave(T entity) {
session().persist(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
protected T doUpdate(T entity) {
return session().merge(entity);
}
@Override
protected void doDelete(T entity) {
Session s = session();
s.remove(s.contains(entity) ? entity : s.merge(entity));
}
@Override
protected void doDeleteById(ID id) {
doFindById(id).ifPresent(this::doDelete);
}
@Override
protected int doDeleteAll(Spec<T> spec) {
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
protected int doUpdateAll(Spec<T> spec, T patch) {
throw new UnsupportedOperationException("Override doUpdateAll() for bulk UPDATE support");
}
protected List<T> hql(String hql, Consumer<TypedQuery<T>> params) {
return roQuery(() -> {
TypedQuery<T> q = session().createQuery(hql, type);
params.accept(q);
return q.getResultList();
});
}
protected <R> List<R> hql(String hql, Class<R> resultType, Consumer<TypedQuery<R>> params) {
return roQuery(() -> {
TypedQuery<R> q = session().createQuery(hql, resultType);
params.accept(q);
return q.getResultList();
});
}
protected int hqlMutate(String hql, Consumer<MutationQuery> params) {
return rwQuery(() -> {
MutationQuery q = session().createMutationQuery(hql);
params.accept(q);
return q.executeUpdate();
});
}
protected Class<T> entityType() {
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) {
return sort.columns().stream()
.map(c -> c.column() + (c.asc() ? " ASC" : " DESC"))
.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);
}
}
@@ -0,0 +1,219 @@
package dev.relism.flash.ext.data.hibernate;
import dev.relism.flash.ext.data.core.*;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import java.util.Objects;
public class HibernateTxManager implements TxManager {
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;
public HibernateTxManager(SessionFactory sessionFactory) {
this.sf = Objects.requireNonNull(sessionFactory);
}
@Override
public TxStatus begin(TxDefinition definition) {
return switch (definition.propagation()) {
case REQUIRED -> ResourceRegistry.isBound(HIBERNATE_STATUS_KEY)
? joinExisting(definition)
: beginNew(definition);
case REQUIRES_NEW -> beginNew(definition);
case SUPPORTS -> ResourceRegistry.isBound(HIBERNATE_STATUS_KEY)
? joinExisting(definition)
: noOp(definition);
case MANDATORY -> {
if (!ResourceRegistry.isBound(HIBERNATE_STATUS_KEY))
throw new IllegalStateException("MANDATORY: no active transaction");
yield joinExisting(definition);
}
case NOT_SUPPORTED -> {
HibernateTxStatus suspended = suspendIfNeeded();
yield noOp(definition, suspended);
}
};
}
private TxStatus beginNew(TxDefinition definition) {
return beginNew(definition, suspendIfNeeded());
}
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();
boolean bound = false;
try {
s.beginTransaction();
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);
}
}
}
private TxStatus joinExisting(TxDefinition definition) {
HibernateTxStatus existing = ResourceRegistry.get(HIBERNATE_STATUS_KEY, HibernateTxStatus.class);
if (definition.readOnly() && !existing.isReadOnly()) {
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(
existing.session(),
false,
definition.readOnly(),
null,
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
public void commit(TxStatus status) {
HibernateTxStatus s = (HibernateTxStatus) status;
if (!s.isNewTransaction()) {
resumeIfNeeded(s);
cleanupIfIdle();
return;
}
TxOutcome outcome = null;
try {
if (s.isRollbackOnly() && s.session().getTransaction().isActive()) {
s.session().getTransaction().rollback();
outcome = TxOutcome.ROLLED_BACK;
} 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();
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 {
// 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);
if (outcome != null) ResourceRegistry.fireSynchronizations(outcome, s.synchronizationBaseline());
cleanupIfIdle();
}
}
@Override
public void rollback(TxStatus status) {
HibernateTxStatus s = (HibernateTxStatus) status;
if (!s.isNewTransaction()) {
s.markRollbackOnly();
resumeIfNeeded(s);
cleanupIfIdle();
return;
}
try {
if (s.session().getTransaction().isActive()) {
s.session().getTransaction().rollback();
}
} 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);
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK, s.synchronizationBaseline());
cleanupIfIdle();
}
}
private void cleanupAndResume(HibernateTxStatus status) {
ResourceRegistry.unbind(HIBERNATE_STATUS_KEY);
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();
if (suspended == null) {
suspended = ResourceRegistry.getOrNull(HIBERNATE_SUSPENDED_KEY, HibernateTxStatus.class);
}
if (suspended != null) {
ResourceRegistry.unbind(HIBERNATE_SUSPENDED_KEY);
ResourceRegistry.bind(HIBERNATE_STATUS_KEY, suspended);
}
}
private void silentClose(Session session) {
if (session == null) {
return;
}
try {
session.close();
} catch (Exception ignored) {
}
}
}
@@ -0,0 +1,53 @@
package dev.relism.flash.ext.data.hibernate;
import dev.relism.flash.ext.data.core.TxStatus;
import org.hibernate.Session;
class HibernateTxStatus implements TxStatus {
static final class RollbackMarker {
boolean rollbackOnly;
}
private final Session session;
private final boolean newTransaction;
private final boolean readOnly;
private final HibernateTxStatus suspended;
private final RollbackMarker rollbackMarker;
private final int synchronizationBaseline;
HibernateTxStatus(
Session session,
boolean newTransaction,
boolean readOnly,
HibernateTxStatus suspended,
RollbackMarker rollbackMarker,
int synchronizationBaseline
) {
this.session = session;
this.newTransaction = newTransaction;
this.readOnly = readOnly;
this.suspended = suspended;
this.rollbackMarker = rollbackMarker;
this.synchronizationBaseline = synchronizationBaseline;
}
@Override public boolean isNewTransaction() { return newTransaction; }
@Override public boolean isReadOnly() { return readOnly; }
@Override public boolean isRollbackOnly() { return rollbackMarker.rollbackOnly; }
@Override public void markRollbackOnly() { rollbackMarker.rollbackOnly = true; }
@Override
public <R> R resource(Class<R> type) {
if (session == null) {
throw new IllegalStateException("No session bound to this transaction status");
}
return type.cast(session);
}
Session session() { return session; }
HibernateTxStatus suspended() { return suspended; }
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);
}
}
@@ -0,0 +1,108 @@
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 static org.junit.jupiter.api.Assertions.*;
class HibernateTxManagerTest {
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();
}
@Test
void required_starts_new_when_absent() {
TxStatus s = manager.begin(TxDefinition.DEFAULTS);
assertNotNull(s.resource(Session.class));
assertTrue(s.isNewTransaction());
assertDoesNotThrow(() -> manager.commit(s));
}
@Test
void required_joins_existing_when_present() {
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
assertSame(outer.resource(Session.class), inner.resource(Session.class));
manager.rollback(outer);
}
@Test
void requires_new_uses_separate_session() {
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW));
assertNotSame(outer.resource(Session.class), inner.resource(Session.class));
manager.commit(inner);
manager.rollback(outer);
}
@Test
void rollback_on_joined_marks_outer_rollback_only() {
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
manager.rollback(inner);
assertTrue(outer.isRollbackOnly());
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,24 @@
package dev.relism.flash.ext.data.hibernate;
import org.hibernate.SessionFactory;
import org.hibernate.boot.MetadataSources;
import org.hibernate.boot.registry.StandardServiceRegistry;
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
public class TestHelper {
public static SessionFactory buildSessionFactory() {
final StandardServiceRegistry registry = new StandardServiceRegistryBuilder()
.applySetting("hibernate.connection.url", "jdbc:h2:mem:tx-hibernate;DB_CLOSE_DELAY=-1")
.applySetting("hibernate.connection.driver_class", "org.h2.Driver")
.applySetting("hibernate.dialect", "org.hibernate.dialect.H2Dialect")
.applySetting("hibernate.hbm2ddl.auto", "none")
.applySetting("hibernate.show_sql", "false")
.build();
try {
return new MetadataSources(registry).buildMetadata().buildSessionFactory();
} catch (Exception e) {
StandardServiceRegistryBuilder.destroy(registry);
throw e;
}
}
}
@@ -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.
@@ -0,0 +1,71 @@
<?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-data-jdbc</artifactId>
<properties>
<jacoco.version>0.8.12</jacoco.version>
</properties>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-data-core</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash</artifactId>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco.version}</version>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,201 @@
package dev.relism.flash.ext.data.jdbc;
import dev.relism.flash.ext.data.core.*;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public abstract class JdbcRepository<T, ID> extends Repository<T, ID> {
private final String table;
private final String idColumn;
protected JdbcRepository(Tx tx, String table, String idColumn) {
super(tx);
this.table = table;
this.idColumn = idColumn;
}
protected Connection connection() {
return tx().resource(Connection.class);
}
protected abstract T mapRow(ResultSet rs) 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 updateSql();
@Override
protected Optional<T> doFindById(ID id) {
return queryOne("select * from " + table + " where " + idColumn + " = ?", ps -> ps.setObject(1, id));
}
@Override
protected List<T> doFind(Query<T> query) {
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 ?" : "";
return queryMany("select * from " + table + where + order + paging, ps -> {
if (query.isPaged()) {
ctx.applyParameters(ps);
int base = ctx.size();
ps.setInt(base + 1, query.size());
ps.setInt(base + 2, query.page() * query.size());
return;
}
ctx.applyParameters(ps);
});
}
@Override
protected Optional<T> doFindOne(Spec<T> spec) {
return doFind(Query.<T>all().where(spec).page(0, 1)).stream().findFirst();
}
@Override
protected Page<T> doFindPage(Query<T> query) {
if (!query.isPaged()) {
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
protected boolean doExistsById(ID id) {
return queryOne("select 1 from " + table + " where " + idColumn + " = ?", ps -> ps.setObject(1, id), rs -> rs.getInt(1)).isPresent();
}
@Override
protected long doCount() {
return queryOne("select count(*) from " + table, ps -> {}, rs -> rs.getLong(1)).orElse(0L);
}
@Override
protected T doSave(T entity) {
try (PreparedStatement ps = connection().prepareStatement(insertSql(), Statement.RETURN_GENERATED_KEYS)) {
bindInsert(ps, entity);
ps.executeUpdate();
applyGeneratedKey(ps, entity);
return entity;
} 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
protected T doUpdate(T entity) {
try (PreparedStatement ps = connection().prepareStatement(updateSql())) {
bindUpdate(ps, entity);
ps.executeUpdate();
return entity;
} catch (SQLException e) {
throw new TxException(e);
}
}
@Override
protected void doDelete(T entity) {
throw new UnsupportedOperationException("Override doDelete() or use deleteById()");
}
@Override
protected void doDeleteById(ID id) {
mutate("delete from " + table + " where " + idColumn + " = ?", ps -> ps.setObject(1, id));
}
@Override
protected int doDeleteAll(Spec<T> spec) {
JdbcSpecContext ctx = new JdbcSpecContext();
String where = " where " + spec.toFragment(ctx);
return mutate("delete from " + table + where, ctx::applyParameters);
}
@Override
protected int doUpdateAll(Spec<T> spec, T patch) {
throw new UnsupportedOperationException("Override doUpdateAll() for bulk UPDATE support");
}
protected Optional<T> queryOne(String sql, SqlBinder params) {
List<T> r = queryMany(sql, params);
return r.isEmpty() ? Optional.empty() : Optional.of(r.get(0));
}
protected <R> Optional<R> queryOne(String sql, SqlBinder params, SqlMapper<R> mapper) {
try (PreparedStatement ps = connection().prepareStatement(sql)) {
params.bind(ps);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Optional.of(mapper.map(rs)) : Optional.empty();
}
} catch (SQLException e) {
throw new TxException(e);
}
}
protected List<T> queryMany(String sql, SqlBinder params) {
try (PreparedStatement ps = connection().prepareStatement(sql)) {
params.bind(ps);
try (ResultSet rs = ps.executeQuery()) {
List<T> results = new ArrayList<>();
while (rs.next()) results.add(mapRow(rs));
return results;
}
} catch (SQLException e) {
throw new TxException(e);
}
}
protected int mutate(String sql, SqlBinder params) {
try (PreparedStatement ps = connection().prepareStatement(sql)) {
params.bind(ps);
return ps.executeUpdate();
} catch (SQLException e) {
throw new TxException(e);
}
}
protected void applyGeneratedKey(PreparedStatement ps, T entity) throws SQLException {
// override when entity has a generated PK
}
protected Class<T> entityType() {
return null;
}
private long countWhere(Spec<T> spec) {
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();
}
}
@@ -0,0 +1,223 @@
package dev.relism.flash.ext.data.jdbc;
import dev.relism.flash.ext.data.core.*;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Objects;
public class JdbcTxManager implements TxManager {
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;
public JdbcTxManager(DataSource ds) {
this.ds = Objects.requireNonNull(ds);
}
@Override
public TxStatus begin(TxDefinition definition) {
return switch (definition.propagation()) {
case REQUIRED -> ResourceRegistry.isBound(JDBC_STATUS_KEY)
? joinExisting(definition)
: beginNew(definition);
case REQUIRES_NEW -> beginNew(definition);
case SUPPORTS -> ResourceRegistry.isBound(JDBC_STATUS_KEY)
? joinExisting(definition)
: noOp(definition);
case MANDATORY -> {
if (!ResourceRegistry.isBound(JDBC_STATUS_KEY))
throw new IllegalStateException("MANDATORY: no active transaction");
yield joinExisting(definition);
}
case NOT_SUPPORTED -> {
JdbcTxStatus suspended = suspendIfNeeded();
yield noOp(definition, suspended);
}
};
}
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 {
conn = ds.getConnection();
conn.setAutoCommit(false);
if (definition.readOnly()) conn.setReadOnly(true);
if (definition.isolation() != TransactionIsolation.DEFAULT) {
conn.setTransactionIsolation(definition.isolation().level());
}
JdbcTxStatus status = new JdbcTxStatus(
conn,
true,
definition.readOnly(),
suspended,
new JdbcTxStatus.RollbackMarker(),
synchronizationBaseline
);
ResourceRegistry.bind(JDBC_STATUS_KEY, status);
bound = true;
return status;
} catch (SQLException e) {
silentClose(conn);
throw new TxException(e);
} finally {
if (!bound && suspended != null) {
ResourceRegistry.unbind(JDBC_SUSPENDED_KEY);
ResourceRegistry.bind(JDBC_STATUS_KEY, suspended);
}
}
}
private TxStatus joinExisting(TxDefinition definition) {
JdbcTxStatus existing = ResourceRegistry.get(JDBC_STATUS_KEY, JdbcTxStatus.class);
if (definition.readOnly() && !existing.isReadOnly()) {
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(
existing.connection(),
false,
definition.readOnly(),
null,
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
public void commit(TxStatus status) {
JdbcTxStatus s = (JdbcTxStatus) status;
if (!s.isNewTransaction()) {
resumeIfNeeded(s);
cleanupIfIdle();
return;
}
TxOutcome outcome = null;
try {
if (s.isRollbackOnly()) {
s.connection().rollback();
outcome = TxOutcome.ROLLED_BACK;
} 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;
}
} catch (SQLException e) {
TxException wrapped = new TxException(e);
outcome = rollbackAfterFailedCommit(s, wrapped);
throw wrapped;
} catch (RuntimeException e) {
outcome = rollbackAfterFailedCommit(s, e);
throw e;
} 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);
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
public void rollback(TxStatus status) {
JdbcTxStatus s = (JdbcTxStatus) status;
if (!s.isNewTransaction()) {
s.markRollbackOnly();
resumeIfNeeded(s);
cleanupIfIdle();
return;
}
try {
s.connection().rollback();
} catch (SQLException e) {
throw new TxException(e);
} finally {
// Same order as commit() above, for the same two reasons.
cleanupAndResume(s);
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK, s.synchronizationBaseline());
cleanupIfIdle();
}
}
private void cleanupAndResume(JdbcTxStatus status) {
ResourceRegistry.unbind(JDBC_STATUS_KEY);
try {
if (status.connection() != null) {
status.connection().close();
}
} 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();
if (suspended == null) {
suspended = ResourceRegistry.getOrNull(JDBC_SUSPENDED_KEY, JdbcTxStatus.class);
}
if (suspended != null) {
ResourceRegistry.unbind(JDBC_SUSPENDED_KEY);
ResourceRegistry.bind(JDBC_STATUS_KEY, suspended);
}
}
private void silentClose(Connection connection) {
if (connection == null) {
return;
}
try {
connection.close();
} catch (SQLException ignored) {
}
}
}
@@ -0,0 +1,59 @@
package dev.relism.flash.ext.data.jdbc;
import dev.relism.flash.ext.data.core.TxStatus;
import java.sql.Connection;
class JdbcTxStatus implements TxStatus {
static final class RollbackMarker {
boolean rollbackOnly;
}
private final Connection connection;
private final boolean newTransaction;
private final boolean readOnly;
private final JdbcTxStatus suspended;
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(
Connection connection,
boolean newTransaction,
boolean readOnly,
JdbcTxStatus suspended,
RollbackMarker rollbackMarker,
int synchronizationBaseline
) {
this.connection = connection;
this.newTransaction = newTransaction;
this.readOnly = readOnly;
this.suspended = suspended;
this.rollbackMarker = rollbackMarker;
this.synchronizationBaseline = synchronizationBaseline;
}
@Override public boolean isNewTransaction() { return newTransaction; }
@Override public boolean isReadOnly() { return readOnly; }
@Override public boolean isRollbackOnly() { return rollbackMarker.rollbackOnly; }
@Override public void markRollbackOnly() { rollbackMarker.rollbackOnly = true; }
@Override
public <R> R resource(Class<R> type) {
if (connection == null) {
throw new IllegalStateException("No connection bound to this transaction status");
}
return type.cast(connection);
}
Connection connection() { return connection; }
JdbcTxStatus suspended() { return suspended; }
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);
}
}
@@ -0,0 +1,87 @@
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 static org.junit.jupiter.api.Assertions.*;
class JdbcTxManagerTest {
private final JdbcTxManager manager = new JdbcTxManager(new TestDataSource());
@AfterEach
void cleanup() {
ResourceRegistry.clear();
}
@Test
void required_starts_new_when_absent() {
TxStatus s = manager.begin(TxDefinition.DEFAULTS);
assertTrue(s.isNewTransaction());
assertDoesNotThrow(() -> manager.commit(s));
}
@Test
void required_joins_existing_when_present() {
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
assertSame(outer.resource(Connection.class), inner.resource(Connection.class));
assertFalse(inner.isNewTransaction());
manager.rollback(outer);
}
@Test
void requires_new_creates_distinct_connection() {
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW));
assertNotSame(outer.resource(Connection.class), inner.resource(Connection.class));
manager.commit(inner);
manager.rollback(outer);
}
@Test
void rollback_on_joined_marks_outer_rollback_only() {
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
manager.rollback(inner);
assertTrue(outer.isRollbackOnly());
manager.rollback(outer);
}
/**
* SUPPORTS without an active transaction yields a connectionless status — it must not be a
* transaction, and asking it for a connection must say so rather than NPE. Both propagations
* that produce one used to throw {@link NullPointerException} straight out of {@code begin()}.
*/
@Test
void supports_without_active_transaction_is_a_connectionless_no_op() {
TxStatus s = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.SUPPORTS));
assertFalse(s.isNewTransaction());
assertThrows(IllegalStateException.class, () -> s.resource(Connection.class));
assertDoesNotThrow(() -> manager.commit(s));
}
@Test
void not_supported_suspends_the_active_transaction_and_restores_it_on_commit() {
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
Connection outerConnection = outer.resource(Connection.class);
TxStatus suspended = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.NOT_SUPPORTED));
assertFalse(suspended.isNewTransaction());
assertThrows(IllegalStateException.class, () -> suspended.resource(Connection.class));
manager.commit(suspended);
TxStatus rejoined = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
assertSame(outerConnection, rejoined.resource(Connection.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)));
}
}
@@ -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(); }
}
@@ -0,0 +1,116 @@
# flash-ext-jackson
Jackson JSON integration for Flash with an opinionated auto-marshal middleware.
## What it provides
| Component | Description |
|---|---|
| `JacksonExtension` | Registers JSON services into `FlashContext` |
| `Json` | JSON read/write helper (`body`, `bodyFrom`, `write`, `writeView`) |
| `ObjectMapper` | Raw mapper escape hatch for advanced usage |
| `JacksonMiddleware` | `autoJson()` middleware for automatic outbound JSON marshalling |
Default mapper behavior (`new JacksonExtension()`):
- auto-discovers Jackson modules on classpath (`findAndAddModules()`)
- includes Java Time support (`jackson-datatype-jsr310`)
- writes date/time values as ISO-8601 strings (not numeric timestamps)
## Recommended default
Install the extension, then apply `autoJson()` once at app or scope level.
```java
JacksonExtension jackson = new JacksonExtension();
FlashApp app = FlashApp.create(8080)
.install(jackson)
.use(jackson.autoJson());
app.startAndBlock();
```
Behavior of `autoJson()`:
- pass-through: `null`, `Response`, `byte[]`, `String`, `CharSequence`
- any other return value: serialize to JSON `byte[]`
- sets `Content-Type: application/json` for marshalled responses
- serialization failures throw `IllegalStateException`
This keeps handlers concise while preserving Flash's direct byte write path.
## Installation
```xml
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-jackson</artifactId>
<version>1.1-indev2</version>
</dependency>
```
## Json helper API
Use `Json` when you want explicit, local control in a handler.
```java
@POST("/users")
public final class CreateUser extends RequestHandler {
private Json json;
@Override
protected void onInit() {
json = require(Json.class);
}
@Override
public Object handle(Request req, Response res) throws Exception {
CreateUserBody body = json.body(req, CreateUserBody.class);
UserDto created = service.create(body);
res.status(201);
return json.write(res, created);
}
}
```
Methods:
- `body(req, Type.class)` -> parse from `req.body().bytes()`
- `bodyFrom(req, Type.class)` -> parse from `req.body().stream()`
- `write(res, obj)` -> writes JSON string and sets JSON content type
- `writeView(res, obj, View.class)` -> JSON with Jackson `@JsonView`
- `mapper()` -> raw `ObjectMapper`
## Custom mapper
```java
ObjectMapper mapper = JsonMapper.builder()
.addModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build();
FlashApp.create(8080)
.install(new JacksonExtension(mapper));
```
## Scope usage
`autoJson()` works the same at scope level:
```java
JacksonExtension jackson = new JacksonExtension();
app.mount("/api", api -> {
api.use(jackson.autoJson());
api.get("/health", (req, res) -> Map.of("ok", true));
});
```
If you need to pull it from context, `JacksonMiddleware` is also provided as a service
after the app boots (same lifecycle model as other extension-provided services).
## Notes
- Install order is irrelevant (Flash two-phase extension lifecycle).
- `autoJson()` and OpenAPI are intentionally decoupled.
@@ -0,0 +1,82 @@
<?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-jackson</artifactId>
<properties>
<jacoco.version>0.8.12</jacoco.version>
</properties>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco.version}</version>
<executions>
<execution>
<id>jacoco-prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>jacoco-report-and-check</id>
<phase>verify</phase>
<goals>
<goal>report</goal>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,94 @@
package dev.relism.flash.ext.jackson;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.routing.Middleware;
/**
* Registers JSON support into the Flash extension layer.
*
* <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)}
* 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}
* for extensions that need direct mapper access (e.g. OpenAPI schema generation).
*
* <p>{@link JacksonMiddleware} is provided under {@code JacksonMiddleware.class} and
* exposes opinionated JSON auto-marshalling middleware via {@link JacksonMiddleware#autoJson()}.
*
* <h3>Usage — composition (preferred)</h3>
* <pre>{@code
* public class MyHandler extends RequestHandler {
* private Json json;
*
* @Override protected void onInit() {
* json = require(Json.class);
* }
*
* public Object handle(Request req, Response res) throws Exception {
* MyDto dto = json.body(req, MyDto.class);
* return json.write(res, 201, dto);
* }
* }
* }</pre>
*
* <h3>Custom mapper</h3>
* <pre>{@code
* ObjectMapper mapper = JsonMapper.builder()
* .addModule(new JavaTimeModule())
* .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
* .build();
*
* FlashApp.create(8080)
* .install(new JacksonExtension(mapper));
* }</pre>
*/
public class JacksonExtension implements FlashExtension {
private final ObjectMapper mapper;
private final JacksonMiddleware middleware;
/**
* Installs with an opinionated default {@link JsonMapper}:
* auto-discovers modules on classpath (e.g. Java Time) and writes dates as ISO strings.
*/
public JacksonExtension() {
this(JsonMapper.builder()
.findAndAddModules()
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build());
}
/** Installs with a fully configured custom {@link ObjectMapper}. */
public JacksonExtension(ObjectMapper mapper) {
this.mapper = mapper;
this.middleware = new JacksonMiddleware(mapper);
}
/**
* Opinionated outbound JSON middleware factory.
*
* <p>Use for app/scope-level registration:
* <pre>{@code
* JacksonExtension jackson = new JacksonExtension();
* app.install(jackson).use(jackson.autoJson());
* }</pre>
*/
public Middleware autoJson() {
return middleware.autoJson();
}
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
Json json = new Json(mapper);
ctx.provide(Json.class, json);
ctx.provide(ObjectMapper.class, mapper);
ctx.provide(JacksonMiddleware.class, middleware);
}
}
@@ -0,0 +1,62 @@
package dev.relism.flash.ext.jackson;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.models.Response;
import dev.relism.flash.routing.Middleware;
/**
* Outbound JSON marshalling middleware for class-based and lambda routes.
*
* <p>{@link #autoJson()} marshals any non-body-native return value to JSON bytes,
* writes {@code Content-Type: application/json}, and returns {@code byte[]} so
* the Flash write path stays direct.
*
* <p>Pass-through return types:
* <ul>
* <li>{@code null}</li>
* <li>{@link Response}</li>
* <li>{@code byte[]}</li>
* <li>{@link String}</li>
* <li>{@link CharSequence}</li>
* </ul>
*/
public final class JacksonMiddleware {
private final ObjectMapper mapper;
JacksonMiddleware(ObjectMapper mapper) {
this.mapper = mapper;
}
/**
* Automatic JSON marshalling policy.
*
* <p>For non-pass-through return values, serializes with Jackson directly to
* {@code byte[]} and sets response content type to JSON.
*
* @throws IllegalStateException when serialization fails
*/
public Middleware autoJson() {
return next -> (req, res) -> {
Object out = next.handle(req, res);
if (isPassThrough(out)) return out;
res.type(ContentType.JSON);
try {
return mapper.writeValueAsBytes(out);
} catch (JsonProcessingException e) {
throw new IllegalStateException(
"Failed to serialize handler result as JSON: " + out.getClass().getName(), e);
}
};
}
private static boolean isPassThrough(Object out) {
return out == null
|| out instanceof Response
|| out instanceof byte[]
|| out instanceof CharSequence;
}
}
@@ -0,0 +1,117 @@
package dev.relism.flash.ext.jackson;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.Response;
/**
* Thread-safe JSON toolbox. Single point of access for all JSON I/O operations
* within a Flash application.
*
* <p>Retrieve once at boot time via {@code require(Json.class)} inside
* {@code onInit()}, cache in a private field, and call on the hot path
* with zero lookup or allocation overhead:
*
* <pre>{@code
* @Route(method = HttpMethod.POST, path = "/api/items")
* public class CreateItemHandler extends RequestHandler {
*
* private Json json;
*
* @Override
* protected void onInit() {
* json = require(Json.class);
* }
*
* public Object handle(Request req, Response res) throws Exception {
* CreateItemRequest body = json.body(req, CreateItemRequest.class);
* return json.write(res, itemService.create(body));
* }
* }
* }</pre>
*
* <p>The underlying {@link ObjectMapper} is shared across all handlers in the same
* scope (one instance per app / per child scope). Jackson's {@code ObjectMapper}
* is fully thread-safe after configuration — no synchronization is needed.
*
* <p>Install via {@link JacksonExtension} before calling {@code scan()} or
* {@code register()}.
*/
public final class Json {
private final ObjectMapper mapper;
/** Package-private — constructed exclusively by {@link JacksonExtension}. */
Json(ObjectMapper mapper) {
this.mapper = mapper;
}
// ── Input ─────────────────────────────────────────────────────────────────
/**
* Deserializes the full request body into an instance of {@code type}.
*
* <p>Reads {@code req.body().bytes()} in one shot. For streaming bodies
* use {@link #bodyFrom(Request, Class)} instead.
*
* @throws HttpException 400 if the body cannot be parsed as {@code type}
*/
public <T> T body(Request req, Class<T> type) throws Exception {
try {
return mapper.readValue(req.body().bytes(), type);
} catch (JsonProcessingException e) {
throw HttpException.badRequest("Invalid request body: " + e.getOriginalMessage());
}
}
/**
* Deserializes the request body via the raw {@link java.io.InputStream},
* avoiding the intermediate {@code byte[]} allocation. Prefer this for
* large bodies or when allocation budget is tight.
*
* @throws HttpException 400 on parse failure
*/
public <T> T bodyFrom(Request req, Class<T> type) throws Exception {
try {
return mapper.readValue(req.body().stream(), type);
} catch (JsonProcessingException e) {
throw HttpException.badRequest("Invalid request body: " + e.getOriginalMessage());
}
}
// ── Output ────────────────────────────────────────────────────────────────
/**
* Serializes {@code obj} to a JSON string and sets
* {@code Content-Type: application/json} on the response.
*
* <p>The returned string is used as the response body by the Flash runtime.
*/
public String write(Response res, Object obj) throws Exception {
res.type(ContentType.JSON);
return mapper.writeValueAsString(obj);
}
/**
* Like {@link #write} but applies a Jackson {@code @JsonView} filter,
* restricting serialization to fields visible under {@code view}.
*/
public String writeView(Response res, Object obj, Class<?> view) throws Exception {
res.type(ContentType.JSON);
return mapper.writerWithView(view).writeValueAsString(obj);
}
// ── Escape hatch ──────────────────────────────────────────────────────────
/**
* Returns the underlying {@link ObjectMapper} for advanced operations
* (custom serialization, schema generation, etc.) not covered by the
* methods above.
*/
public ObjectMapper mapper() {
return mapper;
}
}
@@ -0,0 +1,62 @@
package dev.relism.flash.ext.jackson;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.models.SimpleHandler;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.Response;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
class JacksonExtensionTest {
@Test
void configure_registers_json_mapper_and_middleware() {
FlashContext ctx = new FlashContext();
ObjectMapper mapper = new ObjectMapper();
JacksonExtension ext = new JacksonExtension(mapper);
ext.configure(null, ctx);
ctx.complete();
assertNotNull(ctx.require(Json.class));
assertNotNull(ctx.require(JacksonMiddleware.class));
assertSame(mapper, ctx.require(ObjectMapper.class));
}
@Test
void autoJson_factory_delegates_to_middleware_policy() throws Exception {
ObjectMapper mapper = new ObjectMapper();
JacksonExtension ext = new JacksonExtension(mapper);
RequestHandler next = new RequestHandler() {
@Override
public Object handle(Request request, Response response) {
return new Payload("ok");
}
};
RequestHandler wrapped = new RequestHandler() {
private final SimpleHandler.FunctionalHandler delegate = ext.autoJson().wrap(next);
@Override
public Object handle(Request request, Response response) throws Exception {
return delegate.handle(request, response);
}
};
Response res = new Response(200, ContentType.TEXT_PLAIN);
Object out = wrapped.handle(null, res);
assertTrue(out instanceof byte[]);
assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
}
private record Payload(String status) {}
}
@@ -0,0 +1,90 @@
package dev.relism.flash.ext.jackson;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.flash.models.SimpleHandler;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.Response;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class JacksonMiddlewareTest {
private static final Request REQ = null;
@Test
void autoJson_marshalsPojo_toJsonBytes_and_setsJsonContentType() throws Exception {
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
RequestHandler wrapped = wrap(mw, new UserDto("u1", "alice"));
Response res = new Response(200, ContentType.TEXT_PLAIN);
Object out = wrapped.handle(REQ, res);
assertInstanceOf(byte[].class, out);
assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
assertEquals("{\"id\":\"u1\",\"name\":\"alice\"}", new String((byte[]) out, StandardCharsets.UTF_8));
}
@Test
void autoJson_passThrough_for_response_string_charSequence_bytes_and_null() throws Exception {
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
Response payloadResponse = new Response(201, ContentType.TEXT_PLAIN).body("ok");
RequestHandler wrappedResponse = wrap(mw, payloadResponse);
Response res = new Response(200, ContentType.TEXT_PLAIN);
assertSame(payloadResponse, wrappedResponse.handle(REQ, res));
String s = "hello";
assertSame(s, wrap(mw, s).handle(REQ, res));
CharSequence cs = new StringBuilder("hello-cs");
assertSame(cs, wrap(mw, cs).handle(REQ, res));
byte[] bytes = new byte[]{1, 2, 3};
assertSame(bytes, wrap(mw, bytes).handle(REQ, res));
assertSame(null, wrap(mw, null).handle(REQ, res));
}
@Test
void autoJson_wraps_serialization_errors_as_illegal_state() {
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
RequestHandler wrapped = wrap(mw, new CyclicDto());
Response res = new Response(200, ContentType.TEXT_PLAIN);
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> wrapped.handle(REQ, res));
assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
assertTrue(ex.getMessage().startsWith("Failed to serialize handler result as JSON:"));
}
private static RequestHandler wrap(JacksonMiddleware mw, Object fixedReturn) {
RequestHandler next = new RequestHandler() {
@Override
public Object handle(Request request, Response response) {
return fixedReturn;
}
};
return new RequestHandler() {
private final SimpleHandler.FunctionalHandler delegate = mw.autoJson().wrap(next);
@Override
public Object handle(Request request, Response response) throws Exception {
return delegate.handle(request, response);
}
};
}
private record UserDto(String id, String name) {}
private static final class CyclicDto {
CyclicDto self = this;
}
}
@@ -0,0 +1,116 @@
package dev.relism.flash.ext.jackson;
import com.fasterxml.jackson.annotation.JsonView;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.HeaderMap;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestLine;
import dev.relism.flash.models.Response;
import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class JsonTest {
@Test
void body_parses_valid_json_and_maps_bad_payload_to_http_400() throws Exception {
Json json = new Json(new ObjectMapper());
Request ok = request("{\"id\":\"u1\",\"name\":\"alice\"}");
UserDto dto = json.body(ok, UserDto.class);
assertEquals("u1", dto.id);
assertEquals("alice", dto.name);
Request bad = request("not-json");
HttpException ex = assertThrows(HttpException.class, () -> json.body(bad, UserDto.class));
assertEquals(400, ex.status());
assertTrue(ex.getMessage().startsWith("Invalid request body:"));
}
@Test
void bodyFrom_parses_stream_and_maps_bad_payload_to_http_400() throws Exception {
Json json = new Json(new ObjectMapper());
Request ok = request("{\"id\":\"u2\",\"name\":\"bob\"}");
UserDto dto = json.bodyFrom(ok, UserDto.class);
assertEquals("u2", dto.id);
assertEquals("bob", dto.name);
Request bad = request("[");
HttpException ex = assertThrows(HttpException.class, () -> json.bodyFrom(bad, UserDto.class));
assertEquals(400, ex.status());
}
@Test
void write_and_writeView_set_content_type_and_render_expected_payload() throws Exception {
Json json = new Json(new ObjectMapper());
Response res = new Response(200, ContentType.TEXT_PLAIN);
String payload = json.write(res, new UserDto("u3", "carol"));
assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
assertEquals("{\"id\":\"u3\",\"name\":\"carol\"}", payload);
Response viewRes = new Response(200, ContentType.TEXT_PLAIN);
String viewed = json.writeView(viewRes, new ViewDto("u4", "hidden"), PublicView.class);
assertEquals("application/json", new String(viewRes.getContentType(), StandardCharsets.UTF_8));
assertEquals("{\"id\":\"u4\"}", viewed);
}
@Test
void mapper_returns_underlying_object_mapper_instance() {
ObjectMapper mapper = new ObjectMapper();
Json json = new Json(mapper);
assertSame(mapper, json.mapper());
}
private static Request request(String body) {
return request(body.getBytes(StandardCharsets.UTF_8));
}
private static Request request(byte[] body) {
RequestLine line = new RequestLine(
HttpMethod.POST,
new FastPathViews.StringByteView("/json"),
null,
new FastPathViews.StringByteView("HTTP/1.1"),
new HeaderMap()
);
return new Request(line, body);
}
private static final class UserDto {
public String id;
public String name;
public UserDto() {}
private UserDto(String id, String name) {
this.id = id;
this.name = name;
}
}
private interface PublicView {}
private interface InternalView {}
private static final class ViewDto {
@JsonView(PublicView.class)
public String id;
@JsonView(InternalView.class)
public String secret;
private ViewDto(String id, String secret) {
this.id = id;
this.secret = secret;
}
}
}
@@ -0,0 +1,65 @@
# flash-ext-limiter
Rate limiting for the Flash HTTP server. Zero-allocation hot-path, lock-free counters,
pluggable key resolvers, and two built-in algorithms.
## What it provides
| Component | Description |
|---|---|
| `@Limit` | Annotation for class-based handlers — processed once at boot |
| `Guard` | Programmatic middleware factory for lambda routes |
| `LimiterConfig` | Resolver registry — map string names to key-extraction lambdas |
| `FIXED_WINDOW` | Clock-aligned counter reset; minimal memory |
| `TOKEN_BUCKET` | Continuous refill; absorbs bursts smoothly |
## Dependency
```xml
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-limiter</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
```
## Quick start
```java
// Default install — only the built-in "ip" resolver available
FlashApp.create(8080)
.install(new LimiterExtension())
.scan("com.example.handlers");
```
```java
// With custom resolvers
LimiterConfig conf = new LimiterConfig()
.registerResolver("auth_user", req ->
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous");
FlashApp.create(8080)
.install(new LimiterExtension(conf))
.scan("com.example.handlers");
```
## Installation order
Install `LimiterExtension` **before** authentication extensions. Rate-limit checks
then short-circuit over-limit requests before expensive token validation runs.
```java
app.install(new LimiterExtension(conf)) // ← first
.install(new OidcExtension(oidcConf)) // ← second
.scan("com.example");
```
## Docs
| File | Contents |
|---|---|
| [key-resolvers.md](key-resolvers.md) | Resolver registration, built-in defaults, custom logic |
| [annotation.md](annotation.md) | `@Limit` reference — all fields and examples |
| [guard.md](guard.md) | `Guard` for lambda routes — all overloads |
| [strategies.md](strategies.md) | `FIXED_WINDOW` vs `TOKEN_BUCKET` — algorithm reference |
| [http-headers.md](http-headers.md) | HTTP compliance — headers and 429 response |
@@ -0,0 +1,135 @@
# @Limit annotation
Applies a rate limit to a **class-based** `RequestHandler`. The annotation is read once
per handler class at boot by the `LimiterExtension` annotation processor — zero overhead
at request time.
## Declaration
```java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Limit {
String key() default "ip";
int requests();
long window();
TimeUnit windowUnit() default TimeUnit.SECONDS;
LimitStrategy strategy() default LimitStrategy.FIXED_WINDOW;
}
```
## Fields
| Field | Type | Default | Description |
|---|---|---|---|
| `key` | `String` | `"ip"` | Name of the key resolver registered in `LimiterConfig` |
| `requests` | `int` | — | Maximum requests allowed per window (required) |
| `window` | `long` | — | Window duration in `windowUnit` units (required) |
| `windowUnit` | `TimeUnit` | `SECONDS` | Time unit for `window` |
| `strategy` | `LimitStrategy` | `FIXED_WINDOW` | Rate-limit algorithm |
## Basic usage
### 100 requests per second per IP (default)
```java
@Route(method = HttpMethod.GET, path = "/api/search")
@Limit(requests = 100, window = 1)
public class SearchHandler extends RequestHandler {
public Object handle(Request req, Response res) {
return searchService.query(req.query("q"));
}
}
```
### 20 requests per minute per authenticated user
```java
@Route(method = HttpMethod.POST, path = "/api/report")
@Limit(key = "auth_user", requests = 20, window = 1, windowUnit = TimeUnit.MINUTES)
@Authenticated
public class ReportHandler extends RequestHandler {
public Object handle(Request req, Response res) { ... }
}
```
Order of annotation processors: register `LimiterExtension` before `OidcExtension`
so the rate-limit middleware wraps the outer layer of the chain and fires before auth.
### Token bucket — absorb bursts
```java
@Route(method = HttpMethod.POST, path = "/api/upload")
@Limit(
key = "api_key",
requests = 50,
window = 1,
windowUnit = TimeUnit.MINUTES,
strategy = LimitStrategy.TOKEN_BUCKET
)
public class UploadHandler extends RequestHandler { ... }
```
### Large window — 1000 requests per hour
```java
@Route(method = HttpMethod.GET, path = "/api/export")
@Limit(requests = 1000, window = 1, windowUnit = TimeUnit.HOURS)
public class ExportHandler extends RequestHandler { ... }
```
### Strict per-second limit on a public endpoint
```java
@Route(method = HttpMethod.GET, path = "/api/prices")
@Limit(requests = 10, window = 1, windowUnit = TimeUnit.SECONDS)
public class PriceHandler extends RequestHandler { ... }
```
## Combining @Limit with other annotations
`@Limit` composes naturally with `@Authenticated`, `@RolesAllowed`, and `@ApiOperation`.
Each annotation is processed by its own processor; Flash collects all middleware and
composes them in processor registration order.
When `flash-ext-openapi` is installed, `@Limit` also contributes OpenAPI response
headers (`X-RateLimit-*`) and `Retry-After` on `429` automatically.
```java
@Route(method = HttpMethod.DELETE, path = "/admin/users/{id}")
@Limit(key = "auth_user", requests = 5, window = 1, windowUnit = TimeUnit.MINUTES)
@RolesAllowed("admin")
@ApiOperation(summary = "Delete a user", tags = "admin")
public class DeleteUserHandler extends RequestHandler { ... }
```
Execution chain (outermost → handler):
`LimiterMiddleware → OidcRolesMiddleware → DeleteUserHandler`
## Fail-fast at boot
If `key` names a resolver not registered in `LimiterConfig`, the server refuses to start:
```
dev.relism.exceptions.InitializationException:
Rate-limit resolver "auth_user" is not registered.
Call LimiterConfig.registerResolver("auth_user", req -> ...) before install.
```
There is no silent fallback — a misconfigured rate limit is treated as a hard error.
## What happens on violation
```
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1711750860
Retry-After: 1
Content-Type: text/plain
Too Many Requests
```
The handler body is never invoked. See [http-headers.md](http-headers.md) for the full
header reference.
@@ -0,0 +1,144 @@
# Guard — programmatic rate limiting for lambda routes
`Guard` is the rate-limit API for lambda (inline) route registrations. It produces
a `Middleware` that is composed once at route-wiring time — the resolver lambda is
captured directly into the closure, with no map lookup on the request hot-path.
## Obtaining Guard
`Guard` is provided in the `FlashContext` after `LimiterExtension` is installed:
```java
Guard guard = app.ctx().require(Guard.class);
```
Or inside another extension:
```java
public void install(FlashRegistrar app, FlashContext ctx) {
Guard guard = ctx.require(Guard.class);
// ...
}
```
## API
```java
// Fixed window (default strategy)
Middleware limit(String resolverKey, int requests, long window, TimeUnit unit)
// Explicit strategy
Middleware limit(String resolverKey, int requests, long window, TimeUnit unit, LimitStrategy strategy)
```
Both overloads:
- Resolve the named key lambda **once** at call time (fail-fast if unknown).
- Return a stateless `Middleware` whose closure captures the lambda and `LimitConfig` directly.
- Share the `BucketStore` with all other rules registered through this `LimiterExtension` instance.
## Examples
### Simple per-IP limit on a lambda route
```java
Guard guard = app.ctx().require(Guard.class);
app.get("/api/search", (req, res) -> searchService.query(req.query("q")))
.with(guard.limit("ip", 100, 1, TimeUnit.SECONDS));
```
### Per authenticated user — token bucket
```java
app.post("/api/export", (req, res) -> exportService.run(req))
.with(guard.limit("auth_user", 10, 1, TimeUnit.MINUTES, LimitStrategy.TOKEN_BUCKET));
```
### Chaining with other middleware
`Guard.limit(...)` returns a plain `Middleware`, so it composes with `Middleware.of()`
and `.andThen()` exactly like any other middleware:
```java
Middleware secured = Middleware.of(
guard.limit("ip", 200, 1, TimeUnit.SECONDS), // ← outermost: runs first
oidc.protect()
);
app.get("/dashboard", handler).with(secured);
```
Or with `.andThen()` for two middlewares:
```java
app.get("/dashboard", handler)
.with(guard.limit("ip", 200, 1, TimeUnit.SECONDS).andThen(oidc.protect()));
```
### Different limits on the same path by method
```java
// Read: 500/s; Write: 20/s
app.get("/api/items", readHandler) .with(guard.limit("ip", 500, 1, TimeUnit.SECONDS));
app.post("/api/items", writeHandler).with(guard.limit("ip", 20, 1, TimeUnit.SECONDS));
```
Each `.with(guard.limit(...))` call creates an independent bucket store key namespace —
GET and POST requests to `/api/items` share the same IP bucket only if you share the same
`Middleware` instance. Using two `guard.limit(...)` calls creates **two independent buckets**.
### Reusing a middleware instance across routes
To share a single bucket pool across multiple routes (treating them as one combined limit):
```java
Middleware sharedIpLimit = guard.limit("ip", 1000, 1, TimeUnit.MINUTES);
app.get("/api/items", handler1).with(sharedIpLimit);
app.get("/api/items/{id}", handler2).with(sharedIpLimit);
app.post("/api/items", handler3).with(sharedIpLimit);
```
All three routes now draw from the same per-IP bucket — 1000 combined requests per minute.
### Inside an extension
```java
public class MyApiExtension implements FlashExtension {
public void install(FlashRegistrar app, FlashContext ctx) {
Guard guard = ctx.require(Guard.class); // LimiterExtension must be installed first
Middleware ipLimit = guard.limit("ip", 60, 1, TimeUnit.SECONDS);
app.get("/api/status", statusHandler) .with(ipLimit);
app.get("/api/metrics", metricsHandler).with(ipLimit);
}
}
```
### Large window
```java
app.get("/api/export", exportHandler)
.with(guard.limit("api_key", 50, 24, TimeUnit.HOURS));
```
## Fail-fast
If the resolver name is not registered, `guard.limit(...)` throws immediately
(at wiring time, not at request time):
```
InitializationException: Rate-limit resolver "auth_user" is not registered.
```
## Comparison: Guard vs @Limit
| | `@Limit` | `Guard.limit(...)` |
|---|---|---|
| Route style | Class-based `RequestHandler` | Lambda `(req, res) -> ...` |
| Configuration | Annotation fields | Method arguments |
| Where resolved | `AnnotationProcessor` at `scan()` | `guard.limit(...)` call at wiring |
| Hot-path overhead | Zero | Zero |
| Fail-fast | Yes | Yes |
| Composable with `Middleware.of()` | Via annotation processor order | Yes, directly |
@@ -0,0 +1,128 @@
# HTTP headers and 429 response
The extension injects standard rate-limit headers on **every** request — both allowed
and rejected. Clients can use these headers to implement back-off logic without waiting
for a 429.
## Response headers
| Header | Type | Description |
|---|---|---|
| `X-RateLimit-Limit` | integer | Maximum requests allowed in the current window |
| `X-RateLimit-Remaining` | integer | Requests remaining in the current window (≥ 0) |
| `X-RateLimit-Reset` | Unix timestamp (s) | When the quota resets or the next token arrives |
| `Retry-After` | seconds | **Only on 429** — how long to wait before retrying (≥ 1) |
### Example — allowed request
```
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 73
X-RateLimit-Reset: 1711750860
Content-Type: application/json
```
### Example — rejected request (429)
```
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1711750860
Retry-After: 1
Content-Type: text/plain
Too Many Requests
```
## Header semantics by strategy
### FIXED_WINDOW
| Header | Value |
|---|---|
| `X-RateLimit-Reset` | Unix timestamp of the **next window start** (aligned to clock) |
| `Retry-After` | Seconds until `X-RateLimit-Reset` (minimum 1) |
At a 1-second window boundary `Retry-After` will typically be `1`.
### TOKEN_BUCKET
| Header | Value |
|---|---|
| `X-RateLimit-Remaining` | Current token count (may increase between requests due to refill) |
| `X-RateLimit-Reset` | Estimated Unix timestamp when the **next token arrives** |
| `Retry-After` | Milliseconds-precise estimate converted to seconds (minimum 1) |
Because the token bucket refills continuously, `X-RateLimit-Reset` is a near-future
timestamp rather than an aligned window boundary.
## Retry-After precision
`Retry-After` is computed as:
```
retryAfter = max(1, X-RateLimit-Reset - currentTimeSeconds)
```
The minimum value is always `1` second — RFC 7231 discourages `Retry-After: 0` as it
encourages instant retry loops.
## Client-side back-off example (Java)
```java
HttpResponse<String> res = client.send(request, BodyHandlers.ofString());
if (res.statusCode() == 429) {
String retryAfter = res.headers().firstValue("Retry-After").orElse("1");
long waitMs = Long.parseLong(retryAfter) * 1000L;
Thread.sleep(waitMs);
// retry...
}
```
## Client-side back-off example (JavaScript fetch)
```js
const res = await fetch('/api/search?q=flash');
if (res.status === 429) {
const retryAfter = parseInt(res.headers.get('Retry-After') ?? '1', 10);
await new Promise(r => setTimeout(r, retryAfter * 1000));
// retry...
}
```
## Monitoring / alerting
`X-RateLimit-Remaining` can be scraped by a metrics agent to track approaching limits
before they hit 429:
- `remaining / limit < 0.1` → warning (less than 10% quota left)
- `status == 429` → rate-limit violation counter increment
If `flash-ext-limiter` is used together with a future metrics extension, the 429 rate
per resolver key is a natural signal for abuse detection or auto-scaling.
## Header injection timing
Headers are injected **before** calling `next.handle(req, res)` on allowed requests,
and **instead of** calling it on rejected requests. This means:
- Handlers cannot accidentally overwrite `X-RateLimit-*` headers (they are set first,
but handlers that call `res.header(...)` with the same name will add a second value —
avoid this by not setting these headers manually).
- On 429, the handler body is never executed — no side effects occur.
## Integration with Swagger UI (flash-ext-openapi)
When `flash-ext-openapi` is installed, handlers annotated with `@Limit` automatically
contribute rate-limit response headers to generated OpenAPI responses:
- `X-RateLimit-Limit`
- `X-RateLimit-Remaining`
- `X-RateLimit-Reset`
- `Retry-After` on `429`
If `429` is not manually declared, OpenAPI auto-adds `429 Too Many Requests`.
@@ -0,0 +1,143 @@
# Key Resolvers
A **key resolver** is a lambda `Request → String` that extracts the partition key used
to identify who a rate limit applies to. Each unique key value gets its own independent
bucket — so `"ip"` limits per client address, `"auth_user"` limits per logged-in user, etc.
## Built-in resolver: `"ip"`
Always present. Cannot be removed; can be overridden with `registerResolver("ip", ...)`.
Resolution order:
1. `X-Forwarded-For` header — first address in the comma-separated list (client behind proxy)
2. `X-Real-IP` header — single forwarded IP (nginx `proxy_set_header X-Real-IP`)
3. `req.remoteAddress().getAddress().getHostAddress()` — direct socket address, zero allocation
(the `InetSocketAddress` already exists from `ServerSocket.accept()`; only `getHostAddress()`
allocates a String, and only when the first two headers are absent)
4. `"unknown"` — only if `remoteAddress()` is null (test-constructed requests)
```java
// Override the built-in "ip" resolver to trust only the last hop in X-Forwarded-For
conf.registerResolver("ip", req -> {
String xff = req.header("X-Forwarded-For");
if (xff != null) {
String[] parts = xff.split(",");
return parts[parts.length - 1].strip(); // last = most recent proxy
}
return req.header("X-Real-IP") != null ? req.header("X-Real-IP").strip() : "unknown";
});
```
## Registering custom resolvers
```java
LimiterConfig conf = new LimiterConfig();
```
### By authenticated user (OIDC / ClaimsHolder)
```java
conf.registerResolver("auth_user", req ->
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous");
```
Requests from unauthenticated users share the `"anonymous"` bucket. If you want
unauthenticated requests to be unlimited, pair this resolver with `@Limit` only on
handlers that are already protected by `@Authenticated`.
### By API key header
```java
conf.registerResolver("api_key", req -> {
String key = req.header("X-Api-Key");
return key != null ? key : "none";
});
```
### By tenant (multi-tenant SaaS)
```java
conf.registerResolver("tenant", req -> {
// Extract from subdomain: acme.api.example.com → "acme"
String host = req.header("Host");
if (host == null) return "unknown";
int dot = host.indexOf('.');
return dot > 0 ? host.substring(0, dot) : host;
});
```
### By IP + path (per-endpoint per-IP)
Combines two dimensions into a single key string:
```java
conf.registerResolver("ip_path", req -> {
String ip = req.header("X-Forwarded-For");
if (ip == null) ip = "unknown";
int comma = ip.indexOf(',');
if (comma > 0) ip = ip.substring(0, comma).strip();
return ip + "|" + req.path();
});
```
### Composite: role-based bucket size
One resolver, two different `@Limit` thresholds on two handler classes. The resolver
returns the same key for the same user regardless of endpoint; the limit is set per handler.
```java
conf.registerResolver("auth_user", req ->
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anon");
```
```java
@Limit(key = "auth_user", requests = 1000, window = 1) // privileged endpoint
public class AdminReportHandler extends RequestHandler { ... }
@Limit(key = "auth_user", requests = 20, window = 1) // public endpoint
public class PublicSearchHandler extends RequestHandler { ... }
```
The two handlers maintain **independent buckets** for the same user — each `@Limit`
annotation gets its own `BucketStore`.
## Resolver contract
```java
@FunctionalInterface
public interface KeyResolver {
String resolve(Request req); // must never return null; return "unknown" as fallback
}
```
- Must not return `null` — a null key will throw `NullPointerException` inside `ConcurrentHashMap`.
- Must be **thread-safe** — called concurrently from virtual threads.
- Should be **fast** — it runs on every request for every rate-limited route.
- No state should be mutated — treat `Request` as read-only.
## Fail-fast validation
If a `@Limit` annotation or `guard.limit(...)` call references a resolver name that was never
registered, the server **refuses to start** with `InitializationException`:
```
InitializationException: Rate-limit resolver "auth_user" is not registered.
Call LimiterConfig.registerResolver("auth_user", req -> ...) before install.
```
This check happens at boot time (annotation processor / Guard wiring), not at request time.
## Registration API
```java
LimiterConfig conf = new LimiterConfig()
.registerResolver("auth_user", req -> ...)
.registerResolver("tenant", req -> ...)
.registerResolver("api_key", req -> ...);
app.install(new LimiterExtension(conf));
```
`registerResolver` returns `this` for fluent chaining. Calling it with an existing name
**replaces** the previous resolver — this is how you override the built-in `"ip"` resolver.
@@ -0,0 +1,178 @@
# Rate-limit strategies
Two algorithms are built in. Both are lock-free (CAS-only), operate on pre-allocated
`Bucket` state, and write results into a caller-supplied `long[2]` — zero per-request allocation.
## FIXED_WINDOW
```java
@Limit(strategy = LimitStrategy.FIXED_WINDOW, ...) // default, can be omitted
guard.limit("ip", 100, 1, TimeUnit.SECONDS) // default
```
### How it works
The request counter resets to zero at each clock-aligned window boundary.
```
window 1 window 2 window 3
|────────────────|────────────────|────────────────|
cnt: 0 1 2 … N cnt: 0 1 2 … N cnt: 0 1 2 … N
```
With `requests = 100, window = 1s`:
- Requests 1100 in a given second → allowed
- Request 101+ in that second → 429, allowed again at second +1
### Implementation
All state is packed into a single `AtomicLong` (`Bucket.slot0`):
```
high 32 bits = reduced epoch = (currentTimeMs / windowMs) & 0xFFFFFFFF
low 32 bits = request count in the current window
```
One CAS operation per request. At a window boundary the same CAS atomically resets the
counter to 1. No locks, no additional fields.
### Burst behaviour
Because the window is fixed to the clock, a burst can occur at the boundary:
up to `N` requests at the end of window 1 followed immediately by `N` requests at the
start of window 2 → `2N` requests in a short interval.
```
window 1 │ window 2
────────────┼────────────
99 100 101 │ 1 2 3 4
↑ reset: 101 → 429, then 1 is allowed
```
If burst tolerance is unacceptable, use `TOKEN_BUCKET`.
### When to use
- Simple API rate limiting where occasional boundary bursts are acceptable.
- Scenarios where a hard "N requests per clock second/minute" guarantee matters.
- When you want minimal per-bucket memory (one `AtomicLong`, `Bucket.slot1` unused).
---
## TOKEN_BUCKET
```java
@Limit(strategy = LimitStrategy.TOKEN_BUCKET, ...)
guard.limit("ip", 100, 1, TimeUnit.SECONDS, LimitStrategy.TOKEN_BUCKET)
```
### How it works
The bucket holds up to `requests` tokens and refills at a continuous rate of
`requests / window` tokens per millisecond. Each request consumes one token.
A client that was idle accumulates tokens and can fire a burst, but sustained
excess traffic drains the bucket and triggers 429s.
```
tokens
N ─┐ ┌──── refill slope ────┐
│ │ │
0 └───────────┘ ←─ burst consumed ──→│
burst here 429s during drain recovery
```
### Refill rate
`refillPerMs = (requests × 1000) / windowMs` (integer, minimum 1)
For `requests = 100, window = 1s`:
- Refill rate: 100 tokens/s = 1 token/10 ms
- Max capacity: 100 tokens
- A client idle for 500 ms accumulates 50 tokens and can fire 50 requests instantly.
### Implementation
- `Bucket.slot0` — current tokens × 1000 (fixed-point, avoids floating-point math)
- `Bucket.slot1` — last-refill timestamp in ms (0 = uninitialised → bucket starts full)
One CAS loop on `slot0` per request; `slot1` updated best-effort after CAS success.
The bounded inaccuracy from the non-atomic dual update is at most a few nanoseconds —
negligible and self-correcting for rate limiting.
### Bucket starts full
On the very first request, `slot1 == 0`. The strategy treats this as "one full window
elapsed" → `currentTokens = max`. The bucket starts at capacity; no warm-up needed.
### When to use
- APIs where clients legitimately batch requests (analytics, bulk imports).
- Endpoints where smooth throughput matters more than hard per-second guarantees.
- Any scenario where `FIXED_WINDOW` boundary bursts would be problematic.
---
## Comparison
| | `FIXED_WINDOW` | `TOKEN_BUCKET` |
|---|---|---|
| Algorithm | Aligned counter reset | Continuous token refill |
| Burst handling | Allows 2× limit at boundaries | Absorbs bursts up to bucket capacity |
| Memory per bucket | 1 × `AtomicLong` used | 2 × `AtomicLong` used |
| Clock alignment | Yes (predictable resets) | No (smooth) |
| Typical use case | Simple request quotas | APIs with legitimate burst patterns |
| CAS operations per request | 1 (usually) | 1 (usually) |
Both strategies use the same `Bucket` type. Both are lock-free and allocation-free after
the bucket is first created.
---
## Adding a custom strategy
Implement `RateLimitStrategy` and wrap it in a `LimitStrategy` enum constant:
```java
// 1. Implement the strategy
public final class SlidingWindowStrategy implements RateLimitStrategy {
@Override
public boolean check(Bucket bucket, LimitConfig cfg, long[] out) {
// ... lock-free implementation using bucket.slot0 / slot1
return allowed;
}
}
// 2. Add to the enum
public enum LimitStrategy {
FIXED_WINDOW { ... },
TOKEN_BUCKET { ... },
SLIDING_WINDOW {
@Override
public RateLimitStrategy create() { return new SlidingWindowStrategy(); }
};
public abstract RateLimitStrategy create();
}
```
The new strategy is immediately available to `@Limit(strategy = LimitStrategy.SLIDING_WINDOW)`
and `guard.limit("ip", 100, 1, SECONDS, LimitStrategy.SLIDING_WINDOW)`.
### Strategy contract
```java
public interface RateLimitStrategy {
/**
* @param bucket pre-allocated per-key state (never null)
* @param cfg immutable rule config (limit, windowMs)
* @param out out[0] = remaining, out[1] = reset epoch-seconds
* @return true = allowed, false = rejected (429)
*/
boolean check(Bucket bucket, LimitConfig cfg, long[] out);
}
```
Requirements for custom implementations:
- **Lock-free** — use `AtomicLong.compareAndSet`; no `synchronized` or `ReentrantLock`.
- **Stateless** — all mutable state must live in `Bucket.slot0` / `Bucket.slot1`.
- **No allocation** — `out[]` is the only output channel; do not create objects on the hot path.
- **Thread-safe** — called concurrently from many virtual threads.
@@ -0,0 +1,35 @@
<?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-limiter</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-openapi</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,24 @@
package dev.relism.flash.ext.limiter;
import java.util.concurrent.atomic.AtomicLong;
/**
* Pre-allocated per-key rate limit state. Holds two {@link AtomicLong} slots whose
* semantics are strategy-specific:
*
* <ul>
* <li><b>FIXED_WINDOW</b>: {@code slot0} = packed {@code (epoch << 32 | count)},
* {@code slot1} unused.</li>
* <li><b>SLIDING_WINDOW</b>: {@code slot0} = packed {@code (epoch << 32 | count)},
* {@code slot1} = request count from the immediately preceding epoch.</li>
* <li><b>TOKEN_BUCKET</b>: {@code slot0} = tokens × 1000 (scaled),
* {@code slot1} = last-refill timestamp (ms since epoch).</li>
* </ul>
*
* <p>Buckets are created once per unique key (via {@link BucketStore}) and reused
* for the lifetime of the server — zero allocation on the warm path.
*/
public final class Bucket {
public final AtomicLong slot0 = new AtomicLong(0L);
public final AtomicLong slot1 = new AtomicLong(0L);
}
@@ -0,0 +1,28 @@
package dev.relism.flash.ext.limiter;
import java.util.concurrent.ConcurrentHashMap;
/**
* Thread-safe store of pre-allocated {@link Bucket} instances keyed by partition key.
*
* <p>On the warm path (key already seen), {@link #get} performs a single
* {@link ConcurrentHashMap} lookup — no allocation. On the cold path (new key),
* {@code computeIfAbsent} allocates exactly one {@link Bucket} and inserts it.
*
* <p>Buckets accumulate indefinitely; for workloads with unbounded unique keys
* (e.g. one-shot crawlers), consider periodic store replacement or a bounded
* LRU map implementation.
*/
public final class BucketStore {
private final ConcurrentHashMap<String, Bucket> map = new ConcurrentHashMap<>();
/**
* Returns the bucket for {@code key}, creating one if absent.
* Two threads racing on the same new key are guaranteed to receive the same bucket instance.
*/
public Bucket get(String key) {
Bucket b = map.get(key);
return b != null ? b : map.computeIfAbsent(key, k -> new Bucket());
}
}
@@ -0,0 +1,69 @@
package dev.relism.flash.ext.limiter;
import dev.relism.flash.exceptions.InitializationException;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.routing.Middleware;
import java.util.concurrent.TimeUnit;
/**
* Manual rate-limit guard for lambda routes.
*
* <p>Available via {@link FlashContext}:
* <pre>{@code
* Guard guard = ctx.require(Guard.class);
* }</pre>
*
* <p>{@link #limit} creates a {@link Middleware} that is composed once at route registration
* time — the resolver lambda is captured directly from the registry (no runtime map lookup):
* <pre>{@code
* // 50 req/s per IP — fixed window (default)
* app.get("/api/search", handler, guard.limit("ip", 50, 1, TimeUnit.SECONDS));
*
* // 10 req/min per authenticated user — token bucket
* app.post("/api/export", handler, guard.limit("auth_user", 10, 1, TimeUnit.MINUTES, LimitStrategy.TOKEN_BUCKET));
* }</pre>
*
* <p>The resolver name is looked up once here (at wiring time, not on each request).
* If the name is not registered, {@link InitializationException}
* is thrown immediately.
*/
public final class Guard {
private final LimiterConfig config;
private final BucketStore store;
Guard(LimiterConfig config, BucketStore store) {
this.config = config;
this.store = store;
}
/**
* Returns a {@link Middleware} that enforces the given rate limit using
* {@link LimitStrategy#FIXED_WINDOW}.
*
* @param resolverKey name registered via {@link LimiterConfig#registerResolver}
* @param requests maximum requests allowed per window
* @param window window duration in {@code unit}
* @param unit time unit for {@code window}
*/
public Middleware limit(String resolverKey, int requests, long window, TimeUnit unit) {
return limit(resolverKey, requests, window, unit, LimitStrategy.FIXED_WINDOW);
}
/**
* Returns a {@link Middleware} that enforces the given rate limit with the specified strategy.
*
* @param resolverKey name registered via {@link LimiterConfig#registerResolver}
* @param requests maximum requests allowed per window
* @param window window duration in {@code unit}
* @param unit time unit for {@code window}
* @param strategy rate-limit algorithm
*/
public Middleware limit(String resolverKey, int requests, long window, TimeUnit unit, LimitStrategy strategy) {
// Fail-fast: resolve the lambda at wiring time, not at request time.
KeyResolver resolver = config.requireResolver(resolverKey);
LimitConfig cfg = new LimitConfig(requests, unit.toMillis(window), strategy.create());
return LimiterExtension.buildMiddleware(resolver, cfg, store);
}
}
@@ -0,0 +1,20 @@
package dev.relism.flash.ext.limiter;
import dev.relism.flash.models.Request;
/**
* Extracts a partition key from an incoming request.
*
* <p>The resolved key identifies who the rate limit applies to — an IP address,
* an authenticated user ID, an API key, etc. Implementations are captured once
* at route registration time and called directly (no registry lookup) on every request.
*
* <pre>{@code
* conf.registerResolver("ip", req -> req.header("X-Forwarded-For"));
* conf.registerResolver("auth_user", req -> ClaimsHolder.user().sub());
* }</pre>
*/
@FunctionalInterface
public interface KeyResolver {
String resolve(Request req);
}
@@ -0,0 +1,48 @@
package dev.relism.flash.ext.limiter;
import dev.relism.flash.exceptions.InitializationException;
import dev.relism.flash.models.RequestHandler;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;
/**
* Applies a rate limit to a class-based {@link RequestHandler}.
*
* <p>The annotation is processed at boot time by the {@link LimiterExtension} annotation
* processor. If {@link #key()} names a resolver that was never registered,
* startup fails immediately with {@link InitializationException}.
*
* <pre>{@code
* // 100 req/s per client IP — fixed window
* @Limit(requests = 100, window = 1)
* public class SearchHandler extends RequestHandler { ... }
*
* // 20 req/min per authenticated user — token bucket
* @Limit(key = "auth_user", requests = 20, window = 1, windowUnit = TimeUnit.MINUTES,
* strategy = LimitStrategy.TOKEN_BUCKET)
* public class ExpensiveHandler extends RequestHandler { ... }
* }</pre>
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Limit {
/** Name of the resolver registered via {@link LimiterConfig#registerResolver}. Default: {@code "ip"}. */
String key() default "ip";
/** Maximum number of requests allowed per {@link #window()}. */
int requests();
/** Window duration in {@link #windowUnit()} units. */
long window();
/** Unit for {@link #window()}. Default: {@link TimeUnit#SECONDS}. */
TimeUnit windowUnit() default TimeUnit.SECONDS;
/** Rate-limit algorithm. Default: {@link LimitStrategy#FIXED_WINDOW}. */
LimitStrategy strategy() default LimitStrategy.FIXED_WINDOW;
}
@@ -0,0 +1,11 @@
package dev.relism.flash.ext.limiter;
/**
* Immutable configuration snapshot for a single rate-limit rule.
* Created once at boot time and captured directly in the middleware closure.
*
* @param limit Maximum allowed requests per window.
* @param windowMs Window duration in milliseconds.
* @param strategy Strategy instance bound to this rule (one per rule, not shared).
*/
public record LimitConfig(int limit, long windowMs, RateLimitStrategy strategy) {}
@@ -0,0 +1,58 @@
package dev.relism.flash.ext.limiter;
import dev.relism.flash.ext.limiter.strategy.FixedWindowStrategy;
import dev.relism.flash.ext.limiter.strategy.SlidingWindowStrategy;
import dev.relism.flash.ext.limiter.strategy.TokenBucketStrategy;
/**
* Enumeration of built-in rate-limit algorithms. Each constant is a factory
* for its corresponding {@link RateLimitStrategy} implementation.
*
* <p>New algorithms can be added here without touching the rest of the extension.
* The enum value is referenced by {@link Limit#strategy()} so user code refers
* to the algorithm by name ({@code LimitStrategy.FIXED_WINDOW}) rather than
* instantiating strategy objects directly.
*
* <pre>{@code
* @Limit(key = "ip", requests = 100, window = 1, strategy = LimitStrategy.TOKEN_BUCKET)
* public class SearchHandler extends RequestHandler { ... }
* }</pre>
*/
public enum LimitStrategy {
/**
* Fixed-window counter: resets to zero at each clock-aligned window boundary.
* Simple, minimal memory, but allows up to 2× the limit in bursts that straddle
* two windows.
*/
FIXED_WINDOW {
@Override
public RateLimitStrategy create() { return new FixedWindowStrategy(); }
},
/**
* Token-bucket: tokens refill continuously. Smooth burst absorption — a client
* that was idle accumulates tokens and can fire a short burst, but sustained
* excess traffic is rejected. Preferred for API endpoints where occasional bursts
* are legitimate.
*/
TOKEN_BUCKET {
@Override
public RateLimitStrategy create() { return new TokenBucketStrategy(); }
},
/**
* Sliding-window counter: interpolates between the previous window's count and the
* current window's count weighted by how far into the current window we are.
* Eliminates the boundary burst of {@link #FIXED_WINDOW} while remaining O(1)
* memory and lock-free. Slight approximation — worst-case error ≈ a few percent at
* window boundaries.
*/
SLIDING_WINDOW {
@Override
public RateLimitStrategy create() { return new SlidingWindowStrategy(); }
};
/** Creates a fresh, stateless {@link RateLimitStrategy} instance for this algorithm. */
public abstract RateLimitStrategy create();
}
@@ -0,0 +1,82 @@
package dev.relism.flash.ext.limiter;
import dev.relism.flash.exceptions.InitializationException;
import java.net.InetSocketAddress;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Extension configuration: holds the named {@link KeyResolver} registry.
*
* <p>Resolvers are registered during the <em>config phase</em> (before {@code install}).
* After {@link LimiterExtension#install} is called, the registry is consulted once per
* route/handler at boot to capture the resolver lambda directly into the middleware closure.
* There is no map lookup on the request hot-path.
*
* <p>The built-in {@code "ip"} resolver is always present and extracts the client IP from
* {@code X-Forwarded-For} (first address) or {@code X-Real-IP}. Override it with
* {@code registerResolver("ip", ...)} if needed.
*
* <pre>{@code
* LimiterConfig conf = new LimiterConfig()
* .registerResolver("auth_user", req -> {
* // custom logic — e.g. extract sub from ClaimsHolder
* return ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous";
* });
*
* app.install(new LimiterExtension(conf));
* }</pre>
*/
public final class LimiterConfig {
private final Map<String, KeyResolver> resolvers = new LinkedHashMap<>();
public LimiterConfig() {
// Built-in mandatory "ip" resolver.
// Resolution order (standard reverse-proxy chain):
// 1. X-Forwarded-For — first address (client behind one or more proxies)
// 2. X-Real-IP — single forwarded IP (nginx proxy_set_header X-Real-IP)
// 3. Socket address — direct connection, no proxy headers (zero alloc: the
// InetSocketAddress already exists from accept(); only
// getHostAddress() allocates a String, and only when reached)
resolvers.put("ip", req -> {
String xff = req.header("X-Forwarded-For");
if (xff != null) {
int comma = xff.indexOf(',');
return comma > 0 ? xff.substring(0, comma).strip() : xff.strip();
}
String xri = req.header("X-Real-IP");
if (xri != null) return xri.strip();
InetSocketAddress addr = req.remoteAddress();
return addr != null ? addr.getAddress().getHostAddress() : "unknown";
});
}
/**
* Registers (or replaces) a named key resolver. Returns {@code this} for fluent chaining.
*
* @param name identifier referenced by {@link Limit#key()} and {@link Guard#limit}
* @param resolver lambda that extracts the partition key from a request
*/
public LimiterConfig registerResolver(String name, KeyResolver resolver) {
if (name == null || name.isBlank()) throw new IllegalArgumentException("Resolver name must not be blank");
if (resolver == null) throw new IllegalArgumentException("Resolver must not be null");
resolvers.put(name, resolver);
return this;
}
/**
* Returns the resolver for {@code name}.
*
* @throws InitializationException if no resolver with that name has been registered —
* checked at boot time so misconfigurations surface immediately.
*/
KeyResolver requireResolver(String name) {
KeyResolver r = resolvers.get(name);
if (r == null) throw new InitializationException(
"Rate-limit resolver \"" + name + "\" is not registered. " +
"Call LimiterConfig.registerResolver(\"" + name + "\", req -> ...) before install.");
return r;
}
}
@@ -0,0 +1,192 @@
package dev.relism.flash.ext.limiter;
import dev.relism.flash.ext.openapi.OpenApiContributor;
import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
import dev.relism.flash.extension.AnnotationProcessor;
import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.http.HttpStatus;
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.util.List;
import java.util.Map;
/**
* Rate-limiting extension for Flash.
*
* <p>At {@link #provide}:
* <ol>
* <li>Creates a single {@link BucketStore} shared by all rules in this extension instance.</li>
* <li>Provides a {@link Guard} in the {@link FlashContext} for manual use on lambda routes.</li>
* <li>Registers an {@link AnnotationProcessor} for {@link Limit}:
* reads the annotation once per handler class at boot, resolves the key lambda
* fail-fast, then returns a pre-compiled middleware — zero map lookups at request time.</li>
* </ol>
*
* <h3>Annotation-based (class handlers)</h3>
* <pre>{@code
* @Limit(requests = 100, window = 1) // 100 req/s per IP
* public class SearchHandler extends RequestHandler { ... }
*
* @Limit(key = "auth_user", requests = 20, window = 1,
* windowUnit = TimeUnit.MINUTES,
* strategy = LimitStrategy.TOKEN_BUCKET)
* public class ReportHandler extends RequestHandler { ... }
* }</pre>
*
* <h3>Lambda routes (via Guard)</h3>
* <pre>{@code
* app.install(new LimiterExtension(
* new LimiterConfig().registerResolver("auth_user", req -> ClaimsHolder.user().sub())));
*
* // inside a FlashContext.onReady(...) callback:
* Guard guard = ctx.require(Guard.class);
* app.get("/api/search", handler, guard.limit("ip", 50, 1, TimeUnit.SECONDS));
* }</pre>
*/
public final class LimiterExtension implements FlashExtension {
private static final MiddlewareKey LIMIT = MiddlewareKey.of("flash.limiter.limit");
private final LimiterConfig config;
/** Installs with default config (only the built-in {@code "ip"} resolver). */
public LimiterExtension() {
this(new LimiterConfig());
}
/** Installs with a custom {@link LimiterConfig} (custom resolvers, etc.). */
public LimiterExtension(LimiterConfig config) {
this.config = config;
}
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
BucketStore store = new BucketStore();
Guard guard = new Guard(config, store);
ctx.provide(Guard.class, guard);
ctx.provide(LimiterConfig.class, config);
// Annotation processor: runs once per class-based handler at boot.
ctx.addAnnotationProcessor(handlerClass -> {
Limit ann = handlerClass.getAnnotation(Limit.class);
if (ann == null) return List.of();
// Fail-fast: if the key is unknown the server refuses to start.
KeyResolver resolver = config.requireResolver(ann.key());
LimitConfig cfg = new LimitConfig(
ann.requests(),
ann.windowUnit().toMillis(ann.window()),
ann.strategy().create()
);
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
}
});
}
// ── Package-private helper — shared with Guard ────────────────────────────
/**
* Builds the rate-limit {@link Middleware} from an already-resolved resolver lambda.
*
* <p>Hot-path design:
* <ul>
* <li>{@code resolver} is captured directly in the closure — no registry lookup per request.</li>
* <li>{@code resultBuf} is a per-{@link Middleware}-instance ThreadLocal {@code long[2]}.
* Allocated once per thread, reused forever — zero per-request allocation.</li>
* <li>The static {@code X-RateLimit-Limit} header is pre-encoded at boot — zero-alloc.</li>
* </ul>
*/
static Middleware buildMiddleware(KeyResolver resolver, LimitConfig cfg, BucketStore store) {
ThreadLocal<long[]> resultBuf = ThreadLocal.withInitial(() -> new long[2]);
byte[] limitHeader = ("X-RateLimit-Limit: " + cfg.limit() + "\r\n")
.getBytes(StandardCharsets.UTF_8);
return next -> (req, res) -> {
String key = resolver.resolve(req);
Bucket bucket = store.get(key);
long[] out = resultBuf.get();
boolean allowed = cfg.strategy().check(bucket, cfg, out);
res.header(limitHeader);
res.header("X-RateLimit-Remaining", String.valueOf(out[0]));
res.header("X-RateLimit-Reset", String.valueOf(out[1]));
if (!allowed) {
long retryAfter = Math.max(1L, out[1] - System.currentTimeMillis() / 1000L);
res.status(HttpStatus.TOO_MANY_REQUESTS)
.header("Retry-After", String.valueOf(retryAfter));
return "Too Many Requests";
}
return next.handle(req, res);
};
}
private static final class OpenApiIntegration {
private static final Map<String, Object> INTEGER_SCHEMA = Map.of("type", "integer");
private static final Map<String, Object> LIMIT_HEADER = Map.of(
"description", "Maximum requests allowed in current window",
"schema", INTEGER_SCHEMA
);
private static final Map<String, Object> REMAINING_HEADER = Map.of(
"description", "Requests remaining in current window",
"schema", INTEGER_SCHEMA
);
private static final Map<String, Object> RESET_HEADER = Map.of(
"description", "Unix epoch seconds when quota resets or next token arrives",
"schema", INTEGER_SCHEMA
);
private static final Map<String, Object> RETRY_AFTER_HEADER = Map.of(
"description", "Seconds to wait before retrying",
"schema", INTEGER_SCHEMA
);
static void register(FlashContext ctx) {
ctx.find(OpenApiContributorRegistry.class)
.ifPresent(registry -> registry.add(new OpenApiContributor() {
@Override
public OpenApiOperationContribution operationFor(Class<?> handlerClass) {
if (handlerClass.getAnnotation(Limit.class) == null) {
return OpenApiOperationContribution.builder().build();
}
OpenApiResponseContribution common =
OpenApiResponseContribution.builder()
.header("X-RateLimit-Limit", LIMIT_HEADER)
.header("X-RateLimit-Remaining", REMAINING_HEADER)
.header("X-RateLimit-Reset", RESET_HEADER)
.build();
OpenApiResponseContribution tooManyRequests =
OpenApiResponseContribution.builder()
.description("Too Many Requests")
.header("X-RateLimit-Limit", LIMIT_HEADER)
.header("X-RateLimit-Remaining", REMAINING_HEADER)
.header("X-RateLimit-Reset", RESET_HEADER)
.header("Retry-After", RETRY_AFTER_HEADER)
.build();
return OpenApiOperationContribution.builder()
.allResponses(common)
.response(429, tooManyRequests)
.build();
}
}));
}
}
}
@@ -0,0 +1,39 @@
package dev.relism.flash.ext.limiter;
import dev.relism.flash.ext.limiter.strategy.FixedWindowStrategy;
import dev.relism.flash.ext.limiter.strategy.SlidingWindowStrategy;
import dev.relism.flash.ext.limiter.strategy.TokenBucketStrategy;
/**
* Contract for a rate-limit algorithm. Implementations must be:
* <ul>
* <li><b>Lock-free</b> — rely only on {@link java.util.concurrent.atomic.AtomicLong} CAS operations.</li>
* <li><b>Stateless</b> — all mutable state lives in the {@link Bucket}; the strategy itself
* holds no instance fields so the same object can be shared across threads and rules.</li>
* </ul>
*
* <p>Called on every request — must not allocate on the hot path.
*
* @see FixedWindowStrategy
* @see SlidingWindowStrategy
* @see TokenBucketStrategy
*/
public interface RateLimitStrategy {
/**
* Checks whether this request is within the limit and updates the bucket atomically.
*
* <p>On return, {@code out} contains:
* <ul>
* <li>{@code out[0]} — remaining allowed requests in the current window (≥ 0).</li>
* <li>{@code out[1]} — Unix epoch seconds at which the quota resets (for {@code X-RateLimit-Reset}
* and {@code Retry-After} headers).</li>
* </ul>
*
* @param bucket per-key state carrier (pre-allocated, never null)
* @param cfg immutable rule configuration
* @param out caller-supplied two-element array; values are overwritten on every call
* @return {@code true} if the request is within the limit and should proceed
*/
boolean check(Bucket bucket, LimitConfig cfg, long[] out);
}
@@ -0,0 +1,54 @@
package dev.relism.flash.ext.limiter.strategy;
import dev.relism.flash.ext.limiter.Bucket;
import dev.relism.flash.ext.limiter.LimitConfig;
import dev.relism.flash.ext.limiter.RateLimitStrategy;
/**
* Fixed-window rate limit: allows up to {@link LimitConfig#limit()} requests per window of
* {@link LimitConfig#windowMs()} milliseconds. The window is aligned to clock time
* (e.g. 10:00:00 10:00:59 for a 60-second window), not sliding.
*
* <h3>Implementation</h3>
* The entire state fits in a single {@link java.util.concurrent.atomic.AtomicLong}
* ({@link Bucket#slot0}), packed as:
* <pre>
* high 32 bits = reduced epoch (currentTimeMs / windowMs) & 0xFFFFFFFFL
* low 32 bits = request count in the current window
* </pre>
* Each request performs a single CAS loop — no locks, no allocations.
* At a window boundary the CAS atomically resets the counter to 1.
*
* <p>The reduced epoch wraps every {@code 2^32 × windowMs} milliseconds
* (~13,000 years for a 100 ms window) — collision-free in practice.
*/
public final class FixedWindowStrategy implements RateLimitStrategy {
@Override
public boolean check(Bucket bucket, LimitConfig cfg, long[] out) {
long now = System.currentTimeMillis();
long absEpoch = now / cfg.windowMs();
int epoch = (int)(absEpoch & 0xFFFFFFFFL); // reduced epoch, collision-safe
while (true) {
long packed = bucket.slot0.get();
int storedEpoch = (int)(packed >>> 32);
int count = (int)(packed & 0xFFFFFFFFL);
// Same window: increment; new window: reset to 1.
// Cap at limit+1 to guard against int overflow on extreme traffic.
int newCount = (storedEpoch == epoch)
? Math.min(count + 1, cfg.limit() + 1)
: 1;
long newPacked = ((long) epoch << 32) | (newCount & 0xFFFFFFFFL);
if (bucket.slot0.compareAndSet(packed, newPacked)) {
out[0] = Math.max(0L, cfg.limit() - newCount);
out[1] = (absEpoch + 1) * cfg.windowMs() / 1000L;
return newCount <= cfg.limit();
}
// CAS lost — contention; re-read and retry.
}
}
}
@@ -0,0 +1,86 @@
package dev.relism.flash.ext.limiter.strategy;
import dev.relism.flash.ext.limiter.Bucket;
import dev.relism.flash.ext.limiter.LimitConfig;
import dev.relism.flash.ext.limiter.RateLimitStrategy;
/**
* Sliding-window counter rate limit: approximates a true sliding window by interpolating
* between the previous fixed window's count and the current window's count.
*
* <pre>
* estimate = prevCount × (1 elapsed / windowMs) + currentCount
* </pre>
*
* <p>This is the same approximation used by Redis. It eliminates the boundary burst
* problem of {@link FixedWindowStrategy} while staying O(1) memory and lock-free.
* The error is bounded: in the worst case the true rate at the boundary can exceed
* the limit by at most {@code limit × (1 elapsed/windowMs)} — typically a few percent.
*
* <h3>Slot layout</h3>
* <ul>
* <li>{@link Bucket#slot0} — packed {@code (reducedEpoch << 32 | currentCount)}</li>
* <li>{@link Bucket#slot1} — count from the immediately preceding epoch (0 = none)</li>
* </ul>
*
* <p>On a window transition the thread that wins the {@code slot0} CAS also writes
* {@code slot1}. A concurrent thread that reads {@code slot0} after the transition but
* before {@code slot1} is written sees a slightly stale previous count — acceptable for
* an approximation algorithm.
*/
public final class SlidingWindowStrategy implements RateLimitStrategy {
@Override
public boolean check(Bucket bucket, LimitConfig cfg, long[] out) {
long now = System.currentTimeMillis();
long windowMs = cfg.windowMs();
long absEpoch = now / windowMs;
int epoch = (int)(absEpoch & 0xFFFFFFFFL);
long elapsed = now % windowMs; // ms elapsed inside the current window
while (true) {
long packed = bucket.slot0.get();
int storedEpoch = (int)(packed >>> 32);
int count = (int)(packed & 0xFFFFFFFFL);
if (storedEpoch == epoch) {
// ── Same window ──────────────────────────────────────────────────
long prevCount = bucket.slot1.get();
// Integer interpolation — no floating-point on hot path.
long estimate = (prevCount * (windowMs - elapsed)) / windowMs + count + 1;
if (estimate > cfg.limit()) {
out[0] = 0L;
out[1] = (absEpoch + 1) * windowMs / 1000L;
return false;
}
int newCount = Math.min(count + 1, cfg.limit() + 1);
long newPacked = ((long) epoch << 32) | (newCount & 0xFFFFFFFFL);
if (!bucket.slot0.compareAndSet(packed, newPacked)) continue; // CAS lost, retry
out[0] = Math.max(0L, cfg.limit() - estimate);
out[1] = (absEpoch + 1) * windowMs / 1000L;
return true;
} else {
// ── Window transition ────────────────────────────────────────────
// If the stored epoch is exactly the one before ours, carry its count forward.
// If it's older (gap ≥ 2 windows), the previous window is effectively empty.
int prevEpoch = (int)((absEpoch - 1) & 0xFFFFFFFFL);
long oldCount = (storedEpoch == prevEpoch) ? count : 0L;
long newPacked = ((long) epoch << 32) | 1L;
if (!bucket.slot0.compareAndSet(packed, newPacked)) continue; // CAS lost, retry
// Won the transition: publish old count so the same-window branch can read it.
bucket.slot1.set(oldCount);
long estimate = (oldCount * (windowMs - elapsed)) / windowMs + 1;
out[0] = Math.max(0L, cfg.limit() - estimate);
out[1] = (absEpoch + 1) * windowMs / 1000L;
return estimate <= cfg.limit();
}
}
}
}
@@ -0,0 +1,68 @@
package dev.relism.flash.ext.limiter.strategy;
import dev.relism.flash.ext.limiter.Bucket;
import dev.relism.flash.ext.limiter.LimitConfig;
import dev.relism.flash.ext.limiter.RateLimitStrategy;
/**
* Token-bucket rate limit: tokens refill continuously at a rate of
* {@code limit / windowMs} tokens per millisecond, up to a maximum of {@code limit} tokens.
* Each request consumes one token. Burst traffic is absorbed until the bucket empties.
*
* <h3>Implementation</h3>
* <ul>
* <li>{@link Bucket#slot0} — current token count scaled by {@value #SCALE}
* (allows sub-token precision without floating-point). Starts at 0; treated as
* {@code maxScaled} when {@link Bucket#slot1} is 0 (first call → bucket starts full).</li>
* <li>{@link Bucket#slot1} — last-refill timestamp in ms. 0 = not yet initialised.</li>
* </ul>
*
* <p>Each request CAS-loops on {@code slot0}; {@code slot1} is advanced monotonically via CAS
* after a successful token consumption — never regresses to an older timestamp under concurrent load.
*/
public final class TokenBucketStrategy implements RateLimitStrategy {
/** Fixed-point scale factor. Stored tokens = actual tokens × SCALE. */
static final long SCALE = 1_000L;
@Override
public boolean check(Bucket bucket, LimitConfig cfg, long[] out) {
long now = System.currentTimeMillis();
long maxScaled = (long) cfg.limit() * SCALE;
// Refill rate: limit tokens per windowMs → (limit * SCALE) / windowMs scaled-tokens per ms.
// Minimum 1 to ensure progress even for very large windows.
long rfPerMs = Math.max(1L, maxScaled / cfg.windowMs());
while (true) {
long lastMs = bucket.slot1.get();
long rawTokens = bucket.slot0.get();
// When slot1 == 0 the bucket has never been used: treat as one full window elapsed
// so the bucket starts completely full.
long elapsed = (lastMs == 0L) ? cfg.windowMs() : Math.max(0L, now - lastMs);
long currentTokens = Math.min(maxScaled, rawTokens + elapsed * rfPerMs);
if (currentTokens < SCALE) {
// Not enough for one token — compute when the next token arrives.
long needed = SCALE - currentTokens;
long msToNext = (needed + rfPerMs - 1) / rfPerMs; // ceiling division
out[0] = 0L;
out[1] = (now + msToNext) / 1000L;
// Best-effort: advance the refill baseline so the next call gets a fresh elapsed.
bucket.slot0.compareAndSet(rawTokens, currentTokens);
bucket.slot1.compareAndSet(lastMs, now);
return false;
}
long newTokens = currentTokens - SCALE;
if (bucket.slot0.compareAndSet(rawTokens, newTokens)) {
// Advance refill baseline: CAS ensures we never regress to an older timestamp.
if (lastMs < now) bucket.slot1.compareAndSet(lastMs, now);
out[0] = newTokens / SCALE;
out[1] = now / 1000L;
return true;
}
// CAS lost — another thread consumed a token concurrently; re-read and retry.
}
}
}
@@ -0,0 +1,87 @@
package dev.relism.flash.ext.limiter;
import dev.relism.flash.ext.openapi.OpenApiContributor;
import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.Response;
import dev.relism.flash.routing.GET;
import 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.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class LimiterOpenApiInteropTest {
@GET("/limited")
@Limit(requests = 10, window = 1)
static class LimitedHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@GET("/plain")
static class PlainHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@Test
void registersContributor_whenOpenApiRegistryExists() {
FlashContext ctx = new FlashContext();
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
ctx.provide(OpenApiContributorRegistry.class, registry);
new LimiterExtension().configure(null, ctx);
ctx.complete();
assertEquals(1, registry.contributors().size());
}
@Test
void limitedHandler_contributesHeadersAnd429() {
FlashContext ctx = new FlashContext();
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
ctx.provide(OpenApiContributorRegistry.class, registry);
new LimiterExtension().configure(null, ctx);
ctx.complete();
OpenApiContributor contributor = registry.contributors().getFirst();
OpenApiOperationContribution operation = contributor.operationFor(LimitedHandler.class);
OpenApiResponseContribution all = operation.allResponses();
assertNotNull(all);
assertTrue(all.headers().containsKey("X-RateLimit-Limit"));
assertTrue(all.headers().containsKey("X-RateLimit-Remaining"));
assertTrue(all.headers().containsKey("X-RateLimit-Reset"));
OpenApiResponseContribution tooMany = operation.responses().get(429);
assertNotNull(tooMany);
assertEquals("Too Many Requests", tooMany.description());
assertTrue(tooMany.headers().containsKey("Retry-After"));
}
@Test
void plainHandler_hasNoContribution() {
FlashContext ctx = new FlashContext();
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
ctx.provide(OpenApiContributorRegistry.class, registry);
new LimiterExtension().configure(null, ctx);
ctx.complete();
OpenApiContributor contributor = registry.contributors().getFirst();
OpenApiOperationContribution operation = contributor.operationFor(PlainHandler.class);
assertTrue(operation.isEmpty());
assertFalse(operation.responses().containsKey(429));
}
}
@@ -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.

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