50 Commits
Author SHA1 Message Date
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
647 changed files with 33413 additions and 31182 deletions
View File
+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.
+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,81 @@
# flash-ext-data-core
Core comune per il layer dati di Flash.
## Scopo
Questo modulo definisce il contratto transazionale condiviso tra le implementazioni backend.
Non parla con Hibernate o JDBC direttamente: espone solo astrazioni e un runtime minimale.
## Componenti
- `TxDefinition`: metadata immutabile della transazione.
- `TxStatus`: stato runtime restituito dal manager.
- `TxManager`: contratto per `begin`, `commit`, `rollback`.
- `Tx`: orchestration runtime e stack transazionale per thread.
- `ResourceRegistry`: storage thread-local di risorse e synchronizations.
- `Repository<T, ID>`: base repository auto-transazionale.
- `Spec<T>`: predicato componibile.
- `Query<T>`: oggetto query con spec, sort e paging.
- `SpecBuilder<T>`: DSL fluente per costruire spec tipizzate.
- `RepositorySupport<T, ID>`: helper interno condiviso.
- `TransactionPropagation`: semantica di propagazione.
- `TransactionIsolation`: livello di isolamento.
- `TxSynchronization`: hook lifecycle.
## Modello di esecuzione
Il flusso è:
1. `Tx.call(definition, work)` chiama `TxManager.begin(definition)`.
2. Il `TxManager` crea un `TxStatus` backend-specific.
3. Lo status viene pushato nello stack thread-local.
4. Il lavoro usa `Tx.resource(Class)` per ottenere la risorsa corrente.
5. A fine lavoro `Tx` decide tra `commit` e `rollback`.
6. Lo stack viene poppato e il thread-local viene pulito se vuoto.
## Propagation supportata
- `REQUIRED`: usa la tx attiva oppure ne apre una nuova.
- `REQUIRES_NEW`: sospende la tx corrente e apre una nuova tx.
- `SUPPORTS`: se esiste una tx attiva si aggancia, altrimenti esegue senza tx.
- `NOT_SUPPORTED`: sospende la tx corrente ed esegue senza tx.
- `MANDATORY`: richiede una tx attiva.
## Uso di `Repository`
`Repository` è la base comune per le repository concrete.
Ogni operazione pubblica usa internamente una tx `REQUIRED` o `REQUIRED` read-only.
Le sottoclassi implementano i metodi `doXxx(...)` del nuovo modello:
- `doFind(Query<T>)`
- `doFindOne(Spec<T>)`
- `doFindPage(Query<T>)`
- `doDeleteAll(Spec<T>)`
- `doUpdateAll(Spec<T>, T)`
I vecchi overload di `findAll(...)` e `findPage(...)` sono stati ridotti a una combinazione di `Query<T>` e `Spec<T>`.
```java
public abstract class Repository<T, ID> {
protected Repository(Tx tx) { ... }
protected final <R> R tx(Tx.TxCallable<R> work) { ... }
}
```
## Composizione con Flash
`DataExtension` registra:
- `Tx` nel `FlashContext`
- `TxManager` nel `FlashContext`
- un annotation processor per `@Transactional`
Questo rende il layer dati componibile con il sistema di extension di Flash senza stato globale.
## Note implementative
- Lo stack transazionale è thread-local e viene ripulito quando torna vuoto.
- Le risorse backend sono sospese e ripristinate per `REQUIRES_NEW` e `NOT_SUPPORTED`.
- `TxSynchronization` è il punto di aggancio per hook di commit/rollback/completion.
@@ -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,68 @@
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));
}
public static void fireSynchronizations(TxOutcome outcome) {
List<TxSynchronization> syncs = List.copyOf(SYNCHRONIZATIONS.get());
SYNCHRONIZATIONS.get().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,8 @@
package dev.relism.flash.ext.data.core;
public interface TxSynchronization {
default void beforeCommit(boolean readOnly) {}
default void afterCommit() {}
default void afterRollback() {}
default void afterCompletion(TxOutcome outcome) {}
}
@@ -0,0 +1,92 @@
# flash-ext-data-hibernate
Backend Hibernate per `flash-ext-data-core`.
## Scopo
Questo modulo implementa `TxManager` sopra `SessionFactory` e fornisce una base repository Hibernate-centric.
## Come si usa
### 1. Creare il manager
```java
SessionFactory sessionFactory = ...;
HibernateTxManager txManager = new HibernateTxManager(sessionFactory);
DataExtension extension = new DataExtension(txManager);
```
### 2. Installare lestensione in Flash
Lestensione registra `Tx` e `TxManager` nel `FlashContext`.
Le handler class-based annotate con `@Transactional` vengono wrappate automaticamente.
### 3. Definire una repository
```java
public final class UserRepository extends HibernateRepository<User, Long> {
public UserRepository(Tx tx) {
super(tx, User.class);
}
}
```
Con il nuovo modello query/spec puoi esporre campi riusabili come costanti:
```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));
}
}
```
Le query domain-specific possono usare gli helper della base class:
```java
public List<User> findByEmailDomain(String domain) {
return findMany("from User u where u.email like :email", q ->
q.setParameter("email", "%@" + domain)
);
}
```
## Come funziona sotto
- La tx corrente è rappresentata da `HibernateTxStatus`.
- La risorsa esposta al core è una `Session`.
- `Tx.resource(Session.class)` recupera la `Session` dal contesto corrente.
- `REQUIRES_NEW` sospende lo status attivo e apre una nuova `Session`.
- `NOT_SUPPORTED` sospende la tx attiva e continua senza sessione bindata.
## Repository base
`HibernateRepository` fornisce:
- `findById`, `findAll`, `findPage`, `findOne`
- `save`, `update`, `delete`, `saveAll`
- bulk `deleteAll(Spec<T>)` e `updateAll(Spec<T>, T)`
- helper HQL: `hql(...)`, `hqlMutate(...)`
Le classi concrete devono solo implementare query di dominio, non il plumbing transazionale.
## Semantica transazionale
- `REQUIRED`: join o apertura nuova tx.
- `REQUIRES_NEW`: sospensione del contesto corrente.
- `SUPPORTS`: join se c’è tx, altrimenti no-op.
- `NOT_SUPPORTED`: sospende e prosegue senza tx.
- `MANDATORY`: fallisce se non c’è tx.
## Note
- `Session` viene chiusa a fine tx nuova.
- Le synchronizations vengono eseguite al commit/rollback.
- Il backend è pensato per essere usato tramite la base class, non direttamente.
@@ -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,185 @@
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) {
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()
);
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");
}
return new HibernateTxStatus(
existing.session(),
false,
definition.readOnly(),
null,
existing.rollbackMarker()
);
}
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());
}
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;
try {
if (s.isRollbackOnly() && s.session().getTransaction().isActive()) {
s.session().getTransaction().rollback();
outcome = TxOutcome.ROLLED_BACK;
} else {
s.session().getTransaction().commit();
outcome = TxOutcome.COMMITTED;
}
} finally {
cleanupAndResume(s);
cleanupIfIdle();
}
ResourceRegistry.fireSynchronizations(outcome);
}
@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();
}
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK);
} finally {
cleanupAndResume(s);
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,47 @@
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;
HibernateTxStatus(
Session session,
boolean newTransaction,
boolean readOnly,
HibernateTxStatus suspended,
RollbackMarker rollbackMarker
) {
this.session = session;
this.newTransaction = newTransaction;
this.readOnly = readOnly;
this.suspended = suspended;
this.rollbackMarker = rollbackMarker;
}
@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; }
}
@@ -0,0 +1,68 @@
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);
}
}
@@ -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,96 @@
# flash-ext-data-jdbc
Backend JDBC per `flash-ext-data-core`.
## Scopo
Questo modulo implementa `TxManager` sopra `DataSource` e fornisce una base repository SQL raw.
## Come si usa
### 1. Creare il manager
```java
DataSource dataSource = ...;
JdbcTxManager txManager = new JdbcTxManager(dataSource);
DataExtension extension = new DataExtension(txManager);
```
### 2. Installare lestensione in Flash
Come per Hibernate, `DataExtension` registra `Tx` nel `FlashContext` e abilita `@Transactional` sugli handler class-based.
### 3. Definire una 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"));
}
}
```
Anche qui puoi esporre `Spec` riusabili e comporre query dal 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");
}
}
```
Per il salvataggio e lupdate devi fornire il binding esplicito:
```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());
}
```
## Come funziona sotto
- La tx corrente espone una `Connection`.
- `Tx.resource(Connection.class)` recupera la connessione bindata al thread.
- `REQUIRES_NEW` sospende la connessione attiva e ne apre una nuova.
- `NOT_SUPPORTED` sospende il contesto e prosegue senza tx.
## Repository base
`JdbcRepository` fornisce:
- query `select` con `queryOne`, `queryMany`
- mutation con `mutate`
- persistenza con `doSave`, `doUpdate`
- paging con `doFindPage`
- bulk `deleteAll(Spec<T>)`
- helper raw `queryOne(...)`, `queryMany(...)`, `mutate(...)`
Le repository concrete devono solo tradurre tra `ResultSet` e dominio.
## Semantica transazionale
- `REQUIRED`: join o apertura nuova tx.
- `REQUIRES_NEW`: sospensione del contesto corrente.
- `SUPPORTS`: join se c’è tx, altrimenti no-op.
- `NOT_SUPPORTED`: sospende e prosegue senza tx.
- `MANDATORY`: fallisce se non c’è tx.
## Note
- La `Connection` viene chiusa a fine tx nuova.
- Le synchronizations vengono eseguite al commit/rollback.
- Se una repository usa `doDelete(T)`, il comportamento predefinito è non supportato: usare `deleteById` o override specifico.
@@ -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,186 @@
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;
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()
);
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");
}
return new JdbcTxStatus(
existing.connection(),
false,
definition.readOnly(),
null,
existing.rollbackMarker()
);
}
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());
}
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;
}
try {
if (s.isRollbackOnly()) {
s.connection().rollback();
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK);
return;
}
s.connection().commit();
ResourceRegistry.fireSynchronizations(TxOutcome.COMMITTED);
} catch (SQLException e) {
throw new TxException(e);
} finally {
cleanupAndResume(s);
cleanupIfIdle();
}
}
@Override
public void rollback(TxStatus status) {
JdbcTxStatus s = (JdbcTxStatus) status;
if (!s.isNewTransaction()) {
s.markRollbackOnly();
resumeIfNeeded(s);
cleanupIfIdle();
return;
}
try {
s.connection().rollback();
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK);
} catch (SQLException e) {
throw new TxException(e);
} finally {
cleanupAndResume(s);
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,49 @@
package dev.relism.flash.ext.data.jdbc;
import dev.relism.flash.ext.data.core.TxStatus;
import java.sql.Connection;
import java.util.Objects;
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;
JdbcTxStatus(
Connection connection,
boolean newTransaction,
boolean readOnly,
JdbcTxStatus suspended,
RollbackMarker rollbackMarker
) {
this.connection = Objects.requireNonNull(connection);
this.newTransaction = newTransaction;
this.readOnly = readOnly;
this.suspended = suspended;
this.rollbackMarker = rollbackMarker;
}
@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; }
}
@@ -0,0 +1,104 @@
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 javax.sql.DataSource;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import static org.junit.jupiter.api.Assertions.*;
class JdbcTxManagerTest {
private final JdbcTxManager manager = new JdbcTxManager(dataSource());
@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);
}
private static DataSource dataSource() {
return new DataSource() {
@Override
public Connection getConnection() throws SQLException {
return DriverManager.getConnection("jdbc:h2:mem:tx-jdbc;DB_CLOSE_DELAY=-1");
}
@Override
public Connection getConnection(String username, String password) throws SQLException {
return DriverManager.getConnection("jdbc:h2:mem:tx-jdbc;DB_CLOSE_DELAY=-1", username, password);
}
@Override
public <T> T unwrap(Class<T> iface) {
throw new UnsupportedOperationException();
}
@Override
public boolean isWrapperFor(Class<?> iface) {
return false;
}
@Override
public java.io.PrintWriter getLogWriter() {
throw new UnsupportedOperationException();
}
@Override
public void setLogWriter(java.io.PrintWriter out) {
throw new UnsupportedOperationException();
}
@Override
public void setLoginTimeout(int seconds) {
throw new UnsupportedOperationException();
}
@Override
public int getLoginTimeout() {
return 0;
}
@Override
public java.util.logging.Logger getParentLogger() {
throw new UnsupportedOperationException();
}
};
}
}
@@ -0,0 +1,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.
@@ -0,0 +1,45 @@
# Transport
`flash-ext-mcp` implements the **Streamable HTTP** transport from the MCP specification
(revision `2025-11-25`). `stdio` is out of scope — Flash5 is an HTTP framework, and a
subprocess-stdio transport doesn't fit its model.
## What this revision implements
- A single `POST {rootPath}` endpoint (default `/mcp`) accepting one JSON-RPC 2.0 message per
request and responding with a plain JSON object — the "standard JSON object response" mode the
specification allows as an alternative to opening a Server-Sent Events stream per request.
- `Origin` header validation (DNS-rebinding protection), configurable via
`McpConfig.allowedOrigins(...)`.
- Full JSON-RPC lifecycle: `initialize`, `notifications/initialized` (and any other
`notifications/*`/id-less message — answered with a bare `202 Accepted`, no body, per
JSON-RPC's notification semantics), `ping`, `tools/list`, `tools/call`, `resources/list`,
`resources/read`, `prompts/list`, `prompts/get`.
## What this revision deliberately does not implement
- **No `Mcp-Session-Id` / session state.** The specification says a server "MAY assign a session
ID at initialization time" — it is optional, not mandatory. This server is stateless: every
`POST` is handled independently, with no server-side session store. `initialize` does not need
to precede other calls for the server to function (there's no session to be "not initialized"
yet), which is a looser contract than a session-aware server would enforce — acceptable for a
static, boot-time-defined tool/resource/prompt catalog.
- **No Server-Sent Events stream.** `GET {rootPath}` (used by session-aware servers to open a
standing SSE stream for server-initiated pushes) is not registered — MCP clients that only
speak the request/response half of Streamable HTTP work unaffected; clients that require a
standing SSE connection are not supported by this revision.
Both are real, intentional scope cuts for a first version — not just to keep the surface area
small: a static, precompiled tool catalog (see `tools-resources-prompts.md`) has no
`listChanged` events to push and no long-running server-initiated messages to stream, so the
stateful half of the transport buys little for the common case this extension targets. Sessions
and SSE are natural extension points if a future revision needs server push (e.g. dynamic tool
registration, elicitation, or sampling requests initiated by the server).
## Why `POST`, not the new `QUERY` HTTP method
Flash5's core recently gained `HttpMethod.QUERY` (safe, idempotent, carries a body — a good
semantic fit for JSON-RPC-over-HTTP in general). It is **not** used here: the MCP Streamable
HTTP specification mandates `POST` for the client-to-server message path. Real MCP clients send
`POST`; using `QUERY` instead would break interoperability with every existing client for a
semantic nicety this extension doesn't need standalone.
+43
View File
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>2.1.0-SNAPSHOT</version>
</parent>
<artifactId>flash-ext-mcp</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-oidc</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,8 @@
package dev.relism.flash.ext.mcp;
/**
* MCP tool/prompt content block. {@code sealed} to the variants this extension currently
* writes on the wire — extend the permits clause (and {@link McpContentWriter}) to add
* {@code ImageContent}, {@code EmbeddedResource}, etc. in a future revision.
*/
public sealed interface Content permits TextContent {}
@@ -0,0 +1,13 @@
package dev.relism.flash.ext.mcp;
/** Standard JSON-RPC 2.0 error codes used by the MCP transport. */
final class JsonRpcErrorCode {
private JsonRpcErrorCode() {}
static final int PARSE_ERROR = -32700;
static final int INVALID_REQUEST = -32600;
static final int METHOD_NOT_FOUND = -32601;
static final int INVALID_PARAMS = -32602;
static final int INTERNAL_ERROR = -32603;
}
@@ -0,0 +1,23 @@
package dev.relism.flash.ext.mcp;
import java.util.function.Supplier;
/**
* Compiled per-tool authorization requirement, built once at boot by {@link McpOidcIntegration}
* from {@code @RolesAllowed}/{@code @ScopesAllowed} on an {@link McpTool} subclass — {@code null}
* on {@link McpRegistry.RegisteredTool} means no restriction beyond whatever {@link McpSecurity}
* already enforces route-wide.
*
* <p>{@code check} is a closure, not a raw role/scope list — this is what lets this record (and
* its only caller, {@link McpDispatcher}) stay free of any compile-time reference to a {@code
* flash-ext-oidc} type, preserving the same classload isolation {@link McpOidcIntegration}'s
* javadoc describes for the rest of the OIDC bridge. Only the plain-JDK {@link Supplier}
* signature crosses the boundary; the closure itself, built once inside {@code
* McpOidcIntegration}, is the only place that ever touches {@code OidcUser}/{@code ClaimsHolder}.
*
* <p>Returns {@code null} from {@link #check()}{@code .get()} when authorized, or a
* human-readable denial reason otherwise — invoked once per {@code tools/call} against an
* annotated tool, never allocated on that path (the closure and its captured role/scope arrays
* are built exactly once, at boot).
*/
record McpAuthPolicy(Supplier<String> check) {}
@@ -0,0 +1,143 @@
package dev.relism.flash.ext.mcp;
import java.util.ArrayList;
import java.util.List;
/**
* Immutable configuration for {@link McpExtension}.
*
* <pre>{@code
* McpConfig.builder("my-mcp-server")
* .version("1.0.0")
* .rootPath("/mcp")
* .toolsPackage("com.example.tools")
* .security(McpSecurity.REQUIRED)
* .scopesSupported("openid", "profile", "email")
* .build();
* }</pre>
*/
public final class McpConfig {
private final String name;
private final String version;
private final String instructions;
private final String rootPath;
private final String toolsPackage;
private final McpSecurity security;
private final String resourceIdentifier;
private final String authorizationServerIssuer;
private final List<String> allowedOrigins;
private final List<String> scopesSupported;
private McpConfig(Builder b) {
this.name = b.name;
this.version = b.version;
this.instructions = b.instructions;
this.rootPath = b.rootPath;
this.toolsPackage = b.toolsPackage;
this.security = b.security;
this.resourceIdentifier = b.resourceIdentifier;
this.authorizationServerIssuer = b.authorizationServerIssuer;
this.allowedOrigins = List.copyOf(b.allowedOrigins);
this.scopesSupported = List.copyOf(b.scopesSupported);
}
String name() { return name; }
String version() { return version; }
String instructions() { return instructions; }
String rootPath() { return rootPath; }
String toolsPackage() { return toolsPackage; }
McpSecurity security() { return security; }
String resourceIdentifier() { return resourceIdentifier; }
String authorizationServerIssuer() { return authorizationServerIssuer; }
List<String> allowedOrigins() { return allowedOrigins; }
List<String> scopesSupported() { return scopesSupported; }
public static Builder builder(String name) { return new Builder(name); }
public static final class Builder {
private final String name;
private String version = "1.0.0";
private String instructions;
private String rootPath = "/mcp";
private String toolsPackage;
private McpSecurity security = McpSecurity.AUTO;
private String resourceIdentifier;
private String authorizationServerIssuer;
private final List<String> allowedOrigins = new ArrayList<>();
private final List<String> scopesSupported = new ArrayList<>();
private Builder(String name) {
if (name == null || name.isBlank())
throw new IllegalArgumentException("McpConfig server name cannot be blank");
this.name = name;
}
/** Server version reported in {@code initialize}'s {@code serverInfo}. Default {@code "1.0.0"}. */
public Builder version(String version) { this.version = version; return this; }
/** Free-text instructions surfaced to the client at {@code initialize} time. */
public Builder instructions(String instructions) { this.instructions = instructions; return this; }
/** HTTP path for the Streamable HTTP endpoint. Default {@code "/mcp"}. */
public Builder rootPath(String rootPath) { this.rootPath = normalize(rootPath); return this; }
/** Package scanned for {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} classes. Required. */
public Builder toolsPackage(String toolsPackage) { this.toolsPackage = toolsPackage; return this; }
/** OAuth2 requirement policy. Default {@link McpSecurity#AUTO}. */
public Builder security(McpSecurity security) { this.security = security; return this; }
/**
* Canonical URI of this MCP endpoint, used for RFC 8707 audience binding: tokens whose
* {@code aud} claim does not include this value are rejected. Optional — when
* {@code flash-ext-oidc} is installed, this is auto-derived per request from the
* forwarded/{@code Host} headers (same resolution {@code OidcExtension} uses for its own
* redirect URIs) and audience binding is enforced unconditionally. Set this explicitly
* only to override that guess — a reverse proxy that forwards neither
* {@code X-Forwarded-Host} nor {@code X-Forwarded-Proto}.
*/
public Builder resourceIdentifier(String resourceIdentifier) { this.resourceIdentifier = resourceIdentifier; return this; }
/**
* Authorization server issuer URL, published in the RFC 9728 Protected Resource
* Metadata document at {@code /.well-known/oauth-protected-resource{rootPath}}. Optional
* — when {@code flash-ext-oidc} is installed, this is auto-derived from its configured
* issuer. Set this explicitly only to override that (e.g. publishing a different issuer
* than the one actually validating tokens).
*/
public Builder authorizationServerIssuer(String issuer) { this.authorizationServerIssuer = issuer; return this; }
/**
* Origins allowed to call the MCP endpoint (DNS-rebinding protection, per the Streamable
* HTTP transport spec). If never set, {@code Origin} validation is skipped and a warning
* is logged at boot.
*/
public Builder allowedOrigins(String... origins) { this.allowedOrigins.addAll(List.of(origins)); return this; }
/**
* OAuth2 scopes this server expects clients to request, published as {@code
* scopes_supported} in the RFC 9728 Protected Resource Metadata document. Optional per
* the spec — omitted from the document entirely if never set. A spec-compliant client
* reads this to know what to put in its authorization/token requests instead of
* requesting nothing; see {@code docs/keycloak.md}'s "same story for any other claim"
* section for why this matters in practice (a client that requests no scope only gets
* whatever your authorization server treats as always-included, e.g. Keycloak's `basic`).
* Purely advertisement — this server still validates whatever token it actually receives
* the same way regardless of what a client requested.
*/
public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; }
public McpConfig build() {
if (toolsPackage == null || toolsPackage.isBlank())
throw new IllegalStateException(
"McpConfig.toolsPackage(...) is required — declare at least one @Tool/@Resource/@Prompt class");
return new McpConfig(this);
}
private static String normalize(String path) {
if (path == null || path.isBlank()) throw new IllegalArgumentException("rootPath cannot be blank");
return path.startsWith("/") ? path : "/" + path;
}
}
}

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