Compare commits
78
Commits
v2.0.0
...
f68e661296
@@ -0,0 +1,24 @@
|
|||||||
|
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 http://maven.apache.org/xsd/settings-1.0.0.xsd">
|
||||||
|
<!--
|
||||||
|
Used only by .gitea/workflows/publish-maven.yml (mvn -s .gitea/maven-settings.xml deploy).
|
||||||
|
Not used for local builds. PACKAGES_TOKEN is a real personal access token (write:package
|
||||||
|
scope) on the Relism account, read from the env var the workflow exports — never written
|
||||||
|
to disk. Deliberately not Gitea's own per-job GITEA_TOKEN: that token can't publish to
|
||||||
|
any package registry at all, a known unimplemented limitation
|
||||||
|
(https://github.com/go-gitea/gitea/issues/23642) — confirmed here by testing: it
|
||||||
|
authenticated fine against the plain API but still got 401 from this endpoint.
|
||||||
|
|
||||||
|
Basic auth (username/password): the <httpHeaders> form Gitea's own docs show for this is
|
||||||
|
honored by Maven's resolver (used for reading <repositories>) but not reliably by the
|
||||||
|
wagon-http provider maven-deploy-plugin actually uploads through.
|
||||||
|
-->
|
||||||
|
<servers>
|
||||||
|
<server>
|
||||||
|
<id>gitea</id>
|
||||||
|
<username>Relism</username>
|
||||||
|
<password>${env.PACKAGES_TOKEN}</password>
|
||||||
|
</server>
|
||||||
|
</servers>
|
||||||
|
</settings>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
name: Publish Maven packages
|
||||||
|
|
||||||
|
# Flash's own POM keeps `2.1.0-SNAPSHOT` as its committed version — that's what local
|
||||||
|
# `mvn install` (Pathway's normal dev loop, see its pom.xml `flash.version` comment) always
|
||||||
|
# produces, and changing it here would break that. Gitea's Maven registry, unlike a real
|
||||||
|
# snapshot repository, refuses to re-publish an existing name+version (must delete first —
|
||||||
|
# see https://docs.gitea.com/usage/packages/maven#publish-a-package), so every push instead
|
||||||
|
# publishes under a throwaway version stamped with the commit it built from
|
||||||
|
# (`2.1.0-<short-sha>`), via `versions:set` on a checkout copy — never touching the committed
|
||||||
|
# POMs. Consumers (Pathway's `docker` Maven profile) pin `flash.version` to one specific
|
||||||
|
# published build and bump it by hand to pick up newer Flash changes; see
|
||||||
|
# pathway/pom.xml's `docker` profile for the other half of this.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# No actions/checkout here on purpose: it's a Node-based action, and this container
|
||||||
|
# (chosen for its preinstalled mvn/JDK 21) has no Node — checkout would fail with
|
||||||
|
# "node: executable file not found". A plain git clone needs neither.
|
||||||
|
container:
|
||||||
|
image: maven:3.9-eclipse-temurin-21
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y --no-install-recommends git
|
||||||
|
git clone https://git.pixel-services.com/Relism/Flash5.git .
|
||||||
|
git checkout ${{ gitea.sha }}
|
||||||
|
|
||||||
|
- name: Stamp every module with a commit-scoped version
|
||||||
|
run: |
|
||||||
|
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
mvn -B versions:set -DnewVersion="2.1.0-${SHORT_SHA}" -DprocessAllModules=true -DgenerateBackupPoms=false
|
||||||
|
echo "Publishing as 2.1.0-${SHORT_SHA}"
|
||||||
|
|
||||||
|
- name: Deploy to the Gitea Maven registry
|
||||||
|
env:
|
||||||
|
# Not GITEA_TOKEN: Gitea's own job token can't publish to package registries at
|
||||||
|
# all (a known, still-unimplemented limitation — see
|
||||||
|
# https://github.com/go-gitea/gitea/issues/23642). Confirmed by testing: GITEA_TOKEN
|
||||||
|
# authenticated fine against the plain API but still got 401 from this endpoint no
|
||||||
|
# matter the auth style. PACKAGES_TOKEN is a real PAT with write:package scope.
|
||||||
|
PACKAGES_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
|
||||||
|
run: |
|
||||||
|
mvn -B -s .gitea/maven-settings.xml -DskipTests deploy \
|
||||||
|
-DaltReleaseDeploymentRepository=gitea::https://git.pixel-services.com/api/packages/Relism/maven \
|
||||||
|
-DaltSnapshotDeploymentRepository=gitea::https://git.pixel-services.com/api/packages/Relism/maven
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
|
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0
|
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0
|
||||||
http://maven.apache.org/xsd/maven-1.0.0.xsd">
|
http://maven.apache.org/xsd/settings-1.0.0.xsd">
|
||||||
<servers>
|
<servers>
|
||||||
<server>
|
<server>
|
||||||
<id>Personal</id>
|
<id>Personal</id>
|
||||||
|
|||||||
@@ -32,8 +32,40 @@ jobs:
|
|||||||
server-username: MAVEN_USERNAME
|
server-username: MAVEN_USERNAME
|
||||||
server-password: MAVEN_PASSWORD
|
server-password: MAVEN_PASSWORD
|
||||||
|
|
||||||
|
- name: Install h2spec 2.6.0
|
||||||
|
run: |
|
||||||
|
curl --fail --location --silent --show-error \
|
||||||
|
--output /tmp/h2spec.tar.gz \
|
||||||
|
https://github.com/summerwind/h2spec/releases/download/v2.6.0/h2spec_linux_amd64.tar.gz
|
||||||
|
echo "157ee0de702e01ad40e752dbf074b366027e550c8e7504f9450da2809e279318 /tmp/h2spec.tar.gz" \
|
||||||
|
| sha256sum --check
|
||||||
|
tar --extract --gzip --file /tmp/h2spec.tar.gz --directory /tmp
|
||||||
|
|
||||||
|
- name: Install nghttp client
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install --yes nghttp2-client
|
||||||
|
|
||||||
|
- name: Install grpcurl 1.9.3
|
||||||
|
run: |
|
||||||
|
curl --fail --location --silent --show-error \
|
||||||
|
--output /tmp/grpcurl.tgz \
|
||||||
|
https://github.com/fullstorydev/grpcurl/releases/download/v1.9.3/grpcurl_1.9.3_linux_x86_64.tar.gz
|
||||||
|
echo "a926b62a85787ccf73ef8736b3ae554f1242e39d92bb8767a79d6dd23b11d1d5 /tmp/grpcurl.tgz" \
|
||||||
|
| sha256sum --check
|
||||||
|
tar --extract --gzip --file /tmp/grpcurl.tgz --directory /tmp grpcurl
|
||||||
|
|
||||||
- name: Build and test
|
- name: Build and test
|
||||||
run: mvn -B --settings .github/settings.xml clean verify
|
run: >-
|
||||||
|
mvn -B --settings .github/settings.xml
|
||||||
|
-Dh2spec.executable=/tmp/h2spec
|
||||||
|
-Dcurl.executable=/usr/bin/curl
|
||||||
|
-Dnghttp.executable=/usr/bin/nghttp
|
||||||
|
-Dgrpcurl.executable=/tmp/grpcurl
|
||||||
|
-Djdk.tracePinnedThreads=full
|
||||||
|
-Pjmh
|
||||||
|
-Dflash.performance.gates=true
|
||||||
|
clean verify
|
||||||
env:
|
env:
|
||||||
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
||||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -6,13 +6,15 @@ on:
|
|||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# ── 1. Build, GPG-sign, deploy to Maven releases ──────────────────────────
|
||||||
release:
|
release:
|
||||||
name: Build, Sign, Deploy & Publish
|
name: Build, Sign & Deploy
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
pages: write
|
|
||||||
id-token: write
|
outputs:
|
||||||
|
version: ${{ steps.version.outputs.VERSION }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -47,90 +49,22 @@ jobs:
|
|||||||
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
||||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||||
|
|
||||||
- name: Generate aggregated JavaDoc
|
|
||||||
run: |
|
|
||||||
mvn -B --settings .github/settings.xml \
|
|
||||||
-pl flash,flash-extensions -am \
|
|
||||||
javadoc:aggregate -DskipTests
|
|
||||||
env:
|
|
||||||
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
|
||||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Checkout gh-pages
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: gh-pages
|
|
||||||
path: gh-pages-out
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Copy JavaDoc to versioned folder
|
|
||||||
run: |
|
|
||||||
VERSION=${{ steps.version.outputs.VERSION }}
|
|
||||||
mkdir -p gh-pages-out/javadoc/$VERSION
|
|
||||||
cp -r target/reports/apidocs/. gh-pages-out/javadoc/$VERSION/
|
|
||||||
rm -rf gh-pages-out/latest
|
|
||||||
mkdir -p gh-pages-out/latest
|
|
||||||
cp -r target/reports/apidocs/. gh-pages-out/latest/
|
|
||||||
|
|
||||||
- name: Regenerate index.html
|
|
||||||
run: |
|
|
||||||
cd gh-pages-out
|
|
||||||
python3 - <<'EOF'
|
|
||||||
import os, re
|
|
||||||
|
|
||||||
versions = sorted(
|
|
||||||
[d for d in os.listdir("javadoc") if os.path.isdir(f"javadoc/{d}")],
|
|
||||||
key=lambda v: [int(x) for x in re.sub(r'[^0-9.]', '', v).split('.') if x],
|
|
||||||
reverse=True
|
|
||||||
)
|
|
||||||
|
|
||||||
rows = "\n".join(
|
|
||||||
f' <li><a href="javadoc/{v}/index.html">{v}</a></li>'
|
|
||||||
for v in versions
|
|
||||||
)
|
|
||||||
|
|
||||||
html = f"""<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Flash JavaDoc</title>
|
|
||||||
<style>
|
|
||||||
body {{ font-family: sans-serif; max-width: 600px; margin: 4rem auto; }}
|
|
||||||
h1 {{ font-size: 1.6rem; }}
|
|
||||||
ul {{ line-height: 2; }}
|
|
||||||
a {{ color: #0070f3; text-decoration: none; }}
|
|
||||||
a:hover {{ text-decoration: underline; }}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Flash — JavaDoc</h1>
|
|
||||||
<p><a href="latest/index.html">→ Latest</a></p>
|
|
||||||
<h2>All versions</h2>
|
|
||||||
<ul>
|
|
||||||
{rows}
|
|
||||||
</ul>
|
|
||||||
</body>
|
|
||||||
</html>"""
|
|
||||||
|
|
||||||
with open("index.html", "w") as f:
|
|
||||||
f.write(html)
|
|
||||||
print(f"index.html generated with {len(versions)} versions: {versions}")
|
|
||||||
EOF
|
|
||||||
|
|
||||||
- name: Push gh-pages
|
|
||||||
run: |
|
|
||||||
cd gh-pages-out
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git add -A
|
|
||||||
git diff --cached --quiet || git commit -m "docs(javadoc): release ${{ steps.version.outputs.VERSION }}"
|
|
||||||
git push origin gh-pages
|
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: v${{ steps.version.outputs.VERSION }}
|
tag_name: ${{ github.ref_name }}
|
||||||
name: v${{ steps.version.outputs.VERSION }}
|
name: ${{ github.ref_name }}
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
|
|
||||||
|
# ── 2. Publish JavaDoc (single source of truth: docs.yml) ─────────────────
|
||||||
|
docs:
|
||||||
|
name: Publish JavaDoc
|
||||||
|
needs: release
|
||||||
|
uses: ./.github/workflows/docs.yml
|
||||||
|
with:
|
||||||
|
version: ${{ needs.release.outputs.version }}
|
||||||
|
secrets:
|
||||||
|
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
||||||
|
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||||
Generated
+72
-283
@@ -4,292 +4,42 @@
|
|||||||
<option name="autoReloadType" value="SELECTIVE" />
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="fc757130-fe3e-4ea9-8d69-c26ffb8545a4" name="Changes" comment="add core view extension with JTE and Thymeleaf support">
|
<list default="true" id="fc757130-fe3e-4ea9-8d69-c26ffb8545a4" name="Changes" comment="feat: introduce WebSocket support with new endpoints and transaction propagation enhancements">
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/pom.xml" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/.github/workflows/release.yml" beforeDir="false" afterPath="$PROJECT_DIR$/.github/workflows/release.yml" afterDir="false" />
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/DataExtension.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/Page.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/Repository.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/ResourceRegistry.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/Sort.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TransactionIsolation.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TransactionPropagation.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/Tx.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxDefinition.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxException.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxManager.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxOutcome.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxResourceKey.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxStatus.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxSynchronization.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/pom.xml" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateRepository.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateTxManager.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateTxStatus.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/test/java/dev/relism/flash/ext/data/hibernate/HibernateTxManagerTest.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/test/java/dev/relism/flash/ext/data/hibernate/TestHelper.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/pom.xml" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/main/java/dev/relism/flash/ext/data/jdbc/JdbcRepository.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/main/java/dev/relism/flash/ext/data/jdbc/JdbcTxManager.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/main/java/dev/relism/flash/ext/data/jdbc/JdbcTxStatus.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/test/java/dev/relism/flash/ext/data/jdbc/JdbcTxManagerTest.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteTarget.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendType.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/Flash.java" afterDir="false" />
|
|
||||||
<change afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/routers/radix/RadixPathRouterImpl.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/.idea/encodings.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/encodings.xml" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/JacksonExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/JacksonExtension.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/JacksonMiddleware.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/JacksonMiddleware.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/Json.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/Json.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/ext/jackson/JacksonExtensionTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JacksonExtensionTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/ext/jackson/JacksonMiddlewareTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JacksonMiddlewareTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/ext/jackson/JsonTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JsonTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/Bucket.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/Bucket.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/BucketStore.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/BucketStore.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/Guard.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/Guard.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/KeyResolver.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/KeyResolver.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/Limit.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/Limit.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimitConfig.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimitConfig.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimitStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimitStrategy.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimiterConfig.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterConfig.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimiterExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterExtension.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/RateLimitStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/RateLimitStrategy.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/strategy/FixedWindowStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/strategy/FixedWindowStrategy.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/strategy/SlidingWindowStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/strategy/SlidingWindowStrategy.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/strategy/TokenBucketStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/strategy/TokenBucketStrategy.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/test/java/dev/relism/ext/limiter/LimiterOpenApiInteropTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/test/java/dev/relism/flash/ext/limiter/LimiterOpenApiInteropTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/Authenticated.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/Authenticated.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/ClaimsHolder.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ClaimsHolder.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/ClientAuthMethod.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ClientAuthMethod.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/DiscoveryClient.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/DiscoveryClient.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/InMemoryOidcSessionStore.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/InMemoryOidcSessionStore.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/JwtUtils.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/JwtUtils.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/JwtValidator.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/JwtValidator.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcAuthPolicy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcAuthPolicy.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcConfig.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcConfig.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcMiddleware.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcMiddleware.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcProviderMetadata.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcProviderMetadata.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcSession.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcSession.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcSessionStore.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcSessionStore.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcStateStore.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcStateStore.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcTokenResponse.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcTokenResponse.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcUser.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcUser.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcValidationException.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcValidationException.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/PkceUtils.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/PkceUtils.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/RolesAllowed.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/RolesAllowed.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/ScopesAllowed.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ScopesAllowed.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/TokenClient.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/TokenClient.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcAuthPolicyTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcAuthPolicyTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcMiddlewareAuthzTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcMiddlewareAuthzTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcOpenApiInteropTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcUserScopesTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcUserScopesTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/APIResponse.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/APIResponse.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/APIResponses.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/APIResponses.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiOperation.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ApiOperation.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ArraySchema.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ArraySchema.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/Content.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Content.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiBuilder.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiContributor.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiContributor.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiContributorRegistry.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiContributorRegistry.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiExtension.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiOperationContribution.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiOperationContribution.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiResponseContribution.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiResponseContribution.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/Parameter.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Parameter.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ParameterIn.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ParameterIn.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/Parameters.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Parameters.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/Schema.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Schema.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/SchemaProperty.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/SchemaProperty.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/SchemaType.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/SchemaType.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/ext/openapi/OpenApiBuilderTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/ext/openapi/OpenApiExtensionTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiExtensionTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/GraphSerializer.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/GraphSerializer.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/RouteViewerDataHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/RouteViewerDataHandler.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/RouteViewerExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/RouteViewerExtension.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/RouteViewerHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/RouteViewerHandler.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/RouteViewerStaticHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/RouteViewerStaticHandler.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/model/RouteGraph.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/model/RouteGraph.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/model/RouteRecord.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/model/RouteRecord.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/model/RouterNode.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/model/RouterNode.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/BaseViewExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/BaseViewExtension.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/BaseViewHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/BaseViewHandler.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/GlobalValue.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/GlobalValue.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/RenderedView.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/RenderedView.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/ViewModel.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/ViewModel.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/ViewRuntimeBridge.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/ViewRuntimeBridge.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/test/java/dev/relism/ext/view/core/ViewModelTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/test/java/dev/relism/flash/ext/view/core/ViewModelTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/README.md" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/docs/handlers.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/docs/handlers.md" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.class" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.class" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteExtension.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteHandler.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteRuntime.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteRuntime.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteSettings.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteSettings.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteTarget.java" beforeDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteTargetResolver.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteTargetResolver.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/Template.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/Template.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteExtensionTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteExtensionTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteRuntimeTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteRuntimeTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteSettingsTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteSettingsTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteTargetResolverTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteTargetResolverTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/model/HomePage.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/model/HomePage.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/resources/templates/pages/home.jte" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/resources/templates/pages/home.jte" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/README.md" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/docs/fragments.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/docs/fragments.md" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/docs/handlers.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/docs/handlers.md" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/Fragment.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/Fragment.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/Template.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/Template.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafExtension.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafHandler.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafRuntime.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafRuntime.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafSettings.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafSettings.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafTarget.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafTarget.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafTargetResolver.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafTargetResolver.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/ext/view/thymeleaf/ThymeleafExtensionTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafExtensionTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/ext/view/thymeleaf/ThymeleafRuntimeTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafRuntimeTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/ext/view/thymeleaf/ThymeleafSettingsTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafSettingsTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/ext/view/thymeleaf/ThymeleafTargetResolverTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafTargetResolverTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/README.md" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/asset-sources.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/asset-sources.md" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/configuration.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/configuration.md" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/dev-lifecycle.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/dev-lifecycle.md" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/frontend-selection.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/frontend-selection.md" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/modes.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/modes.md" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/package-managers.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/package-managers.md" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/performance.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/performance.md" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/prod-serving.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/prod-serving.md" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/routing-fallback.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/routing-fallback.md" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/security-policies.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/security-policies.md" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/pom.xml" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetCatalog.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetCatalog.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetEntry.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetEntry.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetIo.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetIo.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetLoadRequest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetLoadRequest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetMetadata.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetMetadata.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetPaths.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetPaths.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetsSource.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetsSource.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetsSources.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetsSources.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/BasePathEnforcementMode.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/BasePathEnforcementMode.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/ClasspathAssetManifest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ClasspathAssetManifest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/ClasspathAssetsSource.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ClasspathAssetsSource.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/CommandOrchestrator.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandOrchestrator.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/CommandSafetyMode.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandSafetyMode.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/CommandSafetyPolicy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandSafetyPolicy.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/CommandTokens.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandTokens.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/FilesystemAssetsSource.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FilesystemAssetsSource.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/FrontendStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendStrategy.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/FrontendType.java" beforeDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/FrontendTypeResolver.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendTypeResolver.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/InstallCache.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/InstallCache.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/InstallPolicy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/InstallPolicy.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/LoggingMode.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/LoggingMode.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/MimeTypes.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/MimeTypes.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/ModeResolver.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ModeResolver.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/OperationMode.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/OperationMode.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/PackageManager.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/PackageManager.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/PackageManagerAdapter.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/PackageManagerAdapter.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/RuntimeEnvironment.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/RuntimeEnvironment.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/RuntimeMode.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/RuntimeMode.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/SpaFallbackPolicy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/SpaFallbackPolicy.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/StaticAssetServingPolicy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticAssetServingPolicy.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/ViteFrontendStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ViteFrontendStrategy.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/WatchList.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WatchList.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/WebBundlerConfig.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerConfig.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/WebBundlerExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerExtension.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/WebBundlerRuntime.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerRuntime.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/AssetsSourceTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/AssetsSourceTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/CommandSafetyPolicyTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/CommandSafetyPolicyTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/InstallCacheTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/InstallCacheTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/ModeResolverTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/ModeResolverTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/PackageManagerAdapterTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/PackageManagerAdapterTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/WebBundlerConfigTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerConfigTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/WebBundlerExtensionDevGuardTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionDevGuardTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/WebBundlerExtensionIntegrationTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionIntegrationTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash-extensions/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/pom.xml" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/ChunkedInputStream.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/ChunkedInputStream.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/Flash.java" beforeDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/HttpServer.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/HttpServer.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/RequestParser.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/RequestParser.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/ServerHandle.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/ServerHandle.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/api/multipart/Multipart.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/api/multipart/Multipart.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/api/multipart/Part.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/api/multipart/Part.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/exceptions/HttpException.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/exceptions/HttpException.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/exceptions/InitializationException.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/exceptions/InitializationException.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/AnnotationProcessor.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/AnnotationProcessor.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/ExtensionPhase.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/ExtensionPhase.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashApp.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashApp.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashConfiguration.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashConfiguration.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashContext.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashContext.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashExtension.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashRegistrar.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashRegistrar.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashScope.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashScope.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/PackageScanner.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/PackageScanner.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/RouteDefinition.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/RouteDefinition.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/RouteEvent.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/RouteEvent.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/RouteListener.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/RouteListener.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/http/ContentType.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/http/ContentType.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/http/HttpMethod.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/http/HttpMethod.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/http/HttpStatus.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/http/HttpStatus.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/HeaderMap.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/HeaderMap.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/PathParams.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/PathParams.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/QueryParams.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/QueryParams.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/Request.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/Request.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/RequestBody.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/RequestBody.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/RequestHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/RequestHandler.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/RequestHelper.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/RequestHelper.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/RequestLine.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/RequestLine.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/Response.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/Response.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/SimpleHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/SimpleHandler.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/AbstractRouter.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/CONNECT.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/CONNECT.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/DELETE.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/DELETE.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/GET.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/GET.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/HEAD.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/HEAD.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/Middleware.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/Middleware.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/OPTIONS.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/OPTIONS.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/PATCH.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/PATCH.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/POST.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/POST.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/PURGE.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/PURGE.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/PUT.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/PUT.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/PathUtils.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/PathUtils.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/Route.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/Route.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/Routes.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/Routes.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/TRACE.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/TRACE.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImpl.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterImpl.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathViews.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathViews.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/routers/radix/RadixPathRouterImpl.java" beforeDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/template/ByteTemplate.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/template/ByteTemplate.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/template/ErrorPages.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/template/ErrorPages.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/ChunkedInputStreamTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/ChunkedInputStreamTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/HttpServerConcurrencyTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/HttpServerConcurrencyTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/HttpServerTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/HttpServerTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/RequestParserTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/RequestParserTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/api/multipart/MultipartTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/api/multipart/MultipartTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/http/ContentTypeTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/http/ContentTypeTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/http/HttpMethodTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/http/HttpMethodTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/http/HttpStatusTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/http/HttpStatusTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/HeaderMapTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/HeaderMapTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/PathParamsTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/PathParamsTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/QueryParamsTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/QueryParamsTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/RequestBodyTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/RequestBodyTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/RequestLineTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/RequestLineTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/RequestTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/RequestTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/ResponseTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/ResponseTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/SimpleHandlerTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/SimpleHandlerTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/AbstractRouterTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/routing/AbstractRouterTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/PathUtilsTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/routing/PathUtilsTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImplTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterImplTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathViewsTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathViewsTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/template/ByteTemplateTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/template/ByteTemplateTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/template/ErrorPagesTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/template/ErrorPagesTest.java" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/pom.xml" afterDir="false" />
|
|
||||||
</list>
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
||||||
<option name="LAST_RESOLUTION" value="IGNORE" />
|
<option name="LAST_RESOLUTION" value="IGNORE" />
|
||||||
</component>
|
</component>
|
||||||
|
<component name="CopilotChats">
|
||||||
|
<option name="panelChat">
|
||||||
|
<chat>
|
||||||
|
<option name="activeSessionId" value="a8d77f48-1eb9-4c5a-961c-1e1452a25ca0" />
|
||||||
|
<option name="sessions">
|
||||||
|
<session>
|
||||||
|
<option name="chatType" value="PANEL" />
|
||||||
|
<option name="id" value="a8d77f48-1eb9-4c5a-961c-1e1452a25ca0" />
|
||||||
|
<option name="modeId" value="Agent" />
|
||||||
|
<option name="modelType" value="builtin_family" />
|
||||||
|
<option name="modelValue" value="auto" />
|
||||||
|
<option name="status" value="Completed" />
|
||||||
|
<option name="targetType" value="LOCAL" />
|
||||||
|
</session>
|
||||||
|
<session>
|
||||||
|
<option name="chatType" value="PANEL" />
|
||||||
|
<option name="id" value="82f48e09-2ca4-4a27-9a04-c65d7a5f0b88" />
|
||||||
|
<option name="modeId" value="Agent" />
|
||||||
|
<option name="modelType" value="builtin_family" />
|
||||||
|
<option name="modelValue" value="auto" />
|
||||||
|
<option name="targetType" value="LOCAL" />
|
||||||
|
</session>
|
||||||
|
</option>
|
||||||
|
<option name="type" value="PANEL" />
|
||||||
|
</chat>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
<component name="CopilotPersistence">
|
<component name="CopilotPersistence">
|
||||||
<persistenceIdMap>
|
<persistenceIdMap>
|
||||||
<entry key="_C:/Users/elorc/Documents/Coding/Java/practice/Flash" value="3Axc6mzLyNvh4TgGFvNSCSq83xw" />
|
<entry key="_C:/Users/elorc/Documents/Coding/Java/practice/Flash" value="3Axc6mzLyNvh4TgGFvNSCSq83xw" />
|
||||||
@@ -298,7 +48,7 @@
|
|||||||
</persistenceIdMap>
|
</persistenceIdMap>
|
||||||
</component>
|
</component>
|
||||||
<component name="EmbeddingIndexingInfo">
|
<component name="EmbeddingIndexingInfo">
|
||||||
<option name="cachedIndexableFilesCount" value="407" />
|
<option name="cachedIndexableFilesCount" value="448" />
|
||||||
<option name="fileBasedEmbeddingIndicesEnabled" value="true" />
|
<option name="fileBasedEmbeddingIndicesEnabled" value="true" />
|
||||||
</component>
|
</component>
|
||||||
<component name="FileTemplateManagerImpl">
|
<component name="FileTemplateManagerImpl">
|
||||||
@@ -534,7 +284,17 @@
|
|||||||
<workItem from="1776931805422" duration="16122000" />
|
<workItem from="1776931805422" duration="16122000" />
|
||||||
<workItem from="1777051880577" duration="2650000" />
|
<workItem from="1777051880577" duration="2650000" />
|
||||||
<workItem from="1777150747725" duration="837000" />
|
<workItem from="1777150747725" duration="837000" />
|
||||||
<workItem from="1777198150359" duration="638000" />
|
<workItem from="1777198150359" duration="5628000" />
|
||||||
|
<workItem from="1777451402985" duration="709000" />
|
||||||
|
<workItem from="1777534738187" duration="13411000" />
|
||||||
|
<workItem from="1777970837797" duration="5042000" />
|
||||||
|
<workItem from="1778160998498" duration="2931000" />
|
||||||
|
<workItem from="1778319397472" duration="5040000" />
|
||||||
|
<workItem from="1778352978922" duration="2169000" />
|
||||||
|
<workItem from="1778414714349" duration="23000" />
|
||||||
|
<workItem from="1778417425077" duration="3241000" />
|
||||||
|
<workItem from="1778489168036" duration="9828000" />
|
||||||
|
<workItem from="1778576795735" duration="5051000" />
|
||||||
</task>
|
</task>
|
||||||
<task id="LOCAL-00001" summary="Initial">
|
<task id="LOCAL-00001" summary="Initial">
|
||||||
<option name="closed" value="true" />
|
<option name="closed" value="true" />
|
||||||
@@ -632,7 +392,31 @@
|
|||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1776724331443</updated>
|
<updated>1776724331443</updated>
|
||||||
</task>
|
</task>
|
||||||
<option name="localTasksCounter" value="13" />
|
<task id="LOCAL-00013" summary="refactor: rename packages and files to use 'flash' prefix for consistency">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1777199691803</created>
|
||||||
|
<option name="number" value="00013" />
|
||||||
|
<option name="presentableId" value="LOCAL-00013" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1777199691804</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00014" summary="fix: enhance global key validation and update template parameters">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1777451475753</created>
|
||||||
|
<option name="number" value="00014" />
|
||||||
|
<option name="presentableId" value="LOCAL-00014" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1777451475753</updated>
|
||||||
|
</task>
|
||||||
|
<task id="LOCAL-00015" summary="feat: introduce WebSocket support with new endpoints and transaction propagation enhancements">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1778508899541</created>
|
||||||
|
<option name="number" value="00015" />
|
||||||
|
<option name="presentableId" value="LOCAL-00015" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1778508899541</updated>
|
||||||
|
</task>
|
||||||
|
<option name="localTasksCounter" value="16" />
|
||||||
<servers />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
@@ -674,7 +458,12 @@
|
|||||||
<MESSAGE value="preparing for another refactoring..." />
|
<MESSAGE value="preparing for another refactoring..." />
|
||||||
<MESSAGE value="implement OpenAPI contributor integration for rate limiting and response headers" />
|
<MESSAGE value="implement OpenAPI contributor integration for rate limiting and response headers" />
|
||||||
<MESSAGE value="add core view extension with JTE and Thymeleaf support" />
|
<MESSAGE value="add core view extension with JTE and Thymeleaf support" />
|
||||||
<option name="LAST_COMMIT_MESSAGE" value="add core view extension with JTE and Thymeleaf support" />
|
<MESSAGE value="refactor: rename packages and files to use 'flash' prefix for consistency" />
|
||||||
|
<MESSAGE value="fix: enhance global key validation and update template parameters" />
|
||||||
|
<MESSAGE value="chore(release): prepare 2.1.0-SNAPSHOT" />
|
||||||
|
<MESSAGE value="feat: introduce Spec and Query interfaces with transaction propagation enhancements" />
|
||||||
|
<MESSAGE value="feat: introduce WebSocket support with new endpoints and transaction propagation enhancements" />
|
||||||
|
<option name="LAST_COMMIT_MESSAGE" value="feat: introduce WebSocket support with new endpoints and transaction propagation enhancements" />
|
||||||
</component>
|
</component>
|
||||||
<component name="XSLT-Support.FileAssociations.UIState">
|
<component name="XSLT-Support.FileAssociations.UIState">
|
||||||
<expand />
|
<expand />
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ Format: `<type>(<scope>): <short description>`
|
|||||||
| `chore` | Build, deps, tooling — no production code |
|
| `chore` | Build, deps, tooling — no production code |
|
||||||
| `ci` | Changes to GitHub Actions workflows |
|
| `ci` | Changes to GitHub Actions workflows |
|
||||||
|
|
||||||
Allowed scopes: `core`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`,
|
Allowed scopes: `core`, `testing`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`,
|
||||||
`ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-web-bundler`,
|
`ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-web-bundler`,
|
||||||
`ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`, `release`, `deps`, `ci`.
|
`ext-mcp`, `ext-validation`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`, `release`, `deps`, `ci`.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
```
|
```
|
||||||
@@ -85,6 +85,9 @@ chore(release): 2.1.0
|
|||||||
|
|
||||||
- Root POM: `flash-parent` — defines all dependency versions and plugin config.
|
- Root POM: `flash-parent` — defines all dependency versions and plugin config.
|
||||||
- `flash` module: the core framework JAR.
|
- `flash` module: the core framework JAR.
|
||||||
|
- `flash-testing` module: JUnit 5 harness for testing Flash applications. Deliberately not
|
||||||
|
under `flash-extensions/` — it is not something you `install()`, and it carries
|
||||||
|
`junit-jupiter-api` at compile scope.
|
||||||
- `flash-extensions` POM: aggregator for all extension modules.
|
- `flash-extensions` POM: aggregator for all extension modules.
|
||||||
- Extensions live under `flash-extensions/flash-ext-*/`.
|
- Extensions live under `flash-extensions/flash-ext-*/`.
|
||||||
- When adding a new extension:
|
- When adding a new extension:
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
# Flash
|
# Flash
|
||||||
|
|
||||||
A high-performance HTTP/1.1 server library for Java 21, built around virtual threads and a zero-allocation FSM router.
|
A high-performance HTTP/1.1 and HTTP/2 server library for Java 21, built around virtual threads,
|
||||||
|
a zero-allocation FSM router, bounded protocol state, and one shared request/response API.
|
||||||
|
|
||||||
## Modules
|
## Modules
|
||||||
|
|
||||||
| Module | Description |
|
| Module | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `flash` | Core server library — router, request parser, HTTP I/O transport |
|
| `flash` | Core server library — HTTP/1.1 and HTTP/2 transport, router, request/response model |
|
||||||
|
| `flash-testing` | JUnit 5 harness — boot an app on an ephemeral port, fake its services, assert on responses |
|
||||||
| `flash-extensions/flash-ext-jackson` | Jackson JSON integration |
|
| `flash-extensions/flash-ext-jackson` | Jackson JSON integration |
|
||||||
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI |
|
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI |
|
||||||
| `flash-extensions/flash-ext-oidc` | OIDC Authorization Code + PKCE flow |
|
| `flash-extensions/flash-ext-oidc` | OIDC Authorization Code + PKCE flow |
|
||||||
|
| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, optional OAuth2 via flash-ext-oidc |
|
||||||
| `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives |
|
| `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives |
|
||||||
| `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension |
|
| `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension |
|
||||||
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
|
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
|
||||||
| `flash-bench` | Demo harness (OIDC + OpenAPI + Jackson) |
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -57,31 +59,31 @@ app.post("/echo", (req, res) -> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get("/users/{id}", (req, res) -> {
|
app.get("/users/{id}", (req, res) -> {
|
||||||
String id = req.pathParam("id");
|
String id = req.param("id");
|
||||||
return "user:" + id;
|
return "user:" + id;
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Class-based handlers
|
### Class-based handlers
|
||||||
|
|
||||||
Extend `RequestHandler` (or a subclass like `JacksonHandler`) and annotate with `@Route`:
|
Extend `RequestHandler`, annotate it, then scan its package. Dependencies are cached in
|
||||||
|
`onInit()` after Flash has resolved its complete boot-time service graph:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
@Route(method = HttpMethod.GET, path = "/api/users")
|
@GET("/api/users")
|
||||||
public class ListUsers extends JacksonHandler {
|
public class ListUsers extends RequestHandler {
|
||||||
@Override
|
private UserService users;
|
||||||
public Object handle(Request req, Response res) throws Exception {
|
|
||||||
return json(res, List.of("alice", "bob"));
|
@Override protected void onInit() { users = require(UserService.class); }
|
||||||
}
|
@Override public Object handle(Request req, Response res) { return users.list(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register:
|
app.scan("dev.example.api");
|
||||||
app.register(new ListUsers());
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Middleware
|
### Middleware
|
||||||
|
|
||||||
Apply middleware via `.with()` on the `RouteHandle` returned by any registration call:
|
Apply middleware at registration. Flash composes the final chain at boot:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
Middleware authCheck = next -> (req, res) -> {
|
Middleware authCheck = next -> (req, res) -> {
|
||||||
@@ -90,14 +92,13 @@ Middleware authCheck = next -> (req, res) -> {
|
|||||||
return next.handle(req, res);
|
return next.handle(req, res);
|
||||||
};
|
};
|
||||||
|
|
||||||
app.get("/secure", (req, res) -> "secret data")
|
app.get("/secure", (req, res) -> "secret data", authCheck);
|
||||||
.with(authCheck);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Multiple middlewares are composed outermost-first (left-to-right in the call):
|
Multiple middlewares are composed outermost-first (left-to-right in the call):
|
||||||
|
|
||||||
```java
|
```java
|
||||||
app.get("/admin", handler).with(logging, auth, rateLimit);
|
app.get("/admin", handler, logging, auth, rateLimit);
|
||||||
// execution order: logging → auth → rateLimit → handler
|
// execution order: logging → auth → rateLimit → handler
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -119,22 +120,22 @@ processors, services):
|
|||||||
```java
|
```java
|
||||||
app.mount("/api", scope -> {
|
app.mount("/api", scope -> {
|
||||||
scope.get("/health", (req, res) -> "ok"); // → GET /api/health
|
scope.get("/health", (req, res) -> "ok"); // → GET /api/health
|
||||||
scope.register(new UserHandler()); // @Route(path="/users") → GET /api/users
|
|
||||||
scope.scan("dev.example.api");
|
scope.scan("dev.example.api");
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## Extensions
|
## Extensions
|
||||||
|
|
||||||
Extensions are installed before route registration. Each extension receives the `FlashRegistrar`
|
Extensions have one declarative `configure` method. They declare services, processors and route
|
||||||
and `FlashContext` — it can register routes, expose services, and register annotation processors.
|
callbacks; Flash resolves the complete graph, materialises routes, compiles both routers, then
|
||||||
|
opens listeners. Extension install order never makes a service “not ready”.
|
||||||
|
|
||||||
```java
|
```java
|
||||||
FlashApp.create(8080)
|
FlashApp.create(8080)
|
||||||
.install(new JacksonExtension())
|
.install(new JacksonExtension())
|
||||||
.install(new OpenApiExtension("/openapi", "My API", "1.0.0"))
|
.install(new OpenApiExtension("/openapi", "My API", "1.0.0"))
|
||||||
.install(new OidcExtension(oidcConfig))
|
.install(new OidcExtension(oidcConfig))
|
||||||
.register(new MyHandler())
|
.scan("dev.example.handlers")
|
||||||
.start();
|
.start();
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -142,8 +143,10 @@ See extension-specific READMEs for full details:
|
|||||||
- [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md)
|
- [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md)
|
||||||
- [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/README.md)
|
- [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/README.md)
|
||||||
- [`flash-ext-oidc`](flash-extensions/flash-ext-oidc/README.md)
|
- [`flash-ext-oidc`](flash-extensions/flash-ext-oidc/README.md)
|
||||||
|
- [`flash-ext-mcp`](flash-extensions/flash-ext-mcp/docs/README.md)
|
||||||
- [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/README.md)
|
- [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/README.md)
|
||||||
- [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md)
|
- [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md)
|
||||||
|
- [`flash-testing`](flash-testing/docs/README.md)
|
||||||
|
|
||||||
## Error handlers
|
## Error handlers
|
||||||
|
|
||||||
@@ -163,24 +166,353 @@ app.onException((ex, req, res) -> {
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `port` | — | TCP port to bind |
|
| `port` | — | TCP port to bind |
|
||||||
| `host` | `"0.0.0.0"` | Bind address |
|
| `host` | `"0.0.0.0"` | Bind address |
|
||||||
|
| `tls` | `null` | TLS for the default listener — see [TLS](#tls) |
|
||||||
|
| `listeners` | `[]` | Multiple bind targets (port + host + optional TLS) on one app — see [TLS](#tls) |
|
||||||
| `maxHeaderBufferSize` | `65536` | Max size of the header buffer (bytes) |
|
| `maxHeaderBufferSize` | `65536` | Max size of the header buffer (bytes) |
|
||||||
|
| `wsFrameBufferSize` | `65536` | Per-connection WebSocket read buffer (bytes) |
|
||||||
|
| `headerReadTimeoutMs` | `10000` | Once a request's first byte arrives, how long the full header block may take. Bounds slowloris-style attacks — see [`HTTP1-HARDENING.md`](flash/docs/core/HTTP1-HARDENING.md). |
|
||||||
|
| `idleKeepAliveTimeoutMs` | `60000` | How long a keep-alive connection may sit idle waiting for its next request. |
|
||||||
|
| `bodyReadTimeoutMs` | `30000` | How long reading a request body (handler or automatic drain) may take. |
|
||||||
|
| `shutdownDrainTimeoutMs` | `15000` | How long graceful shutdown waits for in-flight requests before force-closing. |
|
||||||
|
| `maxConnections` | auto (~heap/10MB) | Maximum concurrent connections across all listeners before new ones are closed immediately at accept time, before any per-connection state (TLS handshake included) is created. Auto-scales from `Runtime.maxMemory()`; set explicitly for a known deployment size, or `0` to disable. |
|
||||||
|
| `http2Enabled` | `false` | Whether TLS listeners advertise HTTP/2 through ALPN. |
|
||||||
|
| `http2CleartextEnabled` | `false` | Whether plaintext listeners accept HTTP/2 prior knowledge (h2c). Independent from TLS HTTP/2. |
|
||||||
|
| `h2HuffmanDynamicValues` | `false` | HPACK-Huffman encode runtime response values. Constants remain pre-encoded; the measured default avoids an extra encode pass. |
|
||||||
|
| `h2MaxResetStreamsPerInterval` | `200` | Rapid Reset budget per rolling interval. |
|
||||||
|
| `h2MaxStreamsCreatedPerInterval` | `400` | New-stream budget per rolling interval. |
|
||||||
|
| `h2AbuseRateIntervalMs` | `10000` | Rolling interval for the two operator-tunable rate limits above. |
|
||||||
|
| `h2MaxStreamsPerConnection` | `100000` | Total stream budget; `0` disables it. |
|
||||||
|
| `h2MaxBytesPerConnection` | `0` | Optional total wire-byte budget; `0` disables it. |
|
||||||
|
| `h2MaxConnectionLifetimeMs` | `0` | Optional connection lifetime; `0` disables it. |
|
||||||
|
| `h2StreamIdleTimeoutMs` | `60000` | Inactive open-stream deadline. |
|
||||||
|
| `sendDate` | `true` | Add an RFC 9110 `Date` field to responses; disable when an upstream proxy supplies it. |
|
||||||
|
|
||||||
|
## Protocols
|
||||||
|
|
||||||
|
Routes, middleware, `Request`, `Response`, bodies, trailers, streaming and WebSockets use the same
|
||||||
|
API on HTTP/1.1 and HTTP/2. Protocol selection happens once per connection:
|
||||||
|
|
||||||
|
- On TLS listeners, enable `http2Enabled`; Flash advertises `h2` and `http/1.1` through ALPN and
|
||||||
|
uses the protocol selected by the client. Existing HTTP/1.1 clients continue to work.
|
||||||
|
- On plaintext listeners, enable `http2CleartextEnabled` to accept the HTTP/2 prior-knowledge
|
||||||
|
preface on the same port as HTTP/1.1. Clients that do not send that exact preface are parsed as
|
||||||
|
HTTP/1.1.
|
||||||
|
- With both switches left at their default `false`, Flash behaves as an HTTP/1.1 server.
|
||||||
|
|
||||||
|
After enabling the appropriate switch, application routes need no protocol-specific code. TLS
|
||||||
|
still requires the normal certificate configuration shown below.
|
||||||
|
|
||||||
|
Flash deliberately does not implement HTTP/2 server push, RFC 7540 dependency-tree priority
|
||||||
|
scheduling, or the obsolete HTTP/1.1 `Upgrade: h2c` transition. Server push has no application API,
|
||||||
|
RFC 9113 deprecated the old priority scheme, and cleartext HTTP/2 uses prior knowledge instead.
|
||||||
|
See the [HTTP/2 compliance record](flash/docs/http2/COMPLIANCE.md) for exact coverage.
|
||||||
|
|
||||||
|
## WebSockets over HTTP/2
|
||||||
|
|
||||||
|
The same `ws(path, handler)` route serves WebSockets over HTTP/1.1 and HTTP/2. When HTTP/2 is
|
||||||
|
enabled, Flash advertises RFC 8441 extended CONNECT support and carries WebSocket frames inside
|
||||||
|
flow-controlled DATA frames. No alternate handler, route, or session API is required:
|
||||||
|
|
||||||
|
```java
|
||||||
|
app.ws("/live", handler);
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP/1.1 clients use the ordinary `101 Switching Protocols` upgrade. HTTP/2 clients use an
|
||||||
|
extended CONNECT and receive status `200`; Flash applies the same RFC 6455 framing, masking,
|
||||||
|
fragmentation, close, and callback behavior on both transports. Client support for negotiating
|
||||||
|
WebSockets over HTTP/2 varies, so clients without RFC 8441 support continue to use HTTP/1.1.
|
||||||
|
|
||||||
|
## TLS
|
||||||
|
|
||||||
|
HTTPS and WSS are a transport-layer concern only. Once a listener is bound, the accepted socket
|
||||||
|
is plain or TLS; the selected HTTP connection implementation then performs either the HTTP/1.1
|
||||||
|
upgrade or the HTTP/2 extended CONNECT. WSS does not require a separate route or handler API.
|
||||||
|
|
||||||
|
### Quick start
|
||||||
|
|
||||||
|
```java
|
||||||
|
FlashApp.create(FlashConfiguration.builder()
|
||||||
|
.port(443)
|
||||||
|
.tls(TlsConfig.keystore(Path.of("cert.p12"), "changeit"))
|
||||||
|
.build())
|
||||||
|
.get("/ping", (req, res) -> "pong") // HTTPS
|
||||||
|
.ws("/live", handler) // WSS, same route API
|
||||||
|
.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple listeners
|
||||||
|
|
||||||
|
One app can bind any number of ports, each independently plain or TLS:
|
||||||
|
|
||||||
|
```java
|
||||||
|
FlashApp.create(FlashConfiguration.builder()
|
||||||
|
.listener(new FlashConfiguration.Listener(80)) // plain
|
||||||
|
.listener(new FlashConfiguration.Listener(443, TlsConfig.keystore(cert, pass))) // TLS
|
||||||
|
.build());
|
||||||
|
```
|
||||||
|
|
||||||
|
A non-empty `listeners` list takes precedence over the top-level `port`/`host`/`tls` fields.
|
||||||
|
Each listener gets its own accept threads; the router, WS router, and virtual-thread executor
|
||||||
|
are shared by all of them — one app, N ports.
|
||||||
|
|
||||||
|
### `TlsConfig`
|
||||||
|
|
||||||
|
| Factory | Use |
|
||||||
|
|---|---|
|
||||||
|
| `TlsConfig.keystore(Path, String)` | Builds the `SSLContext` from a PKCS12/JKS keystore (type guessed from the extension). Pins `TLSv1.2`/`TLSv1.3` as enabled protocols; cipher suites are left at the JDK's own curated default. |
|
||||||
|
| `TlsConfig.ofContext(SSLContext)` | Escape hatch — the given `SSLContext` is used exactly as built. Flash never calls `setSSLParameters` on this path beyond what you explicitly request via `clientAuth`/`applicationProtocols`, so anything else you configured (custom `KeyManager`, ALPN, cipher suites) is authoritative. |
|
||||||
|
|
||||||
|
Chainable on either factory:
|
||||||
|
|
||||||
|
```java
|
||||||
|
TlsConfig.keystore(cert, pass)
|
||||||
|
.clientAuth(ClientAuth.REQUIRE) // mTLS: NONE (default) | OPTIONAL | REQUIRE
|
||||||
|
.applicationProtocols("acme-tls/1", "http/1.1") // ALPN, in preference order
|
||||||
|
```
|
||||||
|
|
||||||
|
**SNI** falls out of `keystore()` for free: a keystore holding more than one certificate entry
|
||||||
|
is matched against the requested hostname by each certificate's SAN (falling back to CN) — no
|
||||||
|
per-hostname config. The first entry in the keystore is the default when SNI is absent or
|
||||||
|
matches nothing (same convention as nginx/HAProxy's `default_server`).
|
||||||
|
|
||||||
|
**ALPN and custom certificate selection** (e.g. TLS-ALPN-01 / RFC 8737 for on-demand ACME
|
||||||
|
issuance): ALPN is resolved while consuming `ClientHello`/producing `ServerHello`, which always
|
||||||
|
precedes `Certificate` production. A custom `X509ExtendedKeyManager` passed via `ofContext`
|
||||||
|
can therefore read `engine.getHandshakeApplicationProtocol()` (or
|
||||||
|
`((SSLSocket) socket).getHandshakeApplicationProtocol()`) inside
|
||||||
|
`chooseEngineServerAlias`/`chooseServerAlias` — the negotiated protocol is already resolved by
|
||||||
|
then, so the certificate decision can key off it.
|
||||||
|
|
||||||
|
**mTLS with a private CA**: `clientAuth(...)` only requests/requires a client certificate;
|
||||||
|
`keystore()` deliberately doesn't expose a way to configure which CAs are trusted for that
|
||||||
|
certificate (it uses the JDK default trust store). For a private CA, build the `SSLContext`
|
||||||
|
yourself with a `TrustManagerFactory` and use `ofContext(...)`.
|
||||||
|
|
||||||
|
### Reading TLS info from a request
|
||||||
|
|
||||||
|
```java
|
||||||
|
app.get("/whoami", (req, res) -> {
|
||||||
|
if (!req.isSecure()) return "plain";
|
||||||
|
SSLSession session = req.sslSession(); // null iff !isSecure()
|
||||||
|
X509Certificate peer = (X509Certificate) session.getPeerCertificates()[0]; // mTLS only
|
||||||
|
return session.getCipherSuite() + " / " + session.getProtocol();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`Request.isSecure()` / `Request.sslSession()` cost nothing extra per request: the `SSLSocket`
|
||||||
|
reference is threaded through once per connection (same mechanism as `remoteAddress()`), and
|
||||||
|
`sslSession()` only calls `SSLSocket#getSession()` — a cached-field read once the handshake
|
||||||
|
that got the request this far has already completed, never a forced handshake.
|
||||||
|
|
||||||
|
`WebSocketSession` mirrors this exactly (`isSecure()`, `sslSession()`) by delegating to the
|
||||||
|
upgrading `Request` — no separate TLS state is tracked for WS.
|
||||||
|
|
||||||
|
## Object lifetime
|
||||||
|
|
||||||
|
`Request` and `Response` are **pooled per connection**, not allocated per request: one instance is
|
||||||
|
created per connection and repositioned (`reset()`) over each new request/response in turn — the
|
||||||
|
same idiom Java NIO buffers use, applied to the whole request/response model
|
||||||
|
(`flash/docs/core/MESSAGE-MODEL.md` has the full design record). This is what makes a warm h1
|
||||||
|
request/response cycle 0 B/op.
|
||||||
|
|
||||||
|
**Do not retain a `Request` or `Response` past the handler that received it.** A reference kept in
|
||||||
|
a field, a captured closure, a `CompletableFuture` continuation, or a background thread and read
|
||||||
|
*after* the handler returns will observe whatever the *next* request on that connection
|
||||||
|
repositioned the same instance to — not the request you thought you had:
|
||||||
|
|
||||||
|
```java
|
||||||
|
// WRONG — captures `req`, reads it after the handler has returned
|
||||||
|
app.get("/slow", (req, res) -> {
|
||||||
|
CompletableFuture.runAsync(() -> log(req.header("X-Trace-Id"))); // may log the NEXT request's header
|
||||||
|
return "ok";
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy out whatever you need before returning or handing work off asynchronously — every accessor
|
||||||
|
that returns a `String` (`header`, `param`, `query`, `path`, …) gives you an independent heap copy
|
||||||
|
that's safe to keep as long as you like:
|
||||||
|
|
||||||
|
```java
|
||||||
|
app.get("/slow", (req, res) -> {
|
||||||
|
String traceId = req.header("X-Trace-Id"); // copy now, safe to retain
|
||||||
|
CompletableFuture.runAsync(() -> log(traceId));
|
||||||
|
return "ok";
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Run with `-Dflash.env=dev` and a use-after-return access throws `IllegalStateException` immediately
|
||||||
|
at the offending call site instead of silently reading the wrong request's data — turn this on in
|
||||||
|
tests and local development. It's a no-op in production beyond a single `boolean` field read.
|
||||||
|
|
||||||
|
`req.body()`/`RequestBody` follows the same rule — materialise (`.bytes()`) or fully consume
|
||||||
|
(`.stream()`) it inside the handler; don't stash the `RequestBody` itself for later.
|
||||||
|
|
||||||
|
### Reusable response headers
|
||||||
|
|
||||||
|
Use `PreEncodedHeader` for a constant header sent by many responses. It stores the name and value
|
||||||
|
once and remains valid on both HTTP versions:
|
||||||
|
|
||||||
|
```java
|
||||||
|
private static final PreEncodedHeader NO_STORE =
|
||||||
|
new PreEncodedHeader("cache-control", "no-store");
|
||||||
|
|
||||||
|
app.get("/health", (req, res) -> res.header(NO_STORE).body("ok"));
|
||||||
|
```
|
||||||
|
|
||||||
|
`Response.header(byte[])` accepts a complete CRLF-terminated HTTP/1 field line and is therefore
|
||||||
|
HTTP/1-only; HPACK needs the name and value as separate fields. Prefer `PreEncodedHeader` for shared
|
||||||
|
application and middleware code.
|
||||||
|
|
||||||
|
### Trailers and push streaming
|
||||||
|
|
||||||
|
Request trailers become available after the body reaches EOF:
|
||||||
|
|
||||||
|
```java
|
||||||
|
byte[] payload = req.body().bytes();
|
||||||
|
String status = req.trailers().first("grpc-status");
|
||||||
|
```
|
||||||
|
|
||||||
|
For a producer-driven response, `Response.streaming` provides a blocking `ResponseStream`. Its
|
||||||
|
bounded buffer and HTTP/2 flow-control windows apply backpressure directly to the producer's
|
||||||
|
virtual thread:
|
||||||
|
|
||||||
|
```java
|
||||||
|
return res.streaming(stream -> {
|
||||||
|
try {
|
||||||
|
stream.write(payload, 0, payload.length);
|
||||||
|
stream.trailer("result", "complete");
|
||||||
|
} catch (IOException failure) {
|
||||||
|
throw new UncheckedIOException(failure);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The API renders as chunked data and trailers on HTTP/1.1, and DATA plus trailing HEADERS on
|
||||||
|
HTTP/2. Flash core supplies these transport primitives; a higher-level gRPC codec belongs in a
|
||||||
|
future `flash-ext-grpc` extension.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
`flash-testing` boots a real app on an OS-assigned port for the duration of a test, and hands you
|
||||||
|
a client pointed at it. Add it with test scope:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-testing</artifactId>
|
||||||
|
<version>${flash.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
```java
|
||||||
|
class UserRoutesTest {
|
||||||
|
|
||||||
|
@RegisterExtension
|
||||||
|
static FlashTest app = FlashTest.of(new BlogApp())
|
||||||
|
.mock(UserService.class, new InMemoryUserService());
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listsUsers() {
|
||||||
|
app.get("/api/users")
|
||||||
|
.expectStatus(200)
|
||||||
|
.expectHeader("content-type", "application/json")
|
||||||
|
.expectBodyContains("alice");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`FlashTest.of` takes a `FlashApplication` — your app's routes, extensions and services expressed
|
||||||
|
independently of which port they run on:
|
||||||
|
|
||||||
|
```java
|
||||||
|
public final class BlogApp implements FlashApplication {
|
||||||
|
@Override public void configure(FlashApp app) {
|
||||||
|
app.install(new JacksonExtension());
|
||||||
|
app.mount("/api", scope -> scope.scan("dev.blog.api"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FlashApp.create(8080).apply(new BlogApp()).startAndBlock(); // production
|
||||||
|
```
|
||||||
|
|
||||||
|
It is a functional interface, so a lambda works too:
|
||||||
|
`FlashTest.of(app -> app.get("/ping", (req, res) -> "pong"))`.
|
||||||
|
|
||||||
|
### Requests
|
||||||
|
|
||||||
|
The HTTP verb sends the request; `expect*` assertions chain and report the real response body on
|
||||||
|
failure. `get` and `delete` skip the builder when there is nothing to add.
|
||||||
|
|
||||||
|
```java
|
||||||
|
app.get("/api/users").expectStatus(200);
|
||||||
|
|
||||||
|
app.request()
|
||||||
|
.header("Authorization", "Bearer " + token)
|
||||||
|
.json("{\"name\":\"bob\"}")
|
||||||
|
.post("/api/users")
|
||||||
|
.expectStatus(201);
|
||||||
|
|
||||||
|
try (FlashWebSocket socket = app.ws("/live")) {
|
||||||
|
socket.sendText("hello");
|
||||||
|
assertEquals("echo:hello", socket.awaitText(Duration.ofSeconds(2)));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Replacing services
|
||||||
|
|
||||||
|
`mock` installs replacements after everything your app and its extensions declare, so a fake always
|
||||||
|
wins. Any object will do — `flash-testing` depends on no mocking library, so a hand-written fake and
|
||||||
|
a Mockito mock are equally welcome.
|
||||||
|
|
||||||
|
### More than one server
|
||||||
|
|
||||||
|
`FlashTest` is an ordinary object in a field, so a test class can hold as many as it needs and wire
|
||||||
|
one from another in plain Java. Startup is lazy — reading `baseUri()` boots that server on the spot
|
||||||
|
— so declaration order does the wiring:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@RegisterExtension static FlashTest auth = FlashTest.of(new FakeOidcApp());
|
||||||
|
@RegisterExtension static FlashTest api = FlashTest.of(new BlogApp(auth.baseUri()));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
A `static` field boots once for the test class; a non-static field boots a fresh app for every test.
|
||||||
|
That is stock JUnit field semantics — the isolation switch is the keyword, not an option.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Full reference: [`flash-testing/docs`](flash-testing/docs/README.md), including the
|
||||||
|
[limits](flash-testing/docs/limits.md) the harness deliberately does not cross.
|
||||||
|
|
||||||
|
`profile` customises the `FlashConfiguration` — timeouts, HTTP/2 switches, buffer sizes. Host, port
|
||||||
|
and the shutdown drain window are stamped afterwards, so a profile cannot break the harness;
|
||||||
|
`listener(...)` and `tls(...)` are rejected because the harness owns the loopback listener it gives
|
||||||
|
you a client for.
|
||||||
|
|
||||||
|
```java
|
||||||
|
FlashTest.of(new BlogApp()).profile(cfg -> cfg.http2CleartextEnabled(true));
|
||||||
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
ServerSocket.accept()
|
TransportFactory.create() # binds every listener, wires the connection runner
|
||||||
→ RequestParser.parse() # zero-alloc header parsing, buffer reuse across keep-alive
|
→ AcceptLoop # one per listener × accept thread; hands sockets off
|
||||||
→ GlobalRouter.route() # two-tier: mounted sub-routers (longest prefix) then FastPathRouterImpl
|
→ ConnectionRunner.accept() # per-connection setup: TLS handshake, protocol negotiation
|
||||||
→ RequestHandler.handle() # user handler; return value sets body
|
→ ProtocolNegotiator # ALPN / h2c-preface — decides the protocol once
|
||||||
→ Request.drain() # consume unread body for keep-alive
|
├─ Http1Connection.run() # request parser, router, handler, h1 response writer
|
||||||
→ HttpServer writes response # status line, headers, then fixed or chunked body
|
└─ Http2Connection.run() # frame demux, HPACK, stream dispatch, flow control
|
||||||
→ loop or close socket # based on Connection header
|
→ RequestHandler.handle() # the same protocol-neutral request/response API
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Virtual threads** — each accepted socket runs on a virtual thread (`Executors.newVirtualThreadPerTaskExecutor()`). Java 21 required.
|
- **Virtual threads** — each accepted socket runs on a virtual thread (`Executors.newVirtualThreadPerTaskExecutor()`, owned by `TransportFactory`). Java 21 required.
|
||||||
- **Zero-allocation router** — `FastPathRouterImpl` uses `fpr-core`, a byte-level FSM that matches on `METHOD + path` bytes with no per-request allocation.
|
- **Zero-allocation router** — `FastPathRouterImpl` uses `fpr-core`, a byte-level FSM that matches on `METHOD + path` bytes with no per-request allocation.
|
||||||
- **Keep-alive** — `RequestParser` reuses its header buffer across requests on the same connection.
|
- **Keep-alive** — `RequestParser` reuses its header buffer across requests on the same connection.
|
||||||
- **Chunked transfer** — both chunked request bodies (decoded via `ChunkedInputStream`) and chunked response bodies are supported.
|
- **Chunked transfer** — both chunked request bodies (decoded via `ChunkedInputStream`) and chunked response bodies are supported.
|
||||||
|
- **TLS is transport-only** — see [TLS](#tls). Listeners bind either a plain `ServerSocket` or an `SSLServerSocket`; nothing downstream of `accept()` branches on which.
|
||||||
|
- **`ConnectionProtocol` seam** — HTTP/1.1 and HTTP/2 are peers behind this interface, selected once per connection by `ProtocolNegotiator`; routing and application models are shared.
|
||||||
|
|
||||||
## Build & test
|
## Build & test
|
||||||
|
|
||||||
@@ -193,7 +525,4 @@ mvn test
|
|||||||
|
|
||||||
# Run a single test class
|
# Run a single test class
|
||||||
mvn test -pl flash -Dtest=RequestParserTest
|
mvn test -pl flash -Dtest=RequestParserTest
|
||||||
|
|
||||||
# Run the benchmark demo server
|
|
||||||
java -jar flash-bench/target/flash-bench-1.0-SNAPSHOT.jar
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
config:
|
||||||
|
target: "ws://localhost:8080/echo"
|
||||||
|
engines:
|
||||||
|
ws: {}
|
||||||
|
phases:
|
||||||
|
- duration: 30
|
||||||
|
arrivalRate: 50
|
||||||
|
rampTo: 500
|
||||||
|
name: "Riscaldamento progressivo"
|
||||||
|
- duration: 120
|
||||||
|
arrivalRate: 1000 # 1000 nuovi utenti al secondo
|
||||||
|
name: "Carico Estremo"
|
||||||
|
ensure:
|
||||||
|
maxErrorRate: 5
|
||||||
|
p99: 150
|
||||||
|
|
||||||
|
scenarios:
|
||||||
|
- name: "Saturazione Totale"
|
||||||
|
engine: ws
|
||||||
|
flow:
|
||||||
|
- loop:
|
||||||
|
- send: "Benchmark data"
|
||||||
|
# Rimosso il 'think' per eliminare il limite artificiale di 10msg/s per utente
|
||||||
|
count: 100 # Ogni utente spara a raffica 100 messaggi senza pause
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# flash-ext-data-core
|
||||||
|
|
||||||
|
Shared core for Flash's data layer.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This module defines the transactional contract shared across backend implementations. It does not
|
||||||
|
talk to Hibernate or JDBC directly: it exposes abstractions and a minimal runtime, nothing else.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
- `TxDefinition`: immutable transaction metadata.
|
||||||
|
- `TxStatus`: runtime state returned by the manager.
|
||||||
|
- `TxManager`: the `begin`/`commit`/`rollback` contract.
|
||||||
|
- `Tx`: runtime orchestration and the per-thread transaction stack.
|
||||||
|
- `ResourceRegistry`: thread-local storage for resources and synchronizations.
|
||||||
|
- `Repository<T, ID>`: self-transactional base repository.
|
||||||
|
- `Spec<T>`: composable predicate.
|
||||||
|
- `Query<T>`: query object carrying spec, sort and paging.
|
||||||
|
- `SpecBuilder<T>`: fluent DSL for building typed specs.
|
||||||
|
- `RepositorySupport<T, ID>`: shared internal helper.
|
||||||
|
- `TransactionPropagation`: propagation semantics.
|
||||||
|
- `TransactionIsolation`: isolation level.
|
||||||
|
- `TxSynchronization`: lifecycle hooks (see below).
|
||||||
|
|
||||||
|
## Execution model
|
||||||
|
|
||||||
|
The flow is:
|
||||||
|
|
||||||
|
1. `Tx.call(definition, work)` calls `TxManager.begin(definition)`.
|
||||||
|
2. The `TxManager` creates a backend-specific `TxStatus`.
|
||||||
|
3. The status is pushed onto the thread-local stack.
|
||||||
|
4. The work uses `Tx.resource(Class)` to obtain the current resource.
|
||||||
|
5. When the work ends, `Tx` chooses between `commit` and `rollback`.
|
||||||
|
6. The stack is popped, and the thread-local is cleared once it is empty.
|
||||||
|
|
||||||
|
## Supported propagation
|
||||||
|
|
||||||
|
- `REQUIRED`: use the active transaction, or open a new one.
|
||||||
|
- `REQUIRES_NEW`: suspend the current transaction and open a new one.
|
||||||
|
- `SUPPORTS`: join the active transaction if there is one, otherwise run without a transaction.
|
||||||
|
- `NOT_SUPPORTED`: suspend the current transaction and run without one.
|
||||||
|
- `MANDATORY`: require an active transaction.
|
||||||
|
|
||||||
|
## Synchronizations (`TxSynchronization`)
|
||||||
|
|
||||||
|
Lifecycle hooks for **one** transaction, registered through `Data.afterCommit(...)` (or directly
|
||||||
|
with `ResourceRegistry.addSynchronization(...)`).
|
||||||
|
|
||||||
|
Every callback belongs to exactly the innermost transaction active at registration time, and fires
|
||||||
|
exactly once, when *that* transaction completes:
|
||||||
|
|
||||||
|
- a **joined** inner transaction (`REQUIRED`) is not a transaction of its own, so callbacks
|
||||||
|
registered inside one wait for the outermost commit;
|
||||||
|
- a `REQUIRES_NEW` transaction is, so completing it fires only its own callbacks and leaves the
|
||||||
|
suspended outer transaction's pending.
|
||||||
|
|
||||||
|
### Which side of the commit each hook sits on
|
||||||
|
|
||||||
|
| hook | when | resource |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `beforeCommit(readOnly)` | immediately **before** the real commit | session/connection still **bound**, transaction still active |
|
||||||
|
| `afterCommit()` / `afterRollback()` | after completion | resource already **unbound** |
|
||||||
|
| `afterCompletion(outcome)` | after the two above | resource already unbound |
|
||||||
|
|
||||||
|
`beforeCommit` is the only hook that can still write through the same resource and have the write
|
||||||
|
land in the same atomic unit: flush a buffer, stamp an audit row, materialize a derived value. It
|
||||||
|
is skipped when the transaction is already `rollback-only`, since there is no commit to precede.
|
||||||
|
|
||||||
|
Post-completion callbacks run with the resource unbound instead: one that opens its own transaction
|
||||||
|
gets a **fresh** one rather than joining the transaction that just finished. That is what makes
|
||||||
|
them the right place to refresh a cache, enqueue a message, or notify anything outside the
|
||||||
|
database.
|
||||||
|
|
||||||
|
### Failure
|
||||||
|
|
||||||
|
Throwing from `beforeCommit` **vetoes the commit**: the transaction is rolled back,
|
||||||
|
`afterRollback`/`afterCompletion(ROLLED_BACK)` fire, and the exception reaches the caller. That is
|
||||||
|
the reason the hook runs before the commit rather than after — it can still refuse.
|
||||||
|
|
||||||
|
The post-completion hooks have no such power: the transaction is already over by the time they run,
|
||||||
|
so an exception propagates but changes nothing already committed, and stops the callbacks queued
|
||||||
|
behind it.
|
||||||
|
|
||||||
|
## Using `Repository`
|
||||||
|
|
||||||
|
`Repository` is the shared base for concrete repositories. Every public operation internally uses a
|
||||||
|
`REQUIRED` transaction, read-only where applicable.
|
||||||
|
|
||||||
|
Subclasses implement the `doXxx(...)` methods:
|
||||||
|
|
||||||
|
- `doFind(Query<T>)`
|
||||||
|
- `doFindOne(Spec<T>)`
|
||||||
|
- `doFindPage(Query<T>)`
|
||||||
|
- `doDeleteAll(Spec<T>)`
|
||||||
|
- `doUpdateAll(Spec<T>, T)`
|
||||||
|
|
||||||
|
The old `findAll(...)` and `findPage(...)` overloads were reduced to a combination of `Query<T>` and
|
||||||
|
`Spec<T>`.
|
||||||
|
|
||||||
|
```java
|
||||||
|
public abstract class Repository<T, ID> {
|
||||||
|
protected Repository(Tx tx) { ... }
|
||||||
|
protected final <R> R tx(Tx.TxCallable<R> work) { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Composing with Flash
|
||||||
|
|
||||||
|
`DataExtension` registers:
|
||||||
|
|
||||||
|
- `Tx` in the `FlashContext`
|
||||||
|
- `TxManager` in the `FlashContext`
|
||||||
|
- an annotation processor for `@Transactional`
|
||||||
|
|
||||||
|
This makes the data layer composable with Flash's extension system without global state.
|
||||||
|
|
||||||
|
## Implementation notes
|
||||||
|
|
||||||
|
- The transaction stack is thread-local and is cleared once it becomes empty.
|
||||||
|
- Backend resources are suspended and restored for `REQUIRES_NEW` and `NOT_SUPPORTED`.
|
||||||
|
- `TxSynchronization` is the hook point for commit/rollback/completion callbacks.
|
||||||
|
- Synchronizations live in a thread-local list; every new transaction records how many were already
|
||||||
|
registered when it opened and fires only its own tail, so a `REQUIRES_NEW` does not drag along
|
||||||
|
the suspended transaction's callbacks.
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash-extensions</artifactId>
|
<artifactId>flash-extensions</artifactId>
|
||||||
<version>2.0.0</version>
|
<version>2.1.0-SNAPSHOT</version>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<artifactId>flash-ext-data-core</artifactId>
|
<artifactId>flash-ext-data-core</artifactId>
|
||||||
|
|||||||
+20
-12
@@ -1,13 +1,15 @@
|
|||||||
package dev.relism.flash.ext.data;
|
package dev.relism.flash.ext.data;
|
||||||
|
|
||||||
import dev.relism.flash.ext.data.core.Tx;
|
import dev.relism.flash.ext.data.core.Tx;
|
||||||
|
import dev.relism.flash.ext.data.core.Data;
|
||||||
import dev.relism.flash.ext.data.core.TxDefinition;
|
import dev.relism.flash.ext.data.core.TxDefinition;
|
||||||
import dev.relism.flash.ext.data.core.TxManager;
|
import dev.relism.flash.ext.data.core.TxManager;
|
||||||
import dev.relism.flash.ext.data.core.TransactionPropagation;
|
import dev.relism.flash.ext.data.core.TransactionPropagation;
|
||||||
import dev.relism.flash.extension.ExtensionPhase;
|
import dev.relism.flash.extension.FlashRegistrar;
|
||||||
import dev.relism.flash.extension.FlashContext;
|
import dev.relism.flash.extension.FlashContext;
|
||||||
import dev.relism.flash.extension.FlashExtension;
|
import dev.relism.flash.extension.FlashExtension;
|
||||||
import dev.relism.flash.extension.FlashRegistrar;
|
import dev.relism.flash.routing.MiddlewareKey;
|
||||||
|
import dev.relism.flash.routing.MiddlewareNode;
|
||||||
import dev.relism.flash.routing.Middleware;
|
import dev.relism.flash.routing.Middleware;
|
||||||
import jakarta.transaction.Transactional;
|
import jakarta.transaction.Transactional;
|
||||||
|
|
||||||
@@ -15,16 +17,26 @@ import java.util.List;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
public final class DataExtension implements FlashExtension {
|
public final class DataExtension implements FlashExtension {
|
||||||
|
private static final MiddlewareKey TRANSACTION = MiddlewareKey.of("flash.data.transaction");
|
||||||
private final TxManager txManager;
|
private final TxManager txManager;
|
||||||
|
private final Tx tx;
|
||||||
|
private final Data data;
|
||||||
|
|
||||||
public DataExtension(TxManager txManager) {
|
public DataExtension(TxManager txManager) {
|
||||||
|
this(txManager, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DataExtension(TxManager txManager, Data data) {
|
||||||
this.txManager = Objects.requireNonNull(txManager);
|
this.txManager = Objects.requireNonNull(txManager);
|
||||||
|
this.data = data;
|
||||||
|
this.tx = data != null ? data.tx() : new Tx(txManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void provide(FlashContext ctx) {
|
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
Tx.init(txManager);
|
ctx.provide(Tx.class, tx);
|
||||||
ctx.provide(TxManager.class, txManager);
|
ctx.provide(TxManager.class, txManager);
|
||||||
|
if (data != null) ctx.provide(Data.class, data);
|
||||||
ctx.addAnnotationProcessor(handlerClass -> {
|
ctx.addAnnotationProcessor(handlerClass -> {
|
||||||
Transactional ann = handlerClass.getAnnotation(Transactional.class);
|
Transactional ann = handlerClass.getAnnotation(Transactional.class);
|
||||||
if (ann == null) {
|
if (ann == null) {
|
||||||
@@ -33,21 +45,17 @@ public final class DataExtension implements FlashExtension {
|
|||||||
TxDefinition definition = TxDefinition.DEFAULTS
|
TxDefinition definition = TxDefinition.DEFAULTS
|
||||||
.withPropagation(mapTxType(ann.value()));
|
.withPropagation(mapTxType(ann.value()));
|
||||||
Middleware middleware = next -> (req, res) -> {
|
Middleware middleware = next -> (req, res) -> {
|
||||||
return Tx.call(definition, () -> next.handle(req, res));
|
return tx.call(definition, () -> next.handle(req, res));
|
||||||
};
|
};
|
||||||
return List.of(middleware);
|
return List.of(MiddlewareNode.of(TRANSACTION, middleware));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public int priority() {
|
|
||||||
return ExtensionPhase.EARLY.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private TransactionPropagation mapTxType(Transactional.TxType txType) {
|
private TransactionPropagation mapTxType(Transactional.TxType txType) {
|
||||||
return switch (txType) {
|
return switch (txType) {
|
||||||
case REQUIRED, SUPPORTS -> TransactionPropagation.REQUIRED;
|
case REQUIRED -> TransactionPropagation.REQUIRED;
|
||||||
case REQUIRES_NEW -> TransactionPropagation.REQUIRES_NEW;
|
case REQUIRES_NEW -> TransactionPropagation.REQUIRES_NEW;
|
||||||
|
case SUPPORTS -> TransactionPropagation.SUPPORTS;
|
||||||
case MANDATORY -> TransactionPropagation.MANDATORY;
|
case MANDATORY -> TransactionPropagation.MANDATORY;
|
||||||
case NOT_SUPPORTED, NEVER -> TransactionPropagation.NOT_SUPPORTED;
|
case NOT_SUPPORTED, NEVER -> TransactionPropagation.NOT_SUPPORTED;
|
||||||
};
|
};
|
||||||
|
|||||||
+46
@@ -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(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+99
-90
@@ -4,109 +4,118 @@ import java.util.ArrayList;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
public abstract class Repository<T, ID> extends RepositorySupport<T, ID> {
|
||||||
* Base repository. Subclasses only extend this — never HibernateRepository
|
|
||||||
* or JdbcRepository directly. The concrete backing is transparent.
|
|
||||||
*
|
|
||||||
* Every method auto-wraps in REQUIRED transaction — safe to call with or
|
|
||||||
* without an active transaction on the thread.
|
|
||||||
*/
|
|
||||||
public abstract class Repository<T, ID> {
|
|
||||||
|
|
||||||
private final TxDefinition required = TxDefinition.DEFAULTS
|
protected Repository(Tx tx) {
|
||||||
.withPropagation(TransactionPropagation.REQUIRED);
|
super(tx);
|
||||||
|
}
|
||||||
// ── CRUD ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public Optional<T> findById(ID id) {
|
public Optional<T> findById(ID id) {
|
||||||
return tx(() -> doFindById(id));
|
return roQuery(() -> doFindById(id));
|
||||||
}
|
|
||||||
|
|
||||||
public List<T> findAll() {
|
|
||||||
return tx(this::doFindAll);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<T> findAll(int page, int size) {
|
|
||||||
return tx(() -> doFindAll(page, size));
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<T> findAll(Sort sort) {
|
|
||||||
return tx(() -> doFindAll(sort));
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<T> findAll(int page, int size, Sort sort) {
|
|
||||||
return tx(() -> doFindAll(page, size, sort));
|
|
||||||
}
|
|
||||||
|
|
||||||
public Page<T> findPage(int page, int size) {
|
|
||||||
return tx(() -> doFindPage(page, size));
|
|
||||||
}
|
|
||||||
|
|
||||||
public Page<T> findPage(int page, int size, Sort sort) {
|
|
||||||
return tx(() -> doFindPage(page, size, sort));
|
|
||||||
}
|
|
||||||
|
|
||||||
public T save(T entity) {
|
|
||||||
return tx(() -> doSave(entity));
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<T> saveAll(Iterable<T> entities) {
|
|
||||||
return tx(() -> {
|
|
||||||
List<T> saved = new ArrayList<>();
|
|
||||||
for (T e : entities) saved.add(doSave(e));
|
|
||||||
return saved;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public T update(T entity) {
|
|
||||||
return tx(() -> doUpdate(entity));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void delete(T entity) {
|
|
||||||
tx(() -> { doDelete(entity); return null; });
|
|
||||||
}
|
|
||||||
|
|
||||||
public void deleteById(ID id) {
|
|
||||||
tx(() -> { doDeleteById(id); return null; });
|
|
||||||
}
|
|
||||||
|
|
||||||
public void deleteAll(Iterable<T> entities) {
|
|
||||||
tx(() -> { entities.forEach(this::doDelete); return null; });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean existsById(ID id) {
|
public boolean existsById(ID id) {
|
||||||
return tx(() -> doExistsById(id));
|
return roQuery(() -> doExistsById(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
public long count() {
|
public long count() {
|
||||||
return tx(this::doCount);
|
return roQuery(this::doCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Auto-wrap helper ──────────────────────────────────────────────────────
|
public List<T> findAll() {
|
||||||
|
return findAll(Query.all());
|
||||||
/**
|
|
||||||
* Ensures the work runs inside a transaction.
|
|
||||||
* If one is already active (caller annotated @Transactional or inside Tx.run)
|
|
||||||
* it joins it — no new connection opened.
|
|
||||||
* If none is active it opens one, commits, and closes it transparently.
|
|
||||||
*/
|
|
||||||
protected final <R> R tx(Tx.TxCallable<R> work) {
|
|
||||||
return Tx.call(required, work);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Abstract — implemented by HibernateRepository / JdbcRepository ────────
|
public List<T> findAll(Spec<T> spec) {
|
||||||
|
return findAll(Query.<T>all().where(spec));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<T> findAll(Query<T> query) {
|
||||||
|
return roQuery(() -> doFind(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Page<T> findPage(Query<T> query) {
|
||||||
|
return roQuery(() -> doFindPage(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<T> findOne(Spec<T> spec) {
|
||||||
|
return roQuery(() -> doFindOne(spec));
|
||||||
|
}
|
||||||
|
|
||||||
|
public T save(T entity) {
|
||||||
|
return rwQuery(() -> doSave(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public T update(T entity) {
|
||||||
|
return rwQuery(() -> doUpdate(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<T> saveAll(Iterable<T> entities) {
|
||||||
|
return rwQuery(() -> doSaveAll(entities));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void delete(T entity) {
|
||||||
|
rwQuery(() -> {
|
||||||
|
doDelete(entity);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteById(ID id) {
|
||||||
|
rwQuery(() -> {
|
||||||
|
doDeleteById(id);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public int deleteAll(Spec<T> spec) {
|
||||||
|
return rwQuery(() -> doDeleteAll(spec));
|
||||||
|
}
|
||||||
|
|
||||||
|
public int updateAll(Spec<T> spec, T patch) {
|
||||||
|
return rwQuery(() -> doUpdateAll(spec, patch));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<T> findAll(int page, int size) {
|
||||||
|
return findAll(Query.<T>all().page(page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<T> findAll(Sort sort) {
|
||||||
|
return findAll(Query.<T>all().orderBy(sort));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<T> findAll(int page, int size, Sort sort) {
|
||||||
|
return findAll(Query.<T>all().orderBy(sort).page(page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Page<T> findPage(int page, int size) {
|
||||||
|
return findPage(Query.<T>all().page(page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Page<T> findPage(int page, int size, Sort sort) {
|
||||||
|
return findPage(Query.<T>all().orderBy(sort).page(page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteAll(Iterable<T> entities) {
|
||||||
|
rwQuery(() -> {
|
||||||
|
for (T entity : entities) {
|
||||||
|
doDelete(entity);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected abstract Optional<T> doFindById(ID id);
|
protected abstract Optional<T> doFindById(ID id);
|
||||||
protected abstract List<T> doFindAll();
|
protected abstract List<T> doFind(Query<T> query);
|
||||||
protected abstract List<T> doFindAll(int page, int size);
|
protected abstract Optional<T> doFindOne(Spec<T> spec);
|
||||||
protected abstract List<T> doFindAll(Sort sort);
|
protected abstract Page<T> doFindPage(Query<T> query);
|
||||||
protected abstract List<T> doFindAll(int page, int size, Sort sort);
|
protected abstract boolean doExistsById(ID id);
|
||||||
protected abstract Page<T> doFindPage(int page, int size);
|
protected abstract long doCount();
|
||||||
protected abstract Page<T> doFindPage(int page, int size, Sort sort);
|
protected abstract T doSave(T entity);
|
||||||
protected abstract T doSave(T entity);
|
protected abstract List<T> doSaveAll(Iterable<T> entities);
|
||||||
protected abstract T doUpdate(T entity);
|
protected abstract T doUpdate(T entity);
|
||||||
protected abstract void doDelete(T entity);
|
protected abstract void doDelete(T entity);
|
||||||
protected abstract void doDeleteById(ID id);
|
protected abstract void doDeleteById(ID id);
|
||||||
protected abstract boolean doExistsById(ID id);
|
protected abstract int doDeleteAll(Spec<T> spec);
|
||||||
protected abstract long doCount();
|
protected abstract int doUpdateAll(Spec<T> spec, T patch);
|
||||||
}
|
}
|
||||||
+9
@@ -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);
|
||||||
|
}
|
||||||
+24
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
-3
@@ -31,6 +31,11 @@ public final class ResourceRegistry {
|
|||||||
SYNCHRONIZATIONS.get().clear();
|
SYNCHRONIZATIONS.get().clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void cleanup() {
|
||||||
|
RESOURCES.remove();
|
||||||
|
SYNCHRONIZATIONS.remove();
|
||||||
|
}
|
||||||
|
|
||||||
public static <R> R get(TxResourceKey key, Class<R> type) {
|
public static <R> R get(TxResourceKey key, Class<R> type) {
|
||||||
Object value = RESOURCES.get().get(key);
|
Object value = RESOURCES.get().get(key);
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
@@ -48,9 +53,52 @@ public final class ResourceRegistry {
|
|||||||
SYNCHRONIZATIONS.get().add(Objects.requireNonNull(sync));
|
SYNCHRONIZATIONS.get().add(Objects.requireNonNull(sync));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void fireSynchronizations(TxOutcome outcome) {
|
/**
|
||||||
List<TxSynchronization> syncs = List.copyOf(SYNCHRONIZATIONS.get());
|
* How many synchronizations are registered right now — captured by a transaction manager when
|
||||||
SYNCHRONIZATIONS.get().clear();
|
* it opens a new transaction, and handed back to {@link #fireSynchronizations} on completion
|
||||||
|
* so that transaction only fires its own. See there for why that matters.
|
||||||
|
*/
|
||||||
|
public static int synchronizationCount() {
|
||||||
|
return SYNCHRONIZATIONS.get().size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs {@link TxSynchronization#beforeCommit} on the synchronizations registered from
|
||||||
|
* {@code fromIndex} onward, while their transaction is still active and its resource still
|
||||||
|
* bound. Unlike {@link #fireSynchronizations} this leaves them registered: they still have
|
||||||
|
* their post-completion callbacks to come. Exceptions propagate on purpose — a beforeCommit
|
||||||
|
* that throws vetoes the commit, see {@link TxSynchronization}.
|
||||||
|
*
|
||||||
|
* <p>Snapshots before iterating, so a callback that registers further synchronizations (a
|
||||||
|
* nested {@code Data#afterCommit}) doesn't mutate the list mid-loop. Those new ones join the
|
||||||
|
* transaction's post-completion callbacks without getting a {@code beforeCommit} of their own,
|
||||||
|
* which is the only coherent answer once the pass is already running.
|
||||||
|
*/
|
||||||
|
public static void fireBeforeCommit(boolean readOnly, int fromIndex) {
|
||||||
|
List<TxSynchronization> pending = SYNCHRONIZATIONS.get();
|
||||||
|
if (fromIndex >= pending.size()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (TxSynchronization sync : List.copyOf(pending.subList(fromIndex, pending.size()))) {
|
||||||
|
sync.beforeCommit(readOnly);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fires (and removes) the synchronizations registered from {@code fromIndex} onward — the ones
|
||||||
|
* belonging to the transaction now completing. Everything before that index was registered by
|
||||||
|
* an enclosing transaction that is merely <em>suspended</em>, not finished: a REQUIRES_NEW
|
||||||
|
* inner transaction sets its own baseline, so committing it no longer drags the outer's
|
||||||
|
* pending callbacks along — which fired them early, and with the inner transaction's outcome,
|
||||||
|
* for an outer transaction that might still roll back.
|
||||||
|
*/
|
||||||
|
public static void fireSynchronizations(TxOutcome outcome, int fromIndex) {
|
||||||
|
List<TxSynchronization> pending = SYNCHRONIZATIONS.get();
|
||||||
|
if (fromIndex >= pending.size()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<TxSynchronization> syncs = List.copyOf(pending.subList(fromIndex, pending.size()));
|
||||||
|
pending.subList(fromIndex, pending.size()).clear();
|
||||||
for (TxSynchronization sync : syncs) {
|
for (TxSynchronization sync : syncs) {
|
||||||
if (outcome == TxOutcome.COMMITTED) {
|
if (outcome == TxOutcome.COMMITTED) {
|
||||||
sync.afterCommit();
|
sync.afterCommit();
|
||||||
|
|||||||
+4
@@ -7,6 +7,10 @@ public record Sort(List<Column> columns) {
|
|||||||
|
|
||||||
public record Column(String column, boolean asc) {}
|
public record Column(String column, boolean asc) {}
|
||||||
|
|
||||||
|
public static Sort unsorted() { return new Sort(List.of()); }
|
||||||
|
|
||||||
|
public boolean isSorted() { return !columns.isEmpty(); }
|
||||||
|
|
||||||
public static Sort by(String column) { return new Sort(List.of(new Column(column, true))); }
|
public static Sort by(String column) { return new Sort(List.of(new Column(column, true))); }
|
||||||
public static Sort desc(String column) { return new Sort(List.of(new Column(column, false))); }
|
public static Sort desc(String column) { return new Sort(List.of(new Column(column, false))); }
|
||||||
public static Sort by(String col, boolean asc){ return new Sort(List.of(new Column(col, asc))); }
|
public static Sort by(String col, boolean asc){ return new Sort(List.of(new Column(col, asc))); }
|
||||||
|
|||||||
+26
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
package dev.relism.flash.ext.data.core;
|
||||||
|
|
||||||
|
public interface SpecContext {
|
||||||
|
String bind(Object value);
|
||||||
|
}
|
||||||
+1
@@ -3,6 +3,7 @@ package dev.relism.flash.ext.data.core;
|
|||||||
public enum TransactionPropagation {
|
public enum TransactionPropagation {
|
||||||
REQUIRED,
|
REQUIRED,
|
||||||
REQUIRES_NEW,
|
REQUIRES_NEW,
|
||||||
|
SUPPORTS,
|
||||||
NOT_SUPPORTED,
|
NOT_SUPPORTED,
|
||||||
MANDATORY
|
MANDATORY
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-31
@@ -2,83 +2,78 @@ package dev.relism.flash.ext.data.core;
|
|||||||
|
|
||||||
import java.util.ArrayDeque;
|
import java.util.ArrayDeque;
|
||||||
import java.util.Deque;
|
import java.util.Deque;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
public final class Tx {
|
public final class Tx {
|
||||||
private static final ThreadLocal<Deque<TxStatus>> STATUS_STACK =
|
private static final ThreadLocal<Deque<TxStatus>> STATUS_STACK =
|
||||||
ThreadLocal.withInitial(ArrayDeque::new);
|
ThreadLocal.withInitial(ArrayDeque::new);
|
||||||
private static volatile TxManager manager;
|
private final TxManager manager;
|
||||||
|
|
||||||
private Tx() {}
|
public Tx(TxManager txManager) {
|
||||||
|
this.manager = Objects.requireNonNull(txManager);
|
||||||
public static void init(TxManager txManager) {
|
|
||||||
if (manager != null) {
|
|
||||||
throw new IllegalStateException("TxManager already initialized");
|
|
||||||
}
|
|
||||||
manager = txManager;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void run(TxRunnable work) {
|
public void run(TxRunnable work) {
|
||||||
run(TxDefinition.DEFAULTS, work);
|
run(TxDefinition.DEFAULTS, work);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void run(TxDefinition definition, TxRunnable work) {
|
public void run(TxDefinition definition, TxRunnable work) {
|
||||||
call(definition, () -> {
|
call(definition, () -> {
|
||||||
work.run();
|
work.run();
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public static <T> T call(TxCallable<T> work) {
|
public <T> T call(TxCallable<T> work) {
|
||||||
return call(TxDefinition.DEFAULTS, work);
|
return call(TxDefinition.DEFAULTS, work);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static <T> T call(TxDefinition definition, TxCallable<T> work) {
|
public <T> T call(TxDefinition definition, TxCallable<T> work) {
|
||||||
TxStatus status = manager().begin(definition);
|
TxStatus status = manager.begin(definition);
|
||||||
pushStatus(status);
|
pushStatus(status);
|
||||||
try {
|
try {
|
||||||
T result = work.call();
|
T result = work.call();
|
||||||
if (status.isRollbackOnly()) {
|
if (status.isRollbackOnly()) {
|
||||||
manager().rollback(status);
|
manager.rollback(status);
|
||||||
} else {
|
} else {
|
||||||
manager().commit(status);
|
manager.commit(status);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
manager().rollback(status);
|
silentRollback(status);
|
||||||
throw (e instanceof TxException txException) ? txException : new TxException(e);
|
throw (e instanceof TxException txException) ? txException : new TxException(e);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
silentRollback(status);
|
||||||
|
throw sneakyThrow(t);
|
||||||
} finally {
|
} finally {
|
||||||
popStatus();
|
popStatus();
|
||||||
|
if (STATUS_STACK.get().isEmpty()) {
|
||||||
|
STATUS_STACK.remove();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean isActive() {
|
public boolean isActive() {
|
||||||
return !STATUS_STACK.get().isEmpty();
|
return !STATUS_STACK.get().isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void setRollbackOnly() {
|
public void setRollbackOnly() {
|
||||||
currentStatus().markRollbackOnly();
|
currentStatus().markRollbackOnly();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static <R> R resource(Class<R> type) {
|
public <R> R resource(Class<R> type) {
|
||||||
return currentStatus().resource(type);
|
return currentStatus().resource(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static TxDefinition requiresNew() {
|
public TxDefinition requiresNew() {
|
||||||
return TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW);
|
return TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static TxDefinition readOnly() {
|
public TxDefinition readOnly() {
|
||||||
return TxDefinition.DEFAULTS.asReadOnly();
|
return TxDefinition.DEFAULTS.asReadOnly();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static TxManager manager() {
|
private TxStatus currentStatus() {
|
||||||
if (manager == null) {
|
|
||||||
throw new IllegalStateException("No TxManager installed");
|
|
||||||
}
|
|
||||||
return manager;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static TxStatus currentStatus() {
|
|
||||||
TxStatus status = STATUS_STACK.get().peek();
|
TxStatus status = STATUS_STACK.get().peek();
|
||||||
if (status == null) {
|
if (status == null) {
|
||||||
throw new IllegalStateException("No active transaction");
|
throw new IllegalStateException("No active transaction");
|
||||||
@@ -86,17 +81,29 @@ public final class Tx {
|
|||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void pushStatus(TxStatus status) {
|
private void pushStatus(TxStatus status) {
|
||||||
STATUS_STACK.get().push(status);
|
STATUS_STACK.get().push(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void popStatus() {
|
private void popStatus() {
|
||||||
Deque<TxStatus> stack = STATUS_STACK.get();
|
Deque<TxStatus> stack = STATUS_STACK.get();
|
||||||
if (!stack.isEmpty()) {
|
if (!stack.isEmpty()) {
|
||||||
stack.pop();
|
stack.pop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void silentRollback(TxStatus status) {
|
||||||
|
try {
|
||||||
|
manager.rollback(status);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static <E extends Throwable> RuntimeException sneakyThrow(Throwable t) throws E {
|
||||||
|
throw (E) t;
|
||||||
|
}
|
||||||
|
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
public interface TxRunnable {
|
public interface TxRunnable {
|
||||||
void run();
|
void run();
|
||||||
|
|||||||
+50
@@ -1,8 +1,58 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
package dev.relism.flash.ext.data.core;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle hooks for one transaction, registered through {@code Data#afterCommit} (or
|
||||||
|
* {@link ResourceRegistry#addSynchronization} directly) and fired by the {@link TxManager} that
|
||||||
|
* owns the transaction they were registered in.
|
||||||
|
*
|
||||||
|
* <p>Every callback belongs to exactly one transaction — the innermost one active at registration
|
||||||
|
* time — and fires exactly once, when <em>that</em> transaction completes. A joined
|
||||||
|
* ({@code REQUIRED}) inner transaction is not a transaction of its own, so callbacks registered
|
||||||
|
* inside one wait for the outermost commit; a {@code REQUIRES_NEW} transaction is, so completing
|
||||||
|
* it fires only its own callbacks and leaves the suspended outer transaction's alone.
|
||||||
|
*
|
||||||
|
* <h3>Where each hook sits relative to the commit</h3>
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link #beforeCommit(boolean)} — immediately <b>before</b> the real commit, with the
|
||||||
|
* transaction still active and its session/connection still bound. This is the only hook
|
||||||
|
* that can still write through that same resource and have the write land in the same atomic
|
||||||
|
* unit: flush a buffer, stamp an audit row, materialize a derived value. Skipped entirely
|
||||||
|
* when the transaction is already rollback-only, since there is no commit to precede.</li>
|
||||||
|
* <li>{@link #afterCommit()} / {@link #afterRollback()}, then {@link #afterCompletion(TxOutcome)}
|
||||||
|
* — <b>after</b> the transaction has completed and its resource has been unbound. Nothing
|
||||||
|
* done here is part of the transaction: a callback that opens its own transaction gets a
|
||||||
|
* fresh one instead of joining the one that just finished, which is what makes this the
|
||||||
|
* right place to refresh a cache, enqueue a message, or notify anything outside the
|
||||||
|
* database.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h3>Failure</h3>
|
||||||
|
* Throwing from {@link #beforeCommit(boolean)} <b>vetoes the commit</b>: the transaction is rolled
|
||||||
|
* back, {@link #afterRollback()}/{@link #afterCompletion(TxOutcome)} fire with
|
||||||
|
* {@link TxOutcome#ROLLED_BACK}, and the exception propagates to the caller. That is the point of
|
||||||
|
* this hook running before the commit rather than after — it can still refuse.
|
||||||
|
*
|
||||||
|
* <p>The post-completion hooks have no such power: the transaction is over by the time they run,
|
||||||
|
* so an exception from one propagates to the caller but changes nothing already committed, and
|
||||||
|
* stops the callbacks queued behind it.
|
||||||
|
*/
|
||||||
public interface TxSynchronization {
|
public interface TxSynchronization {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs inside the transaction, immediately before it commits — see the interface javadoc.
|
||||||
|
* Throwing from here rolls the transaction back instead of committing it.
|
||||||
|
*
|
||||||
|
* @param readOnly whether the transaction was opened read-only, so a callback that would
|
||||||
|
* otherwise write can skip work it is not allowed to do
|
||||||
|
*/
|
||||||
default void beforeCommit(boolean readOnly) {}
|
default void beforeCommit(boolean readOnly) {}
|
||||||
|
|
||||||
|
/** Runs after a successful commit, with the transaction's resource already unbound. */
|
||||||
default void afterCommit() {}
|
default void afterCommit() {}
|
||||||
|
|
||||||
|
/** Runs after a rollback, with the transaction's resource already unbound. */
|
||||||
default void afterRollback() {}
|
default void afterRollback() {}
|
||||||
|
|
||||||
|
/** Runs after {@link #afterCommit()}/{@link #afterRollback()}, whichever applied. */
|
||||||
default void afterCompletion(TxOutcome outcome) {}
|
default void afterCompletion(TxOutcome outcome) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# flash-ext-data-hibernate
|
||||||
|
|
||||||
|
Hibernate backend for `flash-ext-data-core`.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This module implements `TxManager` on top of a `SessionFactory` and provides a Hibernate-centric
|
||||||
|
repository base class.
|
||||||
|
|
||||||
|
## How to use it
|
||||||
|
|
||||||
|
### 1. Create the manager
|
||||||
|
|
||||||
|
```java
|
||||||
|
SessionFactory sessionFactory = ...;
|
||||||
|
HibernateTxManager txManager = new HibernateTxManager(sessionFactory);
|
||||||
|
DataExtension extension = new DataExtension(txManager);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Install the extension in Flash
|
||||||
|
|
||||||
|
The extension registers `Tx` and `TxManager` in the `FlashContext`. Class-based handlers annotated
|
||||||
|
with `@Transactional` are wrapped automatically.
|
||||||
|
|
||||||
|
### 3. Define a repository
|
||||||
|
|
||||||
|
```java
|
||||||
|
public final class UserRepository extends HibernateRepository<User, Long> {
|
||||||
|
public UserRepository(Tx tx) {
|
||||||
|
super(tx, User.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
With the query/spec model you can expose reusable fields as constants:
|
||||||
|
|
||||||
|
```java
|
||||||
|
public final class UserRepository extends HibernateRepository<User, Long> {
|
||||||
|
public static final SpecBuilder.FieldSpec<User, String> EMAIL = SpecBuilder.field("u.email");
|
||||||
|
public static final SpecBuilder.FieldSpec<User, Boolean> ACTIVE = SpecBuilder.field("u.active");
|
||||||
|
|
||||||
|
public UserRepository(Tx tx) {
|
||||||
|
super(tx, User.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<User> findByEmail(String email) {
|
||||||
|
return findOne(EMAIL.eq(email));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Domain-specific queries can use the base class helpers:
|
||||||
|
|
||||||
|
```java
|
||||||
|
public List<User> findByEmailDomain(String domain) {
|
||||||
|
return findMany("from User u where u.email like :email", q ->
|
||||||
|
q.setParameter("email", "%@" + domain)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## How it works underneath
|
||||||
|
|
||||||
|
- The current transaction is represented by `HibernateTxStatus`.
|
||||||
|
- The resource exposed to the core is a `Session`.
|
||||||
|
- `Tx.resource(Session.class)` retrieves the `Session` from the current context.
|
||||||
|
- `REQUIRES_NEW` suspends the active status and opens a new `Session`.
|
||||||
|
- `NOT_SUPPORTED` suspends the active transaction and continues with no session bound.
|
||||||
|
|
||||||
|
## Repository base class
|
||||||
|
|
||||||
|
`HibernateRepository` provides:
|
||||||
|
|
||||||
|
- `findById`, `findAll`, `findPage`, `findOne`
|
||||||
|
- `save`, `update`, `delete`, `saveAll`
|
||||||
|
- bulk `deleteAll(Spec<T>)` and `updateAll(Spec<T>, T)`
|
||||||
|
- HQL helpers: `hql(...)`, `hqlMutate(...)`
|
||||||
|
|
||||||
|
Concrete classes only have to implement domain queries, never the transactional plumbing.
|
||||||
|
|
||||||
|
## Transactional semantics
|
||||||
|
|
||||||
|
- `REQUIRED`: join, or open a new transaction.
|
||||||
|
- `REQUIRES_NEW`: suspend the current context.
|
||||||
|
- `SUPPORTS`: join if a transaction exists, otherwise no-op.
|
||||||
|
- `NOT_SUPPORTED`: suspend and continue without a transaction.
|
||||||
|
- `MANDATORY`: fail if there is no transaction.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The `Session` is closed when a new transaction ends.
|
||||||
|
- Synchronizations registered in a transaction fire when *that* transaction completes:
|
||||||
|
`beforeCommit` while it is still active and the `Session` still bound, the post-completion hooks
|
||||||
|
once it is unbound. See `flash-ext-data-core/docs/README.md` for the full contract.
|
||||||
|
- This backend is meant to be used through the base class, not directly.
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash-extensions</artifactId>
|
<artifactId>flash-extensions</artifactId>
|
||||||
<version>2.0.0</version>
|
<version>2.1.0-SNAPSHOT</version>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<artifactId>flash-ext-data-hibernate</artifactId>
|
<artifactId>flash-ext-data-hibernate</artifactId>
|
||||||
|
|||||||
+25
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
-86
@@ -1,79 +1,74 @@
|
|||||||
package dev.relism.flash.ext.data.hibernate;
|
package dev.relism.flash.ext.data.hibernate;
|
||||||
|
|
||||||
import dev.relism.flash.ext.data.core.*;
|
import dev.relism.flash.ext.data.core.*;
|
||||||
|
import jakarta.persistence.TypedQuery;
|
||||||
import org.hibernate.Session;
|
import org.hibernate.Session;
|
||||||
import org.hibernate.query.MutationQuery;
|
import org.hibernate.query.MutationQuery;
|
||||||
|
|
||||||
import jakarta.persistence.TypedQuery;
|
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
import java.util.*;
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
public abstract class HibernateRepository<T, ID extends Serializable> extends Repository<T, ID> {
|
||||||
* Hibernate-backed repository base.
|
|
||||||
* Never extend this directly — extend {@link Repository} from the core.
|
|
||||||
* This class is instantiated internally by flash-ext-data-hibernate.
|
|
||||||
*/
|
|
||||||
public abstract class HibernateRepository<T, ID extends Serializable>
|
|
||||||
extends Repository<T, ID> {
|
|
||||||
|
|
||||||
private final Class<T> type;
|
private final Class<T> type;
|
||||||
|
|
||||||
protected HibernateRepository(Class<T> type) {
|
protected HibernateRepository(Tx tx, Class<T> type) {
|
||||||
|
super(tx);
|
||||||
this.type = type;
|
this.type = type;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Session — always safe, tx() wrapper guarantees active transaction ─────
|
|
||||||
|
|
||||||
protected Session session() {
|
protected Session session() {
|
||||||
return Tx.resource(Session.class);
|
return tx().resource(Session.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Repository abstract impl ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected Optional<T> doFindById(ID id) {
|
protected Optional<T> doFindById(ID id) {
|
||||||
return Optional.ofNullable(session().get(type, id));
|
return Optional.ofNullable(session().get(type, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected List<T> doFindAll() {
|
protected List<T> doFind(Query<T> query) {
|
||||||
return hql("from " + type.getSimpleName()).getResultList();
|
HibernateSpecContext ctx = new HibernateSpecContext();
|
||||||
|
String where = query.spec() != null ? " where " + query.spec().toFragment(ctx) : "";
|
||||||
|
String order = query.sort() != null && query.sort().isSorted() ? " order by " + orderClause(query.sort()) : "";
|
||||||
|
|
||||||
|
TypedQuery<T> q = session().createQuery("from " + type.getSimpleName() + where + order, type);
|
||||||
|
ctx.applyParameters(q);
|
||||||
|
|
||||||
|
if (query.isPaged()) {
|
||||||
|
q.setFirstResult(query.page() * query.size());
|
||||||
|
q.setMaxResults(query.size());
|
||||||
|
}
|
||||||
|
return q.getResultList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected List<T> doFindAll(int page, int size) {
|
protected Optional<T> doFindOne(Spec<T> spec) {
|
||||||
return hql("from " + type.getSimpleName())
|
return doFind(Query.<T>all().where(spec).page(0, 1)).stream().findFirst();
|
||||||
.setFirstResult(page * size)
|
|
||||||
.setMaxResults(size)
|
|
||||||
.getResultList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected List<T> doFindAll(Sort sort) {
|
protected Page<T> doFindPage(Query<T> query) {
|
||||||
return hql("from " + type.getSimpleName() + orderClause(sort))
|
if (!query.isPaged()) {
|
||||||
.getResultList();
|
throw new IllegalArgumentException("Paged query requires page and size");
|
||||||
|
}
|
||||||
|
long total = countWhere(query.spec());
|
||||||
|
List<T> content = doFind(query);
|
||||||
|
return new Page<>(content, query.page(), query.size(), total);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected List<T> doFindAll(int page, int size, Sort sort) {
|
protected boolean doExistsById(ID id) {
|
||||||
return hql("from " + type.getSimpleName() + orderClause(sort))
|
return doFindById(id).isPresent();
|
||||||
.setFirstResult(page * size)
|
|
||||||
.setMaxResults(size)
|
|
||||||
.getResultList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected Page<T> doFindPage(int page, int size) {
|
protected long doCount() {
|
||||||
long total = doCount();
|
return countWhere(Spec.all());
|
||||||
return new Page<>(doFindAll(page, size), page, size, total);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected Page<T> doFindPage(int page, int size, Sort sort) {
|
|
||||||
long total = doCount();
|
|
||||||
return new Page<>(doFindAll(page, size, sort), page, size, total);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -82,6 +77,22 @@ public abstract class HibernateRepository<T, ID extends Serializable>
|
|||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected List<T> doSaveAll(Iterable<T> entities) {
|
||||||
|
List<T> saved = new ArrayList<>();
|
||||||
|
Session s = session();
|
||||||
|
int i = 0;
|
||||||
|
for (T entity : entities) {
|
||||||
|
s.persist(entity);
|
||||||
|
saved.add(entity);
|
||||||
|
if (++i % 50 == 0) {
|
||||||
|
s.flush();
|
||||||
|
s.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected T doUpdate(T entity) {
|
protected T doUpdate(T entity) {
|
||||||
return session().merge(entity);
|
return session().merge(entity);
|
||||||
@@ -99,66 +110,37 @@ public abstract class HibernateRepository<T, ID extends Serializable>
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected boolean doExistsById(ID id) {
|
protected int doDeleteAll(Spec<T> spec) {
|
||||||
return doFindById(id).isPresent();
|
HibernateSpecContext ctx = new HibernateSpecContext();
|
||||||
|
String where = " where " + spec.toFragment(ctx);
|
||||||
|
MutationQuery q = session().createMutationQuery("delete from " + type.getSimpleName() + where);
|
||||||
|
ctx.applyParameters(q);
|
||||||
|
return q.executeUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected long doCount() {
|
protected int doUpdateAll(Spec<T> spec, T patch) {
|
||||||
return session()
|
throw new UnsupportedOperationException("Override doUpdateAll() for bulk UPDATE support");
|
||||||
.createQuery("select count(*) from " + type.getSimpleName(), Long.class)
|
|
||||||
.uniqueResultOptional()
|
|
||||||
.orElse(0L);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Query helpers — usabili nelle sottoclassi domain ─────────────────────
|
protected List<T> hql(String hql, Consumer<TypedQuery<T>> params) {
|
||||||
|
return roQuery(() -> {
|
||||||
protected TypedQuery<T> hql(String hql) {
|
TypedQuery<T> q = session().createQuery(hql, type);
|
||||||
return session().createQuery(hql, type);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected <R> TypedQuery<R> hql(String hql, Class<R> resultType) {
|
|
||||||
return session().createQuery(hql, resultType);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected Optional<T> findOne(String hql, Consumer<TypedQuery<T>> params) {
|
|
||||||
TypedQuery<T> q = hql(hql);
|
|
||||||
params.accept(q);
|
|
||||||
return q.getResultStream().findFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected List<T> findMany(String hql, Consumer<TypedQuery<T>> params) {
|
|
||||||
return tx(() -> {
|
|
||||||
TypedQuery<T> q = hql(hql);
|
|
||||||
params.accept(q);
|
params.accept(q);
|
||||||
return q.getResultList();
|
return q.getResultList();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected List<T> findMany(String hql, Consumer<TypedQuery<T>> params,
|
protected <R> List<R> hql(String hql, Class<R> resultType, Consumer<TypedQuery<R>> params) {
|
||||||
int page, int size) {
|
return roQuery(() -> {
|
||||||
return tx(() -> {
|
TypedQuery<R> q = session().createQuery(hql, resultType);
|
||||||
TypedQuery<T> q = hql(hql);
|
|
||||||
params.accept(q);
|
params.accept(q);
|
||||||
return q.setFirstResult(page * size).setMaxResults(size).getResultList();
|
return q.getResultList();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected Page<T> findManyPaged(String hql, String countHql,
|
protected int hqlMutate(String hql, Consumer<MutationQuery> params) {
|
||||||
Consumer<TypedQuery<T>> params,
|
return rwQuery(() -> {
|
||||||
int page, int size) {
|
|
||||||
return tx(() -> {
|
|
||||||
long total = session()
|
|
||||||
.createQuery(countHql, Long.class)
|
|
||||||
.uniqueResultOptional()
|
|
||||||
.orElse(0L);
|
|
||||||
List<T> content = findMany(hql, params, page, size);
|
|
||||||
return new Page<>(content, page, size, total);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected int execute(String hql, Consumer<MutationQuery> params) {
|
|
||||||
return tx(() -> {
|
|
||||||
MutationQuery q = session().createMutationQuery(hql);
|
MutationQuery q = session().createMutationQuery(hql);
|
||||||
params.accept(q);
|
params.accept(q);
|
||||||
return q.executeUpdate();
|
return q.executeUpdate();
|
||||||
@@ -169,8 +151,16 @@ public abstract class HibernateRepository<T, ID extends Serializable>
|
|||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private long countWhere(Spec<T> spec) {
|
||||||
|
HibernateSpecContext ctx = new HibernateSpecContext();
|
||||||
|
String where = spec != null ? " where " + spec.toFragment(ctx) : "";
|
||||||
|
TypedQuery<Long> q = session().createQuery("select count(*) from " + type.getSimpleName() + where, Long.class);
|
||||||
|
ctx.applyParameters(q);
|
||||||
|
return q.getResultStream().findFirst().orElse(0L);
|
||||||
|
}
|
||||||
|
|
||||||
private String orderClause(Sort sort) {
|
private String orderClause(Sort sort) {
|
||||||
return " order by " + sort.columns().stream()
|
return sort.columns().stream()
|
||||||
.map(c -> c.column() + (c.asc() ? " ASC" : " DESC"))
|
.map(c -> c.column() + (c.asc() ? " ASC" : " DESC"))
|
||||||
.collect(Collectors.joining(", "));
|
.collect(Collectors.joining(", "));
|
||||||
}
|
}
|
||||||
|
|||||||
+28
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+128
-23
@@ -8,6 +8,7 @@ import java.util.Objects;
|
|||||||
|
|
||||||
public class HibernateTxManager implements TxManager {
|
public class HibernateTxManager implements TxManager {
|
||||||
private static final TxResourceKey HIBERNATE_STATUS_KEY = TxResourceKey.of("hibernate.tx.status");
|
private static final TxResourceKey HIBERNATE_STATUS_KEY = TxResourceKey.of("hibernate.tx.status");
|
||||||
|
private static final TxResourceKey HIBERNATE_SUSPENDED_KEY = TxResourceKey.of("hibernate.tx.suspended");
|
||||||
|
|
||||||
private final SessionFactory sf;
|
private final SessionFactory sf;
|
||||||
|
|
||||||
@@ -22,35 +23,60 @@ public class HibernateTxManager implements TxManager {
|
|||||||
? joinExisting(definition)
|
? joinExisting(definition)
|
||||||
: beginNew(definition);
|
: beginNew(definition);
|
||||||
case REQUIRES_NEW -> beginNew(definition);
|
case REQUIRES_NEW -> beginNew(definition);
|
||||||
|
case SUPPORTS -> ResourceRegistry.isBound(HIBERNATE_STATUS_KEY)
|
||||||
|
? joinExisting(definition)
|
||||||
|
: noOp(definition);
|
||||||
case MANDATORY -> {
|
case MANDATORY -> {
|
||||||
if (!ResourceRegistry.isBound(HIBERNATE_STATUS_KEY))
|
if (!ResourceRegistry.isBound(HIBERNATE_STATUS_KEY))
|
||||||
throw new IllegalStateException("MANDATORY: no active transaction");
|
throw new IllegalStateException("MANDATORY: no active transaction");
|
||||||
yield joinExisting(definition);
|
yield joinExisting(definition);
|
||||||
}
|
}
|
||||||
case NOT_SUPPORTED -> throw new UnsupportedOperationException("NOT_SUPPORTED is not implemented");
|
case NOT_SUPPORTED -> {
|
||||||
|
HibernateTxStatus suspended = suspendIfNeeded();
|
||||||
|
yield noOp(definition, suspended);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private TxStatus beginNew(TxDefinition definition) {
|
private TxStatus beginNew(TxDefinition definition) {
|
||||||
HibernateTxStatus suspended = ResourceRegistry.getOrNull(HIBERNATE_STATUS_KEY, HibernateTxStatus.class);
|
return beginNew(definition, suspendIfNeeded());
|
||||||
if (suspended != null) {
|
}
|
||||||
ResourceRegistry.unbind(HIBERNATE_STATUS_KEY);
|
|
||||||
}
|
private TxStatus beginNew(TxDefinition definition, HibernateTxStatus suspended) {
|
||||||
|
// Anything already registered belongs to an enclosing transaction this one is nested
|
||||||
|
// inside (or suspended over) — see ResourceRegistry#fireSynchronizations.
|
||||||
|
int synchronizationBaseline = ResourceRegistry.synchronizationCount();
|
||||||
Session s = sf.openSession();
|
Session s = sf.openSession();
|
||||||
s.beginTransaction();
|
boolean bound = false;
|
||||||
if (definition.readOnly()) s.setDefaultReadOnly(true);
|
try {
|
||||||
if (definition.isolation() != TransactionIsolation.DEFAULT) {
|
s.beginTransaction();
|
||||||
s.doWork(connection -> connection.setTransactionIsolation(definition.isolation().level()));
|
if (definition.readOnly()) s.setDefaultReadOnly(true);
|
||||||
|
if (definition.isolation() != TransactionIsolation.DEFAULT) {
|
||||||
|
s.doWork(connection -> connection.setTransactionIsolation(definition.isolation().level()));
|
||||||
|
}
|
||||||
|
HibernateTxStatus status = new HibernateTxStatus(
|
||||||
|
s,
|
||||||
|
true,
|
||||||
|
definition.readOnly(),
|
||||||
|
suspended,
|
||||||
|
new HibernateTxStatus.RollbackMarker(),
|
||||||
|
synchronizationBaseline
|
||||||
|
);
|
||||||
|
ResourceRegistry.bind(HIBERNATE_STATUS_KEY, status);
|
||||||
|
bound = true;
|
||||||
|
return status;
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
silentClose(s);
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
silentClose(s);
|
||||||
|
throw new TxException(e);
|
||||||
|
} finally {
|
||||||
|
if (!bound && suspended != null) {
|
||||||
|
ResourceRegistry.unbind(HIBERNATE_SUSPENDED_KEY);
|
||||||
|
ResourceRegistry.bind(HIBERNATE_STATUS_KEY, suspended);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
HibernateTxStatus status = new HibernateTxStatus(
|
|
||||||
s,
|
|
||||||
true,
|
|
||||||
definition.readOnly(),
|
|
||||||
suspended,
|
|
||||||
new HibernateTxStatus.RollbackMarker()
|
|
||||||
);
|
|
||||||
ResourceRegistry.bind(HIBERNATE_STATUS_KEY, status);
|
|
||||||
return status;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private TxStatus joinExisting(TxDefinition definition) {
|
private TxStatus joinExisting(TxDefinition definition) {
|
||||||
@@ -58,31 +84,81 @@ public class HibernateTxManager implements TxManager {
|
|||||||
if (definition.readOnly() && !existing.isReadOnly()) {
|
if (definition.readOnly() && !existing.isReadOnly()) {
|
||||||
throw new TxException("Cannot join read-write tx as read-only");
|
throw new TxException("Cannot join read-write tx as read-only");
|
||||||
}
|
}
|
||||||
|
// Baseline 0 is never read: a joined status isn't a new transaction, so commit()/rollback()
|
||||||
|
// hand it straight back to the transaction it joined without firing anything.
|
||||||
return new HibernateTxStatus(
|
return new HibernateTxStatus(
|
||||||
existing.session(),
|
existing.session(),
|
||||||
false,
|
false,
|
||||||
definition.readOnly(),
|
definition.readOnly(),
|
||||||
null,
|
null,
|
||||||
existing.rollbackMarker()
|
existing.rollbackMarker(),
|
||||||
|
0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private TxStatus noOp(TxDefinition definition) {
|
||||||
|
return noOp(definition, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TxStatus noOp(TxDefinition definition, HibernateTxStatus suspended) {
|
||||||
|
return new HibernateTxStatus(null, false, definition.readOnly(), suspended, new HibernateTxStatus.RollbackMarker(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private HibernateTxStatus suspendIfNeeded() {
|
||||||
|
HibernateTxStatus suspended = ResourceRegistry.getOrNull(HIBERNATE_STATUS_KEY, HibernateTxStatus.class);
|
||||||
|
if (suspended != null) {
|
||||||
|
ResourceRegistry.unbind(HIBERNATE_STATUS_KEY);
|
||||||
|
ResourceRegistry.bind(HIBERNATE_SUSPENDED_KEY, suspended);
|
||||||
|
}
|
||||||
|
return suspended;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void commit(TxStatus status) {
|
public void commit(TxStatus status) {
|
||||||
HibernateTxStatus s = (HibernateTxStatus) status;
|
HibernateTxStatus s = (HibernateTxStatus) status;
|
||||||
if (!s.isNewTransaction()) {
|
if (!s.isNewTransaction()) {
|
||||||
|
resumeIfNeeded(s);
|
||||||
|
cleanupIfIdle();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
TxOutcome outcome = null;
|
||||||
try {
|
try {
|
||||||
if (s.isRollbackOnly() && s.session().getTransaction().isActive()) {
|
if (s.isRollbackOnly() && s.session().getTransaction().isActive()) {
|
||||||
s.session().getTransaction().rollback();
|
s.session().getTransaction().rollback();
|
||||||
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK);
|
outcome = TxOutcome.ROLLED_BACK;
|
||||||
} else {
|
} else {
|
||||||
|
// Still inside the transaction, session still bound: a beforeCommit callback can
|
||||||
|
// write through it and land in this same commit. Throwing from there vetoes the
|
||||||
|
// commit — see TxSynchronization.
|
||||||
|
ResourceRegistry.fireBeforeCommit(s.isReadOnly(), s.synchronizationBaseline());
|
||||||
s.session().getTransaction().commit();
|
s.session().getTransaction().commit();
|
||||||
ResourceRegistry.fireSynchronizations(TxOutcome.COMMITTED);
|
outcome = TxOutcome.COMMITTED;
|
||||||
}
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
// A vetoing beforeCommit, or a commit that failed outright: either way nothing was
|
||||||
|
// committed, so roll back and let the remaining callbacks hear ROLLED_BACK rather than
|
||||||
|
// nothing at all. A rollback failure here is swallowed deliberately — it would mask
|
||||||
|
// the exception that actually explains what went wrong, which is the one propagating.
|
||||||
|
if (s.session().getTransaction().isActive()) {
|
||||||
|
try {
|
||||||
|
s.session().getTransaction().rollback();
|
||||||
|
} catch (RuntimeException suppressed) {
|
||||||
|
e.addSuppressed(suppressed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outcome = TxOutcome.ROLLED_BACK;
|
||||||
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
|
// Order is load-bearing, and getting it wrong is silent: cleanupIfIdle() calls
|
||||||
|
// ResourceRegistry.cleanup(), which removes the very ThreadLocal list of
|
||||||
|
// synchronizations still waiting to be fired — firing afterwards saw a freshly
|
||||||
|
// initialized empty list and dropped every callback on the floor. cleanupAndResume()
|
||||||
|
// still has to come first, so a synchronization that opens its own transaction
|
||||||
|
// (Registry#reload() in Pathway does) starts a fresh one instead of joining the
|
||||||
|
// session that just committed. rollback() below already had this order right.
|
||||||
cleanupAndResume(s);
|
cleanupAndResume(s);
|
||||||
|
if (outcome != null) ResourceRegistry.fireSynchronizations(outcome, s.synchronizationBaseline());
|
||||||
|
cleanupIfIdle();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,24 +167,53 @@ public class HibernateTxManager implements TxManager {
|
|||||||
HibernateTxStatus s = (HibernateTxStatus) status;
|
HibernateTxStatus s = (HibernateTxStatus) status;
|
||||||
if (!s.isNewTransaction()) {
|
if (!s.isNewTransaction()) {
|
||||||
s.markRollbackOnly();
|
s.markRollbackOnly();
|
||||||
|
resumeIfNeeded(s);
|
||||||
|
cleanupIfIdle();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (s.session().getTransaction().isActive()) {
|
if (s.session().getTransaction().isActive()) {
|
||||||
s.session().getTransaction().rollback();
|
s.session().getTransaction().rollback();
|
||||||
}
|
}
|
||||||
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK);
|
|
||||||
} finally {
|
} finally {
|
||||||
|
// Same order as commit(): unbind the session first so a callback opening its own
|
||||||
|
// transaction gets a fresh one, fire before cleanupIfIdle() can drop the list.
|
||||||
cleanupAndResume(s);
|
cleanupAndResume(s);
|
||||||
|
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK, s.synchronizationBaseline());
|
||||||
|
cleanupIfIdle();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void cleanupAndResume(HibernateTxStatus status) {
|
private void cleanupAndResume(HibernateTxStatus status) {
|
||||||
ResourceRegistry.unbind(HIBERNATE_STATUS_KEY);
|
ResourceRegistry.unbind(HIBERNATE_STATUS_KEY);
|
||||||
status.session().close();
|
silentClose(status.session());
|
||||||
|
resumeIfNeeded(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cleanupIfIdle() {
|
||||||
|
if (!ResourceRegistry.isBound(HIBERNATE_STATUS_KEY) && !ResourceRegistry.isBound(HIBERNATE_SUSPENDED_KEY)) {
|
||||||
|
ResourceRegistry.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void resumeIfNeeded(HibernateTxStatus status) {
|
||||||
HibernateTxStatus suspended = status.suspended();
|
HibernateTxStatus suspended = status.suspended();
|
||||||
|
if (suspended == null) {
|
||||||
|
suspended = ResourceRegistry.getOrNull(HIBERNATE_SUSPENDED_KEY, HibernateTxStatus.class);
|
||||||
|
}
|
||||||
if (suspended != null) {
|
if (suspended != null) {
|
||||||
|
ResourceRegistry.unbind(HIBERNATE_SUSPENDED_KEY);
|
||||||
ResourceRegistry.bind(HIBERNATE_STATUS_KEY, suspended);
|
ResourceRegistry.bind(HIBERNATE_STATUS_KEY, suspended);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void silentClose(Session session) {
|
||||||
|
if (session == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
session.close();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -13,19 +13,22 @@ class HibernateTxStatus implements TxStatus {
|
|||||||
private final boolean readOnly;
|
private final boolean readOnly;
|
||||||
private final HibernateTxStatus suspended;
|
private final HibernateTxStatus suspended;
|
||||||
private final RollbackMarker rollbackMarker;
|
private final RollbackMarker rollbackMarker;
|
||||||
|
private final int synchronizationBaseline;
|
||||||
|
|
||||||
HibernateTxStatus(
|
HibernateTxStatus(
|
||||||
Session session,
|
Session session,
|
||||||
boolean newTransaction,
|
boolean newTransaction,
|
||||||
boolean readOnly,
|
boolean readOnly,
|
||||||
HibernateTxStatus suspended,
|
HibernateTxStatus suspended,
|
||||||
RollbackMarker rollbackMarker
|
RollbackMarker rollbackMarker,
|
||||||
|
int synchronizationBaseline
|
||||||
) {
|
) {
|
||||||
this.session = session;
|
this.session = session;
|
||||||
this.newTransaction = newTransaction;
|
this.newTransaction = newTransaction;
|
||||||
this.readOnly = readOnly;
|
this.readOnly = readOnly;
|
||||||
this.suspended = suspended;
|
this.suspended = suspended;
|
||||||
this.rollbackMarker = rollbackMarker;
|
this.rollbackMarker = rollbackMarker;
|
||||||
|
this.synchronizationBaseline = synchronizationBaseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override public boolean isNewTransaction() { return newTransaction; }
|
@Override public boolean isNewTransaction() { return newTransaction; }
|
||||||
@@ -35,10 +38,16 @@ class HibernateTxStatus implements TxStatus {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public <R> R resource(Class<R> type) {
|
public <R> R resource(Class<R> type) {
|
||||||
|
if (session == null) {
|
||||||
|
throw new IllegalStateException("No session bound to this transaction status");
|
||||||
|
}
|
||||||
return type.cast(session);
|
return type.cast(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
Session session() { return session; }
|
Session session() { return session; }
|
||||||
HibernateTxStatus suspended() { return suspended; }
|
HibernateTxStatus suspended() { return suspended; }
|
||||||
RollbackMarker rollbackMarker() { return rollbackMarker; }
|
RollbackMarker rollbackMarker() { return rollbackMarker; }
|
||||||
|
|
||||||
|
/** Index into {@code ResourceRegistry}'s synchronization list where this transaction's own callbacks start. */
|
||||||
|
int synchronizationBaseline() { return synchronizationBaseline; }
|
||||||
}
|
}
|
||||||
|
|||||||
+258
@@ -0,0 +1,258 @@
|
|||||||
|
package dev.relism.flash.ext.data.hibernate;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.data.core.*;
|
||||||
|
import org.hibernate.Session;
|
||||||
|
import org.hibernate.SessionFactory;
|
||||||
|
import org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transaction synchronization semantics — the {@code beforeCommit}/{@code afterCommit}/
|
||||||
|
* {@code afterRollback} callbacks {@code Data#afterCommit} exposes, and the contract callers build
|
||||||
|
* on: "my callback runs once, for my transaction, on the right side of the commit".
|
||||||
|
*
|
||||||
|
* <p>None of this was covered before, and the gap was not academic: {@link #afterCommit_fires_on_commit}
|
||||||
|
* failed against the original {@code commit()}, which fired synchronizations only after
|
||||||
|
* {@code cleanupIfIdle()} had already dropped the ThreadLocal list holding them — so every callback
|
||||||
|
* was silently discarded, on every commit, with no error and no log. Downstream that meant an admin
|
||||||
|
* write landing in Postgres while the in-memory cache it was supposed to refresh never heard about
|
||||||
|
* it until the process restarted.
|
||||||
|
*/
|
||||||
|
class HibernateTxManagerSynchronizationTest {
|
||||||
|
|
||||||
|
static SessionFactory sf;
|
||||||
|
static HibernateTxManager manager;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void setup() {
|
||||||
|
sf = TestHelper.buildSessionFactory();
|
||||||
|
manager = new HibernateTxManager(sf);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
static void teardown() {
|
||||||
|
if (sf != null) {
|
||||||
|
sf.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void cleanup() {
|
||||||
|
ResourceRegistry.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records which callbacks ran, in order — order is part of the contract, not just the fact they ran. */
|
||||||
|
private static final class Recorder implements TxSynchronization {
|
||||||
|
final List<String> calls = new ArrayList<>();
|
||||||
|
|
||||||
|
@Override public void beforeCommit(boolean readOnly) { calls.add("beforeCommit:" + readOnly); }
|
||||||
|
@Override public void afterCommit() { calls.add("afterCommit"); }
|
||||||
|
@Override public void afterRollback() { calls.add("afterRollback"); }
|
||||||
|
@Override public void afterCompletion(TxOutcome outcome) { calls.add("afterCompletion:" + outcome); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final List<String> COMMITTED = List.of("beforeCommit:false", "afterCommit", "afterCompletion:COMMITTED");
|
||||||
|
private static final List<String> ROLLED_BACK = List.of("afterRollback", "afterCompletion:ROLLED_BACK");
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void afterCommit_fires_on_commit() {
|
||||||
|
Recorder recorder = new Recorder();
|
||||||
|
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
ResourceRegistry.addSynchronization(recorder);
|
||||||
|
|
||||||
|
manager.commit(tx);
|
||||||
|
|
||||||
|
assertEquals(COMMITTED, recorder.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void afterRollback_fires_on_rollback() {
|
||||||
|
Recorder recorder = new Recorder();
|
||||||
|
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
ResourceRegistry.addSynchronization(recorder);
|
||||||
|
|
||||||
|
manager.rollback(tx);
|
||||||
|
|
||||||
|
assertEquals(ROLLED_BACK, recorder.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A commit() call on a tx already marked rollback-only really rolls back — and has no commit to precede. */
|
||||||
|
@Test
|
||||||
|
void commit_of_a_rollback_only_tx_rolls_back_without_running_beforeCommit() {
|
||||||
|
Recorder recorder = new Recorder();
|
||||||
|
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
tx.markRollbackOnly();
|
||||||
|
ResourceRegistry.addSynchronization(recorder);
|
||||||
|
|
||||||
|
manager.commit(tx);
|
||||||
|
|
||||||
|
assertEquals(ROLLED_BACK, recorder.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** beforeCommit runs inside the transaction: session still bound, transaction still active. */
|
||||||
|
@Test
|
||||||
|
void beforeCommit_runs_while_the_transaction_is_still_active() {
|
||||||
|
List<Boolean> stillActive = new ArrayList<>();
|
||||||
|
List<Session> sessionSeen = new ArrayList<>();
|
||||||
|
|
||||||
|
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
Session session = tx.resource(Session.class);
|
||||||
|
ResourceRegistry.addSynchronization(new TxSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void beforeCommit(boolean readOnly) {
|
||||||
|
stillActive.add(session.getTransaction().isActive());
|
||||||
|
// MANDATORY only succeeds while a transaction is bound — proof this runs inside it.
|
||||||
|
TxStatus joined = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.MANDATORY));
|
||||||
|
sessionSeen.add(joined.resource(Session.class));
|
||||||
|
manager.commit(joined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.commit(tx);
|
||||||
|
|
||||||
|
assertEquals(List.of(true), stillActive, "the transaction must not have committed yet");
|
||||||
|
assertSame(session, sessionSeen.get(0), "the same session must still be bound, so writes land in this commit");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void beforeCommit_is_told_whether_the_transaction_is_read_only() {
|
||||||
|
Recorder readWrite = new Recorder();
|
||||||
|
TxStatus rw = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
ResourceRegistry.addSynchronization(readWrite);
|
||||||
|
manager.commit(rw);
|
||||||
|
|
||||||
|
Recorder readOnly = new Recorder();
|
||||||
|
TxStatus ro = manager.begin(TxDefinition.DEFAULTS.asReadOnly());
|
||||||
|
ResourceRegistry.addSynchronization(readOnly);
|
||||||
|
manager.commit(ro);
|
||||||
|
|
||||||
|
assertEquals("beforeCommit:false", readWrite.calls.get(0));
|
||||||
|
assertEquals("beforeCommit:true", readOnly.calls.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throwing from beforeCommit is a veto: no commit, the rollback callbacks run, the exception propagates. */
|
||||||
|
@Test
|
||||||
|
void a_throwing_beforeCommit_vetoes_the_commit() {
|
||||||
|
Recorder recorder = new Recorder();
|
||||||
|
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
Session session = tx.resource(Session.class);
|
||||||
|
ResourceRegistry.addSynchronization(new TxSynchronization() {
|
||||||
|
@Override public void beforeCommit(boolean readOnly) { throw new IllegalStateException("veto"); }
|
||||||
|
});
|
||||||
|
ResourceRegistry.addSynchronization(recorder);
|
||||||
|
|
||||||
|
IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> manager.commit(tx));
|
||||||
|
|
||||||
|
assertEquals("veto", thrown.getMessage());
|
||||||
|
assertFalse(session.getTransaction().isActive(), "the vetoed transaction must be rolled back, not left open");
|
||||||
|
assertEquals(ROLLED_BACK, recorder.calls, "the surviving callbacks must hear ROLLED_BACK, not silence");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The load-bearing ordering detail: post-completion callbacks run <em>after</em> the committed
|
||||||
|
* session is unbound, so a callback that opens its own transaction (a cache reload, an outbox
|
||||||
|
* drain) gets a fresh one instead of silently joining the transaction that just committed.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void a_synchronization_may_open_its_own_transaction() {
|
||||||
|
List<Session> sessionsSeen = new ArrayList<>();
|
||||||
|
List<Boolean> wasNewTransaction = new ArrayList<>();
|
||||||
|
|
||||||
|
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
Session committedSession = outer.resource(Session.class);
|
||||||
|
ResourceRegistry.addSynchronization(new TxSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void afterCommit() {
|
||||||
|
TxStatus own = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
sessionsSeen.add(own.resource(Session.class));
|
||||||
|
wasNewTransaction.add(own.isNewTransaction());
|
||||||
|
manager.commit(own);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.commit(outer);
|
||||||
|
|
||||||
|
assertEquals(1, sessionsSeen.size(), "the callback must have run");
|
||||||
|
assertEquals(List.of(true), wasNewTransaction, "must start its own transaction, not join the committed one");
|
||||||
|
assertNotSame(committedSession, sessionsSeen.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A joined (REQUIRED) inner commit is not a real commit — callbacks wait for the outermost one. */
|
||||||
|
@Test
|
||||||
|
void a_joined_commit_defers_synchronizations_to_the_outermost_commit() {
|
||||||
|
Recorder recorder = new Recorder();
|
||||||
|
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
|
||||||
|
ResourceRegistry.addSynchronization(recorder);
|
||||||
|
|
||||||
|
manager.commit(inner);
|
||||||
|
assertEquals(List.of(), recorder.calls, "the joined commit did not commit anything yet");
|
||||||
|
|
||||||
|
manager.commit(outer);
|
||||||
|
assertEquals(COMMITTED, recorder.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Each callback belongs to one transaction: a second transaction must not re-run the first's. */
|
||||||
|
@Test
|
||||||
|
void synchronizations_do_not_leak_into_the_next_transaction() {
|
||||||
|
Recorder recorder = new Recorder();
|
||||||
|
TxStatus first = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
ResourceRegistry.addSynchronization(recorder);
|
||||||
|
manager.commit(first);
|
||||||
|
recorder.calls.clear();
|
||||||
|
|
||||||
|
TxStatus second = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
manager.commit(second);
|
||||||
|
|
||||||
|
assertEquals(List.of(), recorder.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A REQUIRES_NEW inner transaction suspends the outer one; committing the inner must not drag
|
||||||
|
* the still-pending outer transaction's callbacks along with it. They belong to a transaction
|
||||||
|
* that has not committed — and may yet roll back, in which case firing {@code afterCommit} for
|
||||||
|
* it would be a straight lie.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void a_requires_new_commit_leaves_the_suspended_transactions_synchronizations_alone() {
|
||||||
|
Recorder outerSync = new Recorder();
|
||||||
|
Recorder innerSync = new Recorder();
|
||||||
|
|
||||||
|
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
ResourceRegistry.addSynchronization(outerSync);
|
||||||
|
|
||||||
|
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW));
|
||||||
|
ResourceRegistry.addSynchronization(innerSync);
|
||||||
|
manager.commit(inner);
|
||||||
|
|
||||||
|
assertEquals(COMMITTED, innerSync.calls);
|
||||||
|
assertEquals(List.of(), outerSync.calls, "the outer transaction has not committed yet");
|
||||||
|
|
||||||
|
manager.commit(outer);
|
||||||
|
assertEquals(COMMITTED, outerSync.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A rolled-back inner REQUIRES_NEW must not fire the outer's callbacks either — same reason, opposite outcome. */
|
||||||
|
@Test
|
||||||
|
void a_requires_new_rollback_leaves_the_suspended_transactions_synchronizations_alone() {
|
||||||
|
Recorder outerSync = new Recorder();
|
||||||
|
|
||||||
|
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
ResourceRegistry.addSynchronization(outerSync);
|
||||||
|
|
||||||
|
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW));
|
||||||
|
manager.rollback(inner);
|
||||||
|
|
||||||
|
assertEquals(List.of(), outerSync.calls, "the outer transaction is still open");
|
||||||
|
|
||||||
|
manager.commit(outer);
|
||||||
|
assertEquals(COMMITTED, outerSync.calls);
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -65,4 +65,44 @@ class HibernateTxManagerTest {
|
|||||||
assertTrue(outer.isRollbackOnly());
|
assertTrue(outer.isRollbackOnly());
|
||||||
manager.rollback(outer);
|
manager.rollback(outer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** SUPPORTS without an active transaction yields a sessionless status: not a transaction, no session to hand out. */
|
||||||
|
@Test
|
||||||
|
void supports_without_active_transaction_is_a_sessionless_no_op() {
|
||||||
|
TxStatus s = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.SUPPORTS));
|
||||||
|
|
||||||
|
assertFalse(s.isNewTransaction());
|
||||||
|
assertThrows(IllegalStateException.class, () -> s.resource(Session.class));
|
||||||
|
assertDoesNotThrow(() -> manager.commit(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void not_supported_suspends_the_active_transaction_and_restores_it_on_commit() {
|
||||||
|
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
Session outerSession = outer.resource(Session.class);
|
||||||
|
|
||||||
|
TxStatus suspended = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.NOT_SUPPORTED));
|
||||||
|
assertFalse(suspended.isNewTransaction());
|
||||||
|
assertThrows(IllegalStateException.class, () -> suspended.resource(Session.class));
|
||||||
|
manager.commit(suspended);
|
||||||
|
|
||||||
|
TxStatus rejoined = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
|
||||||
|
assertSame(outerSession, rejoined.resource(Session.class), "the suspended transaction must be back");
|
||||||
|
manager.rollback(outer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mandatory_without_active_transaction_is_rejected() {
|
||||||
|
assertThrows(IllegalStateException.class,
|
||||||
|
() -> manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.MANDATORY)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A read-only join onto a read-write transaction is a contract violation, not a silent downgrade. */
|
||||||
|
@Test
|
||||||
|
void read_only_cannot_join_a_read_write_transaction() {
|
||||||
|
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
assertThrows(TxException.class,
|
||||||
|
() -> manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED).asReadOnly()));
|
||||||
|
manager.rollback(outer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# flash-ext-data-jdbc
|
||||||
|
|
||||||
|
JDBC backend for `flash-ext-data-core`.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This module implements `TxManager` on top of a `DataSource` and provides a raw-SQL repository base
|
||||||
|
class.
|
||||||
|
|
||||||
|
## How to use it
|
||||||
|
|
||||||
|
### 1. Create the manager
|
||||||
|
|
||||||
|
```java
|
||||||
|
DataSource dataSource = ...;
|
||||||
|
JdbcTxManager txManager = new JdbcTxManager(dataSource);
|
||||||
|
DataExtension extension = new DataExtension(txManager);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Install the extension in Flash
|
||||||
|
|
||||||
|
As with Hibernate, `DataExtension` registers `Tx` in the `FlashContext` and enables
|
||||||
|
`@Transactional` on class-based handlers.
|
||||||
|
|
||||||
|
### 3. Define a repository
|
||||||
|
|
||||||
|
```java
|
||||||
|
public final class UserRepository extends JdbcRepository<User, Long> {
|
||||||
|
public UserRepository(Tx tx) {
|
||||||
|
super(tx, "users", "id");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected User mapRow(ResultSet rs) throws SQLException {
|
||||||
|
return new User(rs.getLong("id"), rs.getString("name"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Here too you can expose reusable `Spec`s and compose queries from the service layer:
|
||||||
|
|
||||||
|
```java
|
||||||
|
public final class UserRepository extends JdbcRepository<User, Long> {
|
||||||
|
public static final SpecBuilder.FieldSpec<User, String> EMAIL = SpecBuilder.field("email");
|
||||||
|
|
||||||
|
public UserRepository(Tx tx) {
|
||||||
|
super(tx, "users", "id");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Saving and updating need an explicit binding:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Override
|
||||||
|
protected String insertSql() {
|
||||||
|
return "insert into users(name) values(?)";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void bindInsert(PreparedStatement ps, User entity) throws SQLException {
|
||||||
|
ps.setString(1, entity.name());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## How it works underneath
|
||||||
|
|
||||||
|
- The current transaction exposes a `Connection`.
|
||||||
|
- `Tx.resource(Connection.class)` retrieves the connection bound to the thread.
|
||||||
|
- `REQUIRES_NEW` suspends the active connection and opens a new one.
|
||||||
|
- `NOT_SUPPORTED` suspends the context and continues without a transaction.
|
||||||
|
|
||||||
|
## Repository base class
|
||||||
|
|
||||||
|
`JdbcRepository` provides:
|
||||||
|
|
||||||
|
- `select` queries through `queryOne`, `queryMany`
|
||||||
|
- mutations through `mutate`
|
||||||
|
- persistence through `doSave`, `doUpdate`
|
||||||
|
- paging through `doFindPage`
|
||||||
|
- bulk `deleteAll(Spec<T>)`
|
||||||
|
- raw helpers `queryOne(...)`, `queryMany(...)`, `mutate(...)`
|
||||||
|
|
||||||
|
Concrete repositories only have to translate between `ResultSet` and the domain.
|
||||||
|
|
||||||
|
## Transactional semantics
|
||||||
|
|
||||||
|
- `REQUIRED`: join, or open a new transaction.
|
||||||
|
- `REQUIRES_NEW`: suspend the current context.
|
||||||
|
- `SUPPORTS`: join if a transaction exists, otherwise no-op.
|
||||||
|
- `NOT_SUPPORTED`: suspend and continue without a transaction.
|
||||||
|
- `MANDATORY`: fail if there is no transaction.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The `Connection` is closed when a new transaction ends.
|
||||||
|
- Synchronizations registered in a transaction fire when *that* transaction completes:
|
||||||
|
`beforeCommit` while it is still active and the `Connection` still bound, the post-completion
|
||||||
|
hooks once it is unbound. See `flash-ext-data-core/docs/README.md` for the full contract.
|
||||||
|
- If a repository uses `doDelete(T)`, the default behaviour is unsupported: use `deleteById` or
|
||||||
|
override it.
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash-extensions</artifactId>
|
<artifactId>flash-extensions</artifactId>
|
||||||
<version>2.0.0</version>
|
<version>2.1.0-SNAPSHOT</version>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<artifactId>flash-ext-data-jdbc</artifactId>
|
<artifactId>flash-ext-data-jdbc</artifactId>
|
||||||
|
|||||||
+94
-62
@@ -3,87 +3,99 @@ package dev.relism.flash.ext.data.jdbc;
|
|||||||
import dev.relism.flash.ext.data.core.*;
|
import dev.relism.flash.ext.data.core.*;
|
||||||
|
|
||||||
import java.sql.*;
|
import java.sql.*;
|
||||||
import java.util.*;
|
import java.util.ArrayList;
|
||||||
import java.util.stream.Collectors;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
public abstract class JdbcRepository<T, ID> extends Repository<T, ID> {
|
public abstract class JdbcRepository<T, ID> extends Repository<T, ID> {
|
||||||
|
|
||||||
private final String table;
|
private final String table;
|
||||||
private final String idColumn;
|
private final String idColumn;
|
||||||
|
|
||||||
protected JdbcRepository(String table, String idColumn) {
|
protected JdbcRepository(Tx tx, String table, String idColumn) {
|
||||||
this.table = table;
|
super(tx);
|
||||||
|
this.table = table;
|
||||||
this.idColumn = idColumn;
|
this.idColumn = idColumn;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected Connection connection() {
|
protected Connection connection() {
|
||||||
return Tx.resource(Connection.class);
|
return tx().resource(Connection.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Subclass contract ─────────────────────────────────────────────────────
|
protected abstract T mapRow(ResultSet rs) throws SQLException;
|
||||||
|
protected abstract void bindInsert(PreparedStatement ps, T entity) throws SQLException;
|
||||||
protected abstract T mapRow(ResultSet rs) throws SQLException;
|
protected abstract void bindUpdate(PreparedStatement ps, T entity) throws SQLException;
|
||||||
protected abstract void bindInsert(PreparedStatement ps, T entity) throws SQLException;
|
|
||||||
protected abstract void bindUpdate(PreparedStatement ps, T entity) throws SQLException;
|
|
||||||
protected abstract String insertSql();
|
protected abstract String insertSql();
|
||||||
protected abstract String updateSql();
|
protected abstract String updateSql();
|
||||||
|
|
||||||
// ── Repository abstract impl ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected Optional<T> doFindById(ID id) {
|
protected Optional<T> doFindById(ID id) {
|
||||||
return queryOne("select * from " + table + " where " + idColumn + " = ?",
|
return queryOne("select * from " + table + " where " + idColumn + " = ?", ps -> ps.setObject(1, id));
|
||||||
ps -> ps.setObject(1, id));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected List<T> doFindAll() {
|
protected List<T> doFind(Query<T> query) {
|
||||||
return queryMany("select * from " + table, ps -> {});
|
JdbcSpecContext ctx = new JdbcSpecContext();
|
||||||
}
|
String where = query.spec() != null ? " where " + query.spec().toFragment(ctx) : "";
|
||||||
|
String order = query.sort() != null && query.sort().isSorted() ? " order by " + orderClause(query.sort()) : "";
|
||||||
|
String paging = query.isPaged() ? " limit ? offset ?" : "";
|
||||||
|
|
||||||
@Override
|
return queryMany("select * from " + table + where + order + paging, ps -> {
|
||||||
protected List<T> doFindAll(int page, int size) {
|
if (query.isPaged()) {
|
||||||
return queryMany("select * from " + table + " limit ? offset ?", ps -> {
|
ctx.applyParameters(ps);
|
||||||
ps.setInt(1, size);
|
int base = ctx.size();
|
||||||
ps.setInt(2, page * size);
|
ps.setInt(base + 1, query.size());
|
||||||
|
ps.setInt(base + 2, query.page() * query.size());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.applyParameters(ps);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected List<T> doFindAll(Sort sort) {
|
protected Optional<T> doFindOne(Spec<T> spec) {
|
||||||
return queryMany("select * from " + table + orderClause(sort), ps -> {});
|
return doFind(Query.<T>all().where(spec).page(0, 1)).stream().findFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected List<T> doFindAll(int page, int size, Sort sort) {
|
protected Page<T> doFindPage(Query<T> query) {
|
||||||
return queryMany("select * from " + table + orderClause(sort) + " limit ? offset ?",
|
if (!query.isPaged()) {
|
||||||
ps -> {
|
throw new IllegalArgumentException("Paged query requires page and size");
|
||||||
ps.setInt(1, size);
|
}
|
||||||
ps.setInt(2, page * size);
|
long total = countWhere(query.spec());
|
||||||
});
|
List<T> content = doFind(query);
|
||||||
|
return new Page<>(content, query.page(), query.size(), total);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected Page<T> doFindPage(int page, int size) {
|
protected boolean doExistsById(ID id) {
|
||||||
long total = doCount();
|
return queryOne("select 1 from " + table + " where " + idColumn + " = ?", ps -> ps.setObject(1, id), rs -> rs.getInt(1)).isPresent();
|
||||||
return new Page<>(doFindAll(page, size), page, size, total);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected Page<T> doFindPage(int page, int size, Sort sort) {
|
protected long doCount() {
|
||||||
long total = doCount();
|
return queryOne("select count(*) from " + table, ps -> {}, rs -> rs.getLong(1)).orElse(0L);
|
||||||
return new Page<>(doFindAll(page, size, sort), page, size, total);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected T doSave(T entity) {
|
protected T doSave(T entity) {
|
||||||
try (PreparedStatement ps = connection().prepareStatement(
|
try (PreparedStatement ps = connection().prepareStatement(insertSql(), Statement.RETURN_GENERATED_KEYS)) {
|
||||||
insertSql(), Statement.RETURN_GENERATED_KEYS)) {
|
|
||||||
bindInsert(ps, entity);
|
bindInsert(ps, entity);
|
||||||
ps.executeUpdate();
|
ps.executeUpdate();
|
||||||
applyGeneratedKey(ps, entity);
|
applyGeneratedKey(ps, entity);
|
||||||
return entity;
|
return entity;
|
||||||
} catch (SQLException e) { throw new TxException(e); }
|
} catch (SQLException e) {
|
||||||
|
throw new TxException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected List<T> doSaveAll(Iterable<T> entities) {
|
||||||
|
List<T> saved = new ArrayList<>();
|
||||||
|
for (T entity : entities) {
|
||||||
|
saved.add(doSave(entity));
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -92,7 +104,9 @@ public abstract class JdbcRepository<T, ID> extends Repository<T, ID> {
|
|||||||
bindUpdate(ps, entity);
|
bindUpdate(ps, entity);
|
||||||
ps.executeUpdate();
|
ps.executeUpdate();
|
||||||
return entity;
|
return entity;
|
||||||
} catch (SQLException e) { throw new TxException(e); }
|
} catch (SQLException e) {
|
||||||
|
throw new TxException(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -102,38 +116,35 @@ public abstract class JdbcRepository<T, ID> extends Repository<T, ID> {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void doDeleteById(ID id) {
|
protected void doDeleteById(ID id) {
|
||||||
mutate("delete from " + table + " where " + idColumn + " = ?",
|
mutate("delete from " + table + " where " + idColumn + " = ?", ps -> ps.setObject(1, id));
|
||||||
ps -> ps.setObject(1, id));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected boolean doExistsById(ID id) {
|
protected int doDeleteAll(Spec<T> spec) {
|
||||||
return queryOne("select 1 from " + table + " where " + idColumn + " = ?",
|
JdbcSpecContext ctx = new JdbcSpecContext();
|
||||||
ps -> ps.setObject(1, id),
|
String where = " where " + spec.toFragment(ctx);
|
||||||
rs -> rs.getInt(1)).isPresent();
|
return mutate("delete from " + table + where, ctx::applyParameters);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected long doCount() {
|
protected int doUpdateAll(Spec<T> spec, T patch) {
|
||||||
return queryOne("select count(*) from " + table, ps -> {},
|
throw new UnsupportedOperationException("Override doUpdateAll() for bulk UPDATE support");
|
||||||
rs -> rs.getLong(1)).orElse(0L);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Query helpers ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
protected Optional<T> queryOne(String sql, SqlBinder params) {
|
protected Optional<T> queryOne(String sql, SqlBinder params) {
|
||||||
List<T> r = queryMany(sql, params);
|
List<T> r = queryMany(sql, params);
|
||||||
return r.isEmpty() ? Optional.empty() : Optional.of(r.get(0));
|
return r.isEmpty() ? Optional.empty() : Optional.of(r.get(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected <R> Optional<R> queryOne(String sql, SqlBinder params,
|
protected <R> Optional<R> queryOne(String sql, SqlBinder params, SqlMapper<R> mapper) {
|
||||||
SqlMapper<R> mapper) {
|
|
||||||
try (PreparedStatement ps = connection().prepareStatement(sql)) {
|
try (PreparedStatement ps = connection().prepareStatement(sql)) {
|
||||||
params.bind(ps);
|
params.bind(ps);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
return rs.next() ? Optional.of(mapper.map(rs)) : Optional.empty();
|
return rs.next() ? Optional.of(mapper.map(rs)) : Optional.empty();
|
||||||
}
|
}
|
||||||
} catch (SQLException e) { throw new TxException(e); }
|
} catch (SQLException e) {
|
||||||
|
throw new TxException(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected List<T> queryMany(String sql, SqlBinder params) {
|
protected List<T> queryMany(String sql, SqlBinder params) {
|
||||||
@@ -144,26 +155,47 @@ public abstract class JdbcRepository<T, ID> extends Repository<T, ID> {
|
|||||||
while (rs.next()) results.add(mapRow(rs));
|
while (rs.next()) results.add(mapRow(rs));
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
} catch (SQLException e) { throw new TxException(e); }
|
} catch (SQLException e) {
|
||||||
|
throw new TxException(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected int mutate(String sql, SqlBinder params) {
|
protected int mutate(String sql, SqlBinder params) {
|
||||||
try (PreparedStatement ps = connection().prepareStatement(sql)) {
|
try (PreparedStatement ps = connection().prepareStatement(sql)) {
|
||||||
params.bind(ps);
|
params.bind(ps);
|
||||||
return ps.executeUpdate();
|
return ps.executeUpdate();
|
||||||
} catch (SQLException e) { throw new TxException(e); }
|
} catch (SQLException e) {
|
||||||
|
throw new TxException(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void applyGeneratedKey(PreparedStatement ps, T entity) throws SQLException {
|
protected void applyGeneratedKey(PreparedStatement ps, T entity) throws SQLException {
|
||||||
// override when entity has a generated PK
|
// override when entity has a generated PK
|
||||||
}
|
}
|
||||||
|
|
||||||
private String orderClause(Sort sort) {
|
protected Class<T> entityType() {
|
||||||
return " order by " + sort.columns().stream()
|
return null;
|
||||||
.map(c -> c.column() + (c.asc() ? " ASC" : " DESC"))
|
|
||||||
.collect(Collectors.joining(", "));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@FunctionalInterface public interface SqlBinder { void bind(PreparedStatement ps) throws SQLException; }
|
private long countWhere(Spec<T> spec) {
|
||||||
@FunctionalInterface public interface SqlMapper<R> { R map(ResultSet rs) throws SQLException; }
|
JdbcSpecContext ctx = new JdbcSpecContext();
|
||||||
|
String where = spec != null ? " where " + spec.toFragment(ctx) : "";
|
||||||
|
return queryOne("select count(*) from " + table + where, ctx::applyParameters, rs -> rs.getLong(1)).orElse(0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String orderClause(Sort sort) {
|
||||||
|
return sort.columns().stream()
|
||||||
|
.map(c -> c.column() + (c.asc() ? " ASC" : " DESC"))
|
||||||
|
.collect(java.util.stream.Collectors.joining(", "));
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface SqlBinder {
|
||||||
|
void bind(PreparedStatement ps) throws SQLException;
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface SqlMapper<R> {
|
||||||
|
R map(ResultSet rs) throws SQLException;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+28
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+114
-15
@@ -9,6 +9,7 @@ import java.util.Objects;
|
|||||||
|
|
||||||
public class JdbcTxManager implements TxManager {
|
public class JdbcTxManager implements TxManager {
|
||||||
private static final TxResourceKey JDBC_STATUS_KEY = TxResourceKey.of("jdbc.tx.status");
|
private static final TxResourceKey JDBC_STATUS_KEY = TxResourceKey.of("jdbc.tx.status");
|
||||||
|
private static final TxResourceKey JDBC_SUSPENDED_KEY = TxResourceKey.of("jdbc.tx.suspended");
|
||||||
|
|
||||||
private final DataSource ds;
|
private final DataSource ds;
|
||||||
|
|
||||||
@@ -23,22 +24,30 @@ public class JdbcTxManager implements TxManager {
|
|||||||
? joinExisting(definition)
|
? joinExisting(definition)
|
||||||
: beginNew(definition);
|
: beginNew(definition);
|
||||||
case REQUIRES_NEW -> beginNew(definition);
|
case REQUIRES_NEW -> beginNew(definition);
|
||||||
|
case SUPPORTS -> ResourceRegistry.isBound(JDBC_STATUS_KEY)
|
||||||
|
? joinExisting(definition)
|
||||||
|
: noOp(definition);
|
||||||
case MANDATORY -> {
|
case MANDATORY -> {
|
||||||
if (!ResourceRegistry.isBound(JDBC_STATUS_KEY))
|
if (!ResourceRegistry.isBound(JDBC_STATUS_KEY))
|
||||||
throw new IllegalStateException("MANDATORY: no active transaction");
|
throw new IllegalStateException("MANDATORY: no active transaction");
|
||||||
yield joinExisting(definition);
|
yield joinExisting(definition);
|
||||||
}
|
}
|
||||||
case NOT_SUPPORTED -> throw new UnsupportedOperationException("NOT_SUPPORTED is not implemented");
|
case NOT_SUPPORTED -> {
|
||||||
|
JdbcTxStatus suspended = suspendIfNeeded();
|
||||||
|
yield noOp(definition, suspended);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private TxStatus beginNew(TxDefinition definition) {
|
private TxStatus beginNew(TxDefinition definition) {
|
||||||
|
Connection conn = null;
|
||||||
|
// Anything already registered belongs to an enclosing transaction this one is nested
|
||||||
|
// inside (or suspended over) — see ResourceRegistry#fireSynchronizations.
|
||||||
|
int synchronizationBaseline = ResourceRegistry.synchronizationCount();
|
||||||
|
JdbcTxStatus suspended = suspendIfNeeded();
|
||||||
|
boolean bound = false;
|
||||||
try {
|
try {
|
||||||
JdbcTxStatus suspended = ResourceRegistry.getOrNull(JDBC_STATUS_KEY, JdbcTxStatus.class);
|
conn = ds.getConnection();
|
||||||
if (suspended != null) {
|
|
||||||
ResourceRegistry.unbind(JDBC_STATUS_KEY);
|
|
||||||
}
|
|
||||||
Connection conn = ds.getConnection();
|
|
||||||
conn.setAutoCommit(false);
|
conn.setAutoCommit(false);
|
||||||
if (definition.readOnly()) conn.setReadOnly(true);
|
if (definition.readOnly()) conn.setReadOnly(true);
|
||||||
if (definition.isolation() != TransactionIsolation.DEFAULT) {
|
if (definition.isolation() != TransactionIsolation.DEFAULT) {
|
||||||
@@ -49,12 +58,20 @@ public class JdbcTxManager implements TxManager {
|
|||||||
true,
|
true,
|
||||||
definition.readOnly(),
|
definition.readOnly(),
|
||||||
suspended,
|
suspended,
|
||||||
new JdbcTxStatus.RollbackMarker()
|
new JdbcTxStatus.RollbackMarker(),
|
||||||
|
synchronizationBaseline
|
||||||
);
|
);
|
||||||
ResourceRegistry.bind(JDBC_STATUS_KEY, status);
|
ResourceRegistry.bind(JDBC_STATUS_KEY, status);
|
||||||
|
bound = true;
|
||||||
return status;
|
return status;
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
|
silentClose(conn);
|
||||||
throw new TxException(e);
|
throw new TxException(e);
|
||||||
|
} finally {
|
||||||
|
if (!bound && suspended != null) {
|
||||||
|
ResourceRegistry.unbind(JDBC_SUSPENDED_KEY);
|
||||||
|
ResourceRegistry.bind(JDBC_STATUS_KEY, suspended);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,62 +80,144 @@ public class JdbcTxManager implements TxManager {
|
|||||||
if (definition.readOnly() && !existing.isReadOnly()) {
|
if (definition.readOnly() && !existing.isReadOnly()) {
|
||||||
throw new TxException("Cannot join read-write tx as read-only");
|
throw new TxException("Cannot join read-write tx as read-only");
|
||||||
}
|
}
|
||||||
|
// Baseline 0 is never read: a joined status isn't a new transaction, so commit()/rollback()
|
||||||
|
// hand it straight back to the transaction it joined without firing anything.
|
||||||
return new JdbcTxStatus(
|
return new JdbcTxStatus(
|
||||||
existing.connection(),
|
existing.connection(),
|
||||||
false,
|
false,
|
||||||
definition.readOnly(),
|
definition.readOnly(),
|
||||||
null,
|
null,
|
||||||
existing.rollbackMarker()
|
existing.rollbackMarker(),
|
||||||
|
0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private TxStatus noOp(TxDefinition definition) {
|
||||||
|
return noOp(definition, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TxStatus noOp(TxDefinition definition, JdbcTxStatus suspended) {
|
||||||
|
return new JdbcTxStatus(null, false, definition.readOnly(), suspended, new JdbcTxStatus.RollbackMarker(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JdbcTxStatus suspendIfNeeded() {
|
||||||
|
JdbcTxStatus suspended = ResourceRegistry.getOrNull(JDBC_STATUS_KEY, JdbcTxStatus.class);
|
||||||
|
if (suspended != null) {
|
||||||
|
ResourceRegistry.unbind(JDBC_STATUS_KEY);
|
||||||
|
ResourceRegistry.bind(JDBC_SUSPENDED_KEY, suspended);
|
||||||
|
}
|
||||||
|
return suspended;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void commit(TxStatus status) {
|
public void commit(TxStatus status) {
|
||||||
JdbcTxStatus s = (JdbcTxStatus) status;
|
JdbcTxStatus s = (JdbcTxStatus) status;
|
||||||
if (!s.isNewTransaction()) {
|
if (!s.isNewTransaction()) {
|
||||||
|
resumeIfNeeded(s);
|
||||||
|
cleanupIfIdle();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
TxOutcome outcome = null;
|
||||||
try {
|
try {
|
||||||
if (s.isRollbackOnly()) {
|
if (s.isRollbackOnly()) {
|
||||||
s.connection().rollback();
|
s.connection().rollback();
|
||||||
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK);
|
outcome = TxOutcome.ROLLED_BACK;
|
||||||
return;
|
} else {
|
||||||
|
// Still inside the transaction, connection still bound: a beforeCommit callback
|
||||||
|
// can write through it and land in this same commit. Throwing from there vetoes
|
||||||
|
// the commit — see TxSynchronization.
|
||||||
|
ResourceRegistry.fireBeforeCommit(s.isReadOnly(), s.synchronizationBaseline());
|
||||||
|
s.connection().commit();
|
||||||
|
outcome = TxOutcome.COMMITTED;
|
||||||
}
|
}
|
||||||
s.connection().commit();
|
|
||||||
ResourceRegistry.fireSynchronizations(TxOutcome.COMMITTED);
|
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
throw new TxException(e);
|
TxException wrapped = new TxException(e);
|
||||||
|
outcome = rollbackAfterFailedCommit(s, wrapped);
|
||||||
|
throw wrapped;
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
outcome = rollbackAfterFailedCommit(s, e);
|
||||||
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
|
// Unbind the connection before firing, so a callback that opens its own transaction
|
||||||
|
// gets a fresh one instead of joining the connection that just committed — and fire
|
||||||
|
// before cleanupIfIdle(), whose ResourceRegistry.cleanup() drops the pending list.
|
||||||
cleanupAndResume(s);
|
cleanupAndResume(s);
|
||||||
|
if (outcome != null) ResourceRegistry.fireSynchronizations(outcome, s.synchronizationBaseline());
|
||||||
|
cleanupIfIdle();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nothing was committed — a vetoing {@code beforeCommit}, or a commit that failed outright —
|
||||||
|
* so undo whatever the transaction had done and report {@code ROLLED_BACK} to the callbacks
|
||||||
|
* still queued behind it. A failure to roll back is attached to the exception already on its
|
||||||
|
* way out rather than replacing it: that one explains what actually went wrong.
|
||||||
|
*/
|
||||||
|
private static TxOutcome rollbackAfterFailedCommit(JdbcTxStatus s, Throwable propagating) {
|
||||||
|
try {
|
||||||
|
s.connection().rollback();
|
||||||
|
} catch (SQLException suppressed) {
|
||||||
|
propagating.addSuppressed(suppressed);
|
||||||
|
}
|
||||||
|
return TxOutcome.ROLLED_BACK;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void rollback(TxStatus status) {
|
public void rollback(TxStatus status) {
|
||||||
JdbcTxStatus s = (JdbcTxStatus) status;
|
JdbcTxStatus s = (JdbcTxStatus) status;
|
||||||
if (!s.isNewTransaction()) {
|
if (!s.isNewTransaction()) {
|
||||||
s.markRollbackOnly();
|
s.markRollbackOnly();
|
||||||
|
resumeIfNeeded(s);
|
||||||
|
cleanupIfIdle();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
s.connection().rollback();
|
s.connection().rollback();
|
||||||
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK);
|
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
throw new TxException(e);
|
throw new TxException(e);
|
||||||
} finally {
|
} finally {
|
||||||
|
// Same order as commit() above, for the same two reasons.
|
||||||
cleanupAndResume(s);
|
cleanupAndResume(s);
|
||||||
|
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK, s.synchronizationBaseline());
|
||||||
|
cleanupIfIdle();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void cleanupAndResume(JdbcTxStatus status) {
|
private void cleanupAndResume(JdbcTxStatus status) {
|
||||||
ResourceRegistry.unbind(JDBC_STATUS_KEY);
|
ResourceRegistry.unbind(JDBC_STATUS_KEY);
|
||||||
try {
|
try {
|
||||||
status.connection().close();
|
if (status.connection() != null) {
|
||||||
|
status.connection().close();
|
||||||
|
}
|
||||||
} catch (SQLException ignored) {
|
} catch (SQLException ignored) {
|
||||||
}
|
}
|
||||||
|
resumeIfNeeded(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cleanupIfIdle() {
|
||||||
|
if (!ResourceRegistry.isBound(JDBC_STATUS_KEY) && !ResourceRegistry.isBound(JDBC_SUSPENDED_KEY)) {
|
||||||
|
ResourceRegistry.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void resumeIfNeeded(JdbcTxStatus status) {
|
||||||
JdbcTxStatus suspended = status.suspended();
|
JdbcTxStatus suspended = status.suspended();
|
||||||
|
if (suspended == null) {
|
||||||
|
suspended = ResourceRegistry.getOrNull(JDBC_SUSPENDED_KEY, JdbcTxStatus.class);
|
||||||
|
}
|
||||||
if (suspended != null) {
|
if (suspended != null) {
|
||||||
|
ResourceRegistry.unbind(JDBC_SUSPENDED_KEY);
|
||||||
ResourceRegistry.bind(JDBC_STATUS_KEY, suspended);
|
ResourceRegistry.bind(JDBC_STATUS_KEY, suspended);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void silentClose(Connection connection) {
|
||||||
|
if (connection == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
connection.close();
|
||||||
|
} catch (SQLException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-3
@@ -3,7 +3,6 @@ package dev.relism.flash.ext.data.jdbc;
|
|||||||
import dev.relism.flash.ext.data.core.TxStatus;
|
import dev.relism.flash.ext.data.core.TxStatus;
|
||||||
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
class JdbcTxStatus implements TxStatus {
|
class JdbcTxStatus implements TxStatus {
|
||||||
static final class RollbackMarker {
|
static final class RollbackMarker {
|
||||||
@@ -15,19 +14,27 @@ class JdbcTxStatus implements TxStatus {
|
|||||||
private final boolean readOnly;
|
private final boolean readOnly;
|
||||||
private final JdbcTxStatus suspended;
|
private final JdbcTxStatus suspended;
|
||||||
private final RollbackMarker rollbackMarker;
|
private final RollbackMarker rollbackMarker;
|
||||||
|
private final int synchronizationBaseline;
|
||||||
|
|
||||||
|
// No requireNonNull on the connection: a SUPPORTS-without-a-transaction or a NOT_SUPPORTED
|
||||||
|
// status is deliberately connectionless (see JdbcTxManager#noOp), and rejecting null here
|
||||||
|
// turned both of those propagations into an NPE at begin() — the Hibernate manager has
|
||||||
|
// always allowed it. resource() reports the real mistake, asking a connectionless status for
|
||||||
|
// its connection, where it can name it.
|
||||||
JdbcTxStatus(
|
JdbcTxStatus(
|
||||||
Connection connection,
|
Connection connection,
|
||||||
boolean newTransaction,
|
boolean newTransaction,
|
||||||
boolean readOnly,
|
boolean readOnly,
|
||||||
JdbcTxStatus suspended,
|
JdbcTxStatus suspended,
|
||||||
RollbackMarker rollbackMarker
|
RollbackMarker rollbackMarker,
|
||||||
|
int synchronizationBaseline
|
||||||
) {
|
) {
|
||||||
this.connection = Objects.requireNonNull(connection);
|
this.connection = connection;
|
||||||
this.newTransaction = newTransaction;
|
this.newTransaction = newTransaction;
|
||||||
this.readOnly = readOnly;
|
this.readOnly = readOnly;
|
||||||
this.suspended = suspended;
|
this.suspended = suspended;
|
||||||
this.rollbackMarker = rollbackMarker;
|
this.rollbackMarker = rollbackMarker;
|
||||||
|
this.synchronizationBaseline = synchronizationBaseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override public boolean isNewTransaction() { return newTransaction; }
|
@Override public boolean isNewTransaction() { return newTransaction; }
|
||||||
@@ -37,10 +44,16 @@ class JdbcTxStatus implements TxStatus {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public <R> R resource(Class<R> type) {
|
public <R> R resource(Class<R> type) {
|
||||||
|
if (connection == null) {
|
||||||
|
throw new IllegalStateException("No connection bound to this transaction status");
|
||||||
|
}
|
||||||
return type.cast(connection);
|
return type.cast(connection);
|
||||||
}
|
}
|
||||||
|
|
||||||
Connection connection() { return connection; }
|
Connection connection() { return connection; }
|
||||||
JdbcTxStatus suspended() { return suspended; }
|
JdbcTxStatus suspended() { return suspended; }
|
||||||
RollbackMarker rollbackMarker() { return rollbackMarker; }
|
RollbackMarker rollbackMarker() { return rollbackMarker; }
|
||||||
|
|
||||||
|
/** Index into {@code ResourceRegistry}'s synchronization list where this transaction's own callbacks start. */
|
||||||
|
int synchronizationBaseline() { return synchronizationBaseline; }
|
||||||
}
|
}
|
||||||
|
|||||||
+216
@@ -0,0 +1,216 @@
|
|||||||
|
package dev.relism.flash.ext.data.jdbc;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.data.core.*;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transaction synchronization semantics for the JDBC manager — the same contract {@code
|
||||||
|
* HibernateTxManagerSynchronizationTest} pins down for the Hibernate one, kept deliberately
|
||||||
|
* parallel: the two managers are interchangeable behind {@code TxManager}, so a callback must not
|
||||||
|
* observe a different lifecycle depending on which one is installed.
|
||||||
|
*/
|
||||||
|
class JdbcTxManagerSynchronizationTest {
|
||||||
|
|
||||||
|
private final JdbcTxManager manager = new JdbcTxManager(new TestDataSource());
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void cleanup() {
|
||||||
|
ResourceRegistry.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records which callbacks ran, in order — order is part of the contract, not just the fact they ran. */
|
||||||
|
private static final class Recorder implements TxSynchronization {
|
||||||
|
final List<String> calls = new ArrayList<>();
|
||||||
|
|
||||||
|
@Override public void beforeCommit(boolean readOnly) { calls.add("beforeCommit:" + readOnly); }
|
||||||
|
@Override public void afterCommit() { calls.add("afterCommit"); }
|
||||||
|
@Override public void afterRollback() { calls.add("afterRollback"); }
|
||||||
|
@Override public void afterCompletion(TxOutcome outcome) { calls.add("afterCompletion:" + outcome); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final List<String> COMMITTED = List.of("beforeCommit:false", "afterCommit", "afterCompletion:COMMITTED");
|
||||||
|
private static final List<String> ROLLED_BACK = List.of("afterRollback", "afterCompletion:ROLLED_BACK");
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void afterCommit_fires_on_commit() {
|
||||||
|
Recorder recorder = new Recorder();
|
||||||
|
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
ResourceRegistry.addSynchronization(recorder);
|
||||||
|
|
||||||
|
manager.commit(tx);
|
||||||
|
|
||||||
|
assertEquals(COMMITTED, recorder.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void afterRollback_fires_on_rollback() {
|
||||||
|
Recorder recorder = new Recorder();
|
||||||
|
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
ResourceRegistry.addSynchronization(recorder);
|
||||||
|
|
||||||
|
manager.rollback(tx);
|
||||||
|
|
||||||
|
assertEquals(ROLLED_BACK, recorder.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void commit_of_a_rollback_only_tx_rolls_back_without_running_beforeCommit() {
|
||||||
|
Recorder recorder = new Recorder();
|
||||||
|
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
tx.markRollbackOnly();
|
||||||
|
ResourceRegistry.addSynchronization(recorder);
|
||||||
|
|
||||||
|
manager.commit(tx);
|
||||||
|
|
||||||
|
assertEquals(ROLLED_BACK, recorder.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** beforeCommit runs inside the transaction: connection still bound, nothing committed yet. */
|
||||||
|
@Test
|
||||||
|
void beforeCommit_runs_while_the_transaction_is_still_active() {
|
||||||
|
List<Connection> connectionSeen = new ArrayList<>();
|
||||||
|
|
||||||
|
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
Connection connection = tx.resource(Connection.class);
|
||||||
|
ResourceRegistry.addSynchronization(new TxSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void beforeCommit(boolean readOnly) {
|
||||||
|
// MANDATORY only succeeds while a transaction is bound — proof this runs inside it.
|
||||||
|
TxStatus joined = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.MANDATORY));
|
||||||
|
connectionSeen.add(joined.resource(Connection.class));
|
||||||
|
manager.commit(joined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.commit(tx);
|
||||||
|
|
||||||
|
assertSame(connection, connectionSeen.get(0), "the same connection must still be bound, so writes land in this commit");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void beforeCommit_is_told_whether_the_transaction_is_read_only() {
|
||||||
|
Recorder readWrite = new Recorder();
|
||||||
|
TxStatus rw = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
ResourceRegistry.addSynchronization(readWrite);
|
||||||
|
manager.commit(rw);
|
||||||
|
|
||||||
|
Recorder readOnly = new Recorder();
|
||||||
|
TxStatus ro = manager.begin(TxDefinition.DEFAULTS.asReadOnly());
|
||||||
|
ResourceRegistry.addSynchronization(readOnly);
|
||||||
|
manager.commit(ro);
|
||||||
|
|
||||||
|
assertEquals("beforeCommit:false", readWrite.calls.get(0));
|
||||||
|
assertEquals("beforeCommit:true", readOnly.calls.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throwing from beforeCommit is a veto: no commit, the rollback callbacks run, the exception propagates. */
|
||||||
|
@Test
|
||||||
|
void a_throwing_beforeCommit_vetoes_the_commit() {
|
||||||
|
Recorder recorder = new Recorder();
|
||||||
|
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
ResourceRegistry.addSynchronization(new TxSynchronization() {
|
||||||
|
@Override public void beforeCommit(boolean readOnly) { throw new IllegalStateException("veto"); }
|
||||||
|
});
|
||||||
|
ResourceRegistry.addSynchronization(recorder);
|
||||||
|
|
||||||
|
IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> manager.commit(tx));
|
||||||
|
|
||||||
|
assertEquals("veto", thrown.getMessage());
|
||||||
|
assertEquals(ROLLED_BACK, recorder.calls, "the surviving callbacks must hear ROLLED_BACK, not silence");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Post-completion callbacks run after the committed connection is unbound, so opening a transaction gets a fresh one. */
|
||||||
|
@Test
|
||||||
|
void a_synchronization_may_open_its_own_transaction() {
|
||||||
|
List<Connection> connectionsSeen = new ArrayList<>();
|
||||||
|
List<Boolean> wasNewTransaction = new ArrayList<>();
|
||||||
|
|
||||||
|
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
Connection committedConnection = outer.resource(Connection.class);
|
||||||
|
ResourceRegistry.addSynchronization(new TxSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void afterCommit() {
|
||||||
|
TxStatus own = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
connectionsSeen.add(own.resource(Connection.class));
|
||||||
|
wasNewTransaction.add(own.isNewTransaction());
|
||||||
|
manager.commit(own);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.commit(outer);
|
||||||
|
|
||||||
|
assertEquals(1, connectionsSeen.size(), "the callback must have run");
|
||||||
|
assertEquals(List.of(true), wasNewTransaction, "must start its own transaction, not join the committed one");
|
||||||
|
assertNotSame(committedConnection, connectionsSeen.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void a_joined_commit_defers_synchronizations_to_the_outermost_commit() {
|
||||||
|
Recorder recorder = new Recorder();
|
||||||
|
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
|
||||||
|
ResourceRegistry.addSynchronization(recorder);
|
||||||
|
|
||||||
|
manager.commit(inner);
|
||||||
|
assertEquals(List.of(), recorder.calls, "the joined commit did not commit anything yet");
|
||||||
|
|
||||||
|
manager.commit(outer);
|
||||||
|
assertEquals(COMMITTED, recorder.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void synchronizations_do_not_leak_into_the_next_transaction() {
|
||||||
|
Recorder recorder = new Recorder();
|
||||||
|
TxStatus first = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
ResourceRegistry.addSynchronization(recorder);
|
||||||
|
manager.commit(first);
|
||||||
|
recorder.calls.clear();
|
||||||
|
|
||||||
|
TxStatus second = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
manager.commit(second);
|
||||||
|
|
||||||
|
assertEquals(List.of(), recorder.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void a_requires_new_commit_leaves_the_suspended_transactions_synchronizations_alone() {
|
||||||
|
Recorder outerSync = new Recorder();
|
||||||
|
Recorder innerSync = new Recorder();
|
||||||
|
|
||||||
|
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
ResourceRegistry.addSynchronization(outerSync);
|
||||||
|
|
||||||
|
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW));
|
||||||
|
ResourceRegistry.addSynchronization(innerSync);
|
||||||
|
manager.commit(inner);
|
||||||
|
|
||||||
|
assertEquals(COMMITTED, innerSync.calls);
|
||||||
|
assertEquals(List.of(), outerSync.calls, "the outer transaction has not committed yet");
|
||||||
|
|
||||||
|
manager.commit(outer);
|
||||||
|
assertEquals(COMMITTED, outerSync.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void a_requires_new_rollback_leaves_the_suspended_transactions_synchronizations_alone() {
|
||||||
|
Recorder outerSync = new Recorder();
|
||||||
|
|
||||||
|
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
ResourceRegistry.addSynchronization(outerSync);
|
||||||
|
|
||||||
|
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW));
|
||||||
|
manager.rollback(inner);
|
||||||
|
|
||||||
|
assertEquals(List.of(), outerSync.calls, "the outer transaction is still open");
|
||||||
|
|
||||||
|
manager.commit(outer);
|
||||||
|
assertEquals(COMMITTED, outerSync.calls);
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
-46
@@ -4,15 +4,12 @@ import dev.relism.flash.ext.data.core.*;
|
|||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import javax.sql.DataSource;
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.DriverManager;
|
|
||||||
import java.sql.SQLException;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
class JdbcTxManagerTest {
|
class JdbcTxManagerTest {
|
||||||
private final JdbcTxManager manager = new JdbcTxManager(dataSource());
|
private final JdbcTxManager manager = new JdbcTxManager(new TestDataSource());
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
void cleanup() {
|
void cleanup() {
|
||||||
@@ -53,52 +50,38 @@ class JdbcTxManagerTest {
|
|||||||
manager.rollback(outer);
|
manager.rollback(outer);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DataSource dataSource() {
|
/**
|
||||||
return new DataSource() {
|
* SUPPORTS without an active transaction yields a connectionless status — it must not be a
|
||||||
@Override
|
* transaction, and asking it for a connection must say so rather than NPE. Both propagations
|
||||||
public Connection getConnection() throws SQLException {
|
* that produce one used to throw {@link NullPointerException} straight out of {@code begin()}.
|
||||||
return DriverManager.getConnection("jdbc:h2:mem:tx-jdbc;DB_CLOSE_DELAY=-1");
|
*/
|
||||||
}
|
@Test
|
||||||
|
void supports_without_active_transaction_is_a_connectionless_no_op() {
|
||||||
|
TxStatus s = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.SUPPORTS));
|
||||||
|
|
||||||
@Override
|
assertFalse(s.isNewTransaction());
|
||||||
public Connection getConnection(String username, String password) throws SQLException {
|
assertThrows(IllegalStateException.class, () -> s.resource(Connection.class));
|
||||||
return DriverManager.getConnection("jdbc:h2:mem:tx-jdbc;DB_CLOSE_DELAY=-1", username, password);
|
assertDoesNotThrow(() -> manager.commit(s));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Test
|
||||||
public <T> T unwrap(Class<T> iface) {
|
void not_supported_suspends_the_active_transaction_and_restores_it_on_commit() {
|
||||||
throw new UnsupportedOperationException();
|
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
||||||
}
|
Connection outerConnection = outer.resource(Connection.class);
|
||||||
|
|
||||||
@Override
|
TxStatus suspended = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.NOT_SUPPORTED));
|
||||||
public boolean isWrapperFor(Class<?> iface) {
|
assertFalse(suspended.isNewTransaction());
|
||||||
return false;
|
assertThrows(IllegalStateException.class, () -> suspended.resource(Connection.class));
|
||||||
}
|
manager.commit(suspended);
|
||||||
|
|
||||||
@Override
|
TxStatus rejoined = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
|
||||||
public java.io.PrintWriter getLogWriter() {
|
assertSame(outerConnection, rejoined.resource(Connection.class), "the suspended transaction must be back");
|
||||||
throw new UnsupportedOperationException();
|
manager.rollback(outer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Test
|
||||||
public void setLogWriter(java.io.PrintWriter out) {
|
void mandatory_without_active_transaction_is_rejected() {
|
||||||
throw new UnsupportedOperationException();
|
assertThrows(IllegalStateException.class,
|
||||||
}
|
() -> manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.MANDATORY)));
|
||||||
|
|
||||||
@Override
|
|
||||||
public void setLoginTimeout(int seconds) {
|
|
||||||
throw new UnsupportedOperationException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int getLoginTimeout() {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public java.util.logging.Logger getParentLogger() {
|
|
||||||
throw new UnsupportedOperationException();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
package dev.relism.flash.ext.data.jdbc;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.io.PrintWriter;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bare in-memory H2 {@link DataSource} — one fresh connection per {@code getConnection()}, which
|
||||||
|
* is all {@code JdbcTxManager} needs to exercise real commit/rollback and suspension. Every method
|
||||||
|
* outside the two {@code getConnection} overloads throws: nothing under test calls them, and a
|
||||||
|
* loud failure beats a silent stub if that ever changes.
|
||||||
|
*/
|
||||||
|
final class TestDataSource implements DataSource {
|
||||||
|
|
||||||
|
static final String URL = "jdbc:h2:mem:tx-jdbc;DB_CLOSE_DELAY=-1";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Connection getConnection() throws SQLException {
|
||||||
|
return DriverManager.getConnection(URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Connection getConnection(String username, String password) throws SQLException {
|
||||||
|
return DriverManager.getConnection(URL, username, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public <T> T unwrap(Class<T> iface) { throw new UnsupportedOperationException(); }
|
||||||
|
@Override public boolean isWrapperFor(Class<?> iface) { return false; }
|
||||||
|
@Override public PrintWriter getLogWriter() { throw new UnsupportedOperationException(); }
|
||||||
|
@Override public void setLogWriter(PrintWriter out) { throw new UnsupportedOperationException(); }
|
||||||
|
@Override public void setLoginTimeout(int seconds) { throw new UnsupportedOperationException(); }
|
||||||
|
@Override public int getLoginTimeout() { return 0; }
|
||||||
|
@Override public Logger getParentLogger() { throw new UnsupportedOperationException(); }
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash-extensions</artifactId>
|
<artifactId>flash-extensions</artifactId>
|
||||||
<version>2.0.0</version>
|
<version>2.1.0-SNAPSHOT</version>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<artifactId>flash-ext-jackson</artifactId>
|
<artifactId>flash-ext-jackson</artifactId>
|
||||||
|
|||||||
+4
-2
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||||
import dev.relism.flash.extension.FlashContext;
|
import dev.relism.flash.extension.FlashContext;
|
||||||
|
import dev.relism.flash.extension.FlashRegistrar;
|
||||||
import dev.relism.flash.extension.FlashExtension;
|
import dev.relism.flash.extension.FlashExtension;
|
||||||
import dev.relism.flash.routing.Middleware;
|
import dev.relism.flash.routing.Middleware;
|
||||||
|
|
||||||
@@ -12,7 +13,8 @@ import dev.relism.flash.routing.Middleware;
|
|||||||
*
|
*
|
||||||
* <p>Exposes a {@link Json} utility instance in the {@link FlashContext} under
|
* <p>Exposes a {@link Json} utility instance in the {@link FlashContext} under
|
||||||
* {@code Json.class}. Any handler or extension can retrieve it via {@code ctx.require(Json.class)}
|
* {@code Json.class}. Any handler or extension can retrieve it via {@code ctx.require(Json.class)}
|
||||||
* inside {@code onInit()} (class-based) or inside {@link FlashExtension#routes} (extensions).
|
* inside {@code onInit()} (class-based) or from a {@link FlashContext#onReady(Runnable)}
|
||||||
|
* callback (extensions).
|
||||||
*
|
*
|
||||||
* <p>The raw {@link ObjectMapper} is also registered under {@code ObjectMapper.class}
|
* <p>The raw {@link ObjectMapper} is also registered under {@code ObjectMapper.class}
|
||||||
* for extensions that need direct mapper access (e.g. OpenAPI schema generation).
|
* for extensions that need direct mapper access (e.g. OpenAPI schema generation).
|
||||||
@@ -83,7 +85,7 @@ public class JacksonExtension implements FlashExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void provide(FlashContext ctx) {
|
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
Json json = new Json(mapper);
|
Json json = new Json(mapper);
|
||||||
ctx.provide(Json.class, json);
|
ctx.provide(Json.class, json);
|
||||||
ctx.provide(ObjectMapper.class, mapper);
|
ctx.provide(ObjectMapper.class, mapper);
|
||||||
|
|||||||
+3
-2
@@ -19,12 +19,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||||||
class JacksonExtensionTest {
|
class JacksonExtensionTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void provide_registers_json_mapper_and_middleware() {
|
void configure_registers_json_mapper_and_middleware() {
|
||||||
FlashContext ctx = new FlashContext();
|
FlashContext ctx = new FlashContext();
|
||||||
ObjectMapper mapper = new ObjectMapper();
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
JacksonExtension ext = new JacksonExtension(mapper);
|
JacksonExtension ext = new JacksonExtension(mapper);
|
||||||
|
|
||||||
ext.provide(ctx);
|
ext.configure(null, ctx);
|
||||||
|
ctx.complete();
|
||||||
|
|
||||||
assertNotNull(ctx.require(Json.class));
|
assertNotNull(ctx.require(Json.class));
|
||||||
assertNotNull(ctx.require(JacksonMiddleware.class));
|
assertNotNull(ctx.require(JacksonMiddleware.class));
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||||||
import dev.relism.flash.exceptions.HttpException;
|
import dev.relism.flash.exceptions.HttpException;
|
||||||
import dev.relism.flash.http.ContentType;
|
import dev.relism.flash.http.ContentType;
|
||||||
import dev.relism.flash.http.HttpMethod;
|
import dev.relism.flash.http.HttpMethod;
|
||||||
import dev.relism.flash.models.HeaderMap;
|
import dev.relism.flash.models.Http1HeaderMap;
|
||||||
import dev.relism.flash.models.Request;
|
import dev.relism.flash.models.Request;
|
||||||
import dev.relism.flash.models.RequestLine;
|
import dev.relism.flash.models.RequestLine;
|
||||||
import dev.relism.flash.models.Response;
|
import dev.relism.flash.models.Response;
|
||||||
@@ -82,7 +82,7 @@ class JsonTest {
|
|||||||
new FastPathViews.StringByteView("/json"),
|
new FastPathViews.StringByteView("/json"),
|
||||||
null,
|
null,
|
||||||
new FastPathViews.StringByteView("HTTP/1.1"),
|
new FastPathViews.StringByteView("HTTP/1.1"),
|
||||||
new HeaderMap()
|
new Http1HeaderMap()
|
||||||
);
|
);
|
||||||
return new Request(line, body);
|
return new Request(line, body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash-extensions</artifactId>
|
<artifactId>flash-extensions</artifactId>
|
||||||
<version>2.0.0</version>
|
<version>2.1.0-SNAPSHOT</version>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<artifactId>flash-ext-limiter</artifactId>
|
<artifactId>flash-ext-limiter</artifactId>
|
||||||
|
|||||||
+14
-20
@@ -5,12 +5,13 @@ import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
|
|||||||
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
|
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
|
||||||
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
|
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
|
||||||
import dev.relism.flash.extension.AnnotationProcessor;
|
import dev.relism.flash.extension.AnnotationProcessor;
|
||||||
import dev.relism.flash.extension.ExtensionPhase;
|
import dev.relism.flash.extension.FlashRegistrar;
|
||||||
import dev.relism.flash.extension.FlashContext;
|
import dev.relism.flash.extension.FlashContext;
|
||||||
import dev.relism.flash.extension.FlashExtension;
|
import dev.relism.flash.extension.FlashExtension;
|
||||||
import dev.relism.flash.extension.FlashRegistrar;
|
|
||||||
import dev.relism.flash.http.HttpStatus;
|
import dev.relism.flash.http.HttpStatus;
|
||||||
import dev.relism.flash.routing.Middleware;
|
import dev.relism.flash.routing.Middleware;
|
||||||
|
import dev.relism.flash.routing.MiddlewareKey;
|
||||||
|
import dev.relism.flash.routing.MiddlewareNode;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -44,21 +45,16 @@ import java.util.Map;
|
|||||||
* app.install(new LimiterExtension(
|
* app.install(new LimiterExtension(
|
||||||
* new LimiterConfig().registerResolver("auth_user", req -> ClaimsHolder.user().sub())));
|
* new LimiterConfig().registerResolver("auth_user", req -> ClaimsHolder.user().sub())));
|
||||||
*
|
*
|
||||||
* // inside FlashExtension.routes() or after install():
|
* // inside a FlashContext.onReady(...) callback:
|
||||||
* Guard guard = ctx.require(Guard.class);
|
* Guard guard = ctx.require(Guard.class);
|
||||||
* app.get("/api/search", handler, guard.limit("ip", 50, 1, TimeUnit.SECONDS));
|
* app.get("/api/search", handler, guard.limit("ip", 50, 1, TimeUnit.SECONDS));
|
||||||
* }</pre>
|
* }</pre>
|
||||||
*/
|
*/
|
||||||
public final class LimiterExtension implements FlashExtension {
|
public final class LimiterExtension implements FlashExtension {
|
||||||
|
private static final MiddlewareKey LIMIT = MiddlewareKey.of("flash.limiter.limit");
|
||||||
|
|
||||||
private final LimiterConfig config;
|
private final LimiterConfig config;
|
||||||
|
|
||||||
/**
|
|
||||||
* Rate limiting runs before authentication — cheaper check rejects over-limit
|
|
||||||
* requests before any token validation occurs.
|
|
||||||
*/
|
|
||||||
@Override public int priority() { return ExtensionPhase.EARLY.value; }
|
|
||||||
|
|
||||||
/** Installs with default config (only the built-in {@code "ip"} resolver). */
|
/** Installs with default config (only the built-in {@code "ip"} resolver). */
|
||||||
public LimiterExtension() {
|
public LimiterExtension() {
|
||||||
this(new LimiterConfig());
|
this(new LimiterConfig());
|
||||||
@@ -70,7 +66,7 @@ public final class LimiterExtension implements FlashExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void provide(FlashContext ctx) {
|
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
BucketStore store = new BucketStore();
|
BucketStore store = new BucketStore();
|
||||||
Guard guard = new Guard(config, store);
|
Guard guard = new Guard(config, store);
|
||||||
|
|
||||||
@@ -90,17 +86,15 @@ public final class LimiterExtension implements FlashExtension {
|
|||||||
ann.strategy().create()
|
ann.strategy().create()
|
||||||
);
|
);
|
||||||
|
|
||||||
return List.of(buildMiddleware(resolver, cfg, store));
|
return List.of(MiddlewareNode.of(LIMIT, buildMiddleware(resolver, cfg, store)));
|
||||||
|
});
|
||||||
|
ctx.onReady(() -> {
|
||||||
|
try {
|
||||||
|
OpenApiIntegration.register(ctx);
|
||||||
|
} catch (NoClassDefFoundError ignored) {
|
||||||
|
// flash-ext-openapi not available — OpenAPI integration disabled
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
|
|
||||||
try {
|
|
||||||
OpenApiIntegration.register(ctx);
|
|
||||||
} catch (NoClassDefFoundError ignored) {
|
|
||||||
// flash-ext-openapi not available — OpenAPI integration disabled
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Package-private helper — shared with Guard ────────────────────────────
|
// ── Package-private helper — shared with Guard ────────────────────────────
|
||||||
|
|||||||
+6
-3
@@ -41,7 +41,8 @@ class LimiterOpenApiInteropTest {
|
|||||||
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
||||||
ctx.provide(OpenApiContributorRegistry.class, registry);
|
ctx.provide(OpenApiContributorRegistry.class, registry);
|
||||||
|
|
||||||
new LimiterExtension().routes(null, ctx);
|
new LimiterExtension().configure(null, ctx);
|
||||||
|
ctx.complete();
|
||||||
|
|
||||||
assertEquals(1, registry.contributors().size());
|
assertEquals(1, registry.contributors().size());
|
||||||
}
|
}
|
||||||
@@ -51,7 +52,8 @@ class LimiterOpenApiInteropTest {
|
|||||||
FlashContext ctx = new FlashContext();
|
FlashContext ctx = new FlashContext();
|
||||||
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
||||||
ctx.provide(OpenApiContributorRegistry.class, registry);
|
ctx.provide(OpenApiContributorRegistry.class, registry);
|
||||||
new LimiterExtension().routes(null, ctx);
|
new LimiterExtension().configure(null, ctx);
|
||||||
|
ctx.complete();
|
||||||
|
|
||||||
OpenApiContributor contributor = registry.contributors().getFirst();
|
OpenApiContributor contributor = registry.contributors().getFirst();
|
||||||
OpenApiOperationContribution operation = contributor.operationFor(LimitedHandler.class);
|
OpenApiOperationContribution operation = contributor.operationFor(LimitedHandler.class);
|
||||||
@@ -73,7 +75,8 @@ class LimiterOpenApiInteropTest {
|
|||||||
FlashContext ctx = new FlashContext();
|
FlashContext ctx = new FlashContext();
|
||||||
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
||||||
ctx.provide(OpenApiContributorRegistry.class, registry);
|
ctx.provide(OpenApiContributorRegistry.class, registry);
|
||||||
new LimiterExtension().routes(null, ctx);
|
new LimiterExtension().configure(null, ctx);
|
||||||
|
ctx.complete();
|
||||||
|
|
||||||
OpenApiContributor contributor = registry.contributors().getFirst();
|
OpenApiContributor contributor = registry.contributors().getFirst();
|
||||||
OpenApiOperationContribution operation = contributor.operationFor(PlainHandler.class);
|
OpenApiOperationContribution operation = contributor.operationFor(PlainHandler.class);
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# flash-ext-mcp
|
||||||
|
|
||||||
|
`flash-ext-mcp` turns a Flash5 app into an [MCP](https://modelcontextprotocol.io) (Model Context
|
||||||
|
Protocol) server: JSON-RPC 2.0 over the Streamable HTTP transport, tools/resources/prompts
|
||||||
|
declared as plain classes and discovered at boot, optional OAuth2 protection built on
|
||||||
|
`flash-ext-oidc`.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```java
|
||||||
|
FlashApp.create(8080)
|
||||||
|
.install(new McpExtension(McpConfig.builder("my-mcp-server")
|
||||||
|
.toolsPackage("com.example.tools")
|
||||||
|
.build()))
|
||||||
|
.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Tool(name = "get_weather", description = "Get current weather for a city",
|
||||||
|
args = @ToolArg(name = "city", description = "City name", required = true))
|
||||||
|
public class GetWeatherTool extends McpTool {
|
||||||
|
|
||||||
|
private WeatherService weatherService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onInit() {
|
||||||
|
weatherService = require(WeatherService.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ToolResponse call(ToolArguments args) {
|
||||||
|
return ToolResponse.success(new TextContent(weatherService.fetch(args.getString("city"))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Operating Model
|
||||||
|
|
||||||
|
- **One class per tool/resource/prompt** — mirrors `RequestHandler`: a no-arg constructor,
|
||||||
|
`onInit()` to cache services from `FlashContext`, one hot-path method
|
||||||
|
(`call`/`read`/`render`). No CDI, no field injection, no reflection on the hot path.
|
||||||
|
- **Boot-time precompilation** — `tools/list`/`resources/list`/`prompts/list` JSON payloads
|
||||||
|
(including JSON Schema) are built once at boot and spliced verbatim into responses. See
|
||||||
|
`tools-resources-prompts.md`.
|
||||||
|
- **Transport**: Streamable HTTP, `POST`-only, stateless in this revision — see `transport.md`
|
||||||
|
for exactly what that means and why.
|
||||||
|
- **Security**: optional, policy-driven OAuth2 via `flash-ext-oidc` — see `security.md`.
|
||||||
|
- **JSON**: this extension owns its JSON handling independently of `flash-ext-jackson` — see
|
||||||
|
`jackson-interop.md` for why, and how a future opt-in reuse could work.
|
||||||
|
|
||||||
|
## Documents
|
||||||
|
|
||||||
|
- [`tools-resources-prompts.md`](tools-resources-prompts.md) — defining tools, resources, prompts
|
||||||
|
- [`transport.md`](transport.md) — Streamable HTTP scope, session/SSE limitations, Origin validation
|
||||||
|
- [`security.md`](security.md) — `McpSecurity` policy, OAuth2 resolution, RFC 9728 / RFC 8707
|
||||||
|
- [`keycloak.md`](keycloak.md) — Keycloak-specific setup cookbook: Dynamic Client Registration,
|
||||||
|
the RFC 8707 audience mapper gotcha, and how to verify/debug it
|
||||||
|
- [`jackson-interop.md`](jackson-interop.md) — why this extension does not depend on `flash-ext-jackson`
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Why no `flash-ext-jackson` interop (yet)
|
||||||
|
|
||||||
|
## The decision
|
||||||
|
|
||||||
|
`flash-ext-mcp` does not depend on, or integrate with, `flash-ext-jackson`. It brings its own
|
||||||
|
JSON handling (`jackson-databind`/`jackson-core` as a plain library dependency, wrapped by the
|
||||||
|
internal `McpJson` utility) and never touches `flash-ext-jackson`'s `Json`/`JacksonMiddleware`/
|
||||||
|
shared `ObjectMapper`, even if the host app has `flash-ext-jackson` installed. This was a
|
||||||
|
deliberate choice, discussed and made explicitly — not an oversight — and is written down here
|
||||||
|
so it isn't accidentally "fixed" later without re-litigating the trade-off.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
`flash-ext-jackson`'s `Json` class is built around full databinding:
|
||||||
|
`mapper.readValue(bytes, SomeDto.class)` / `mapper.writeValueAsBytes(obj)` — reflection-driven
|
||||||
|
property matching in both directions. The MCP JSON-RPC envelope has a **fixed, known shape**
|
||||||
|
(`{jsonrpc, id, method, params}` in, `{jsonrpc, id, result|error}` out) defined by a spec, not by
|
||||||
|
application DTOs. Given that, hand-writing it with `JsonGenerator` directly is both simpler and
|
||||||
|
strictly cheaper than round-tripping through databinding: no property-name matching, no
|
||||||
|
reflection, no intermediate POJO graph for the parts of the response this extension controls
|
||||||
|
(the envelope itself, `tools/list`/`resources/list`/`prompts/list` — precompiled once at boot,
|
||||||
|
see `tools-resources-prompts.md` — and the fixed `TextContent`/`TextResourceContents`/
|
||||||
|
`PromptMessage` shapes). `ToolArguments`/`PromptArguments` read the incoming `arguments` object
|
||||||
|
as a `JsonNode` tree, not as a databound class, for the same reason — a JSON-RPC tool call's
|
||||||
|
arguments aren't a DTO with getters/setters, they're a dynamic, per-tool-defined bag of values.
|
||||||
|
|
||||||
|
This mirrors how `flash-ext-oidc` already handles its own internal JSON needs (`json-smart` for
|
||||||
|
token-endpoint responses) independently of `flash-ext-jackson` — extensions with protocol-level
|
||||||
|
JSON needs that are shaped by a spec, not by user code, own that JSON handling themselves rather
|
||||||
|
than routing it through the app's general-purpose JSON extension.
|
||||||
|
|
||||||
|
## What this means practically
|
||||||
|
|
||||||
|
- Installing `flash-ext-mcp` never requires installing `flash-ext-jackson`. A pure MCP server
|
||||||
|
with no other JSON REST routes has zero unrelated dependencies to configure.
|
||||||
|
- If the host app *does* have `flash-ext-jackson` installed for its own REST routes, that
|
||||||
|
`ObjectMapper`'s configuration (custom modules, date formatting, naming strategy, etc.) is
|
||||||
|
**not** consulted by `flash-ext-mcp` — the two JSON paths are entirely independent today.
|
||||||
|
|
||||||
|
## What a future opt-in reuse could look like
|
||||||
|
|
||||||
|
Nothing here rules out a later, additive convenience layer: `McpExtension.routes()` could check
|
||||||
|
`ctx.find(ObjectMapper.class)` (populated by `JacksonExtension.provide()`) and, if present, use
|
||||||
|
that shared mapper as the backing for an escape hatch such as `ToolArguments.as(Class<T>)` or
|
||||||
|
for a tool that wants to `ToolResponse.success(someRecord)` and have it serialized with the
|
||||||
|
app's own conventions — falling back to a locally-constructed default `ObjectMapper` when
|
||||||
|
`flash-ext-jackson` isn't installed, the same "prefer shared, degrade to sane default" shape
|
||||||
|
already used for `McpSecurity.AUTO`. That would be purely additive on top of the
|
||||||
|
`JsonGenerator`-based envelope/content writing described above, not a replacement for it — the
|
||||||
|
fixed-shape protocol plumbing has no reason to ever go through databinding, regardless of what
|
||||||
|
convenience layer gets added around it.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Keycloak cookbook
|
||||||
|
|
||||||
|
`security.md` covers the OAuth2 mechanics `McpOidcIntegration` implements against any
|
||||||
|
`flash-ext-oidc`-compatible provider. This is the Keycloak-specific setup: the exact Admin
|
||||||
|
Console configuration for a working MCP OAuth2 flow with open Dynamic Client Registration
|
||||||
|
(DCR) — no pre-registered clients, any MCP client self-registers on first connect.
|
||||||
|
|
||||||
|
## 1. Allow Dynamic Client Registration
|
||||||
|
|
||||||
|
MCP clients (Claude Desktop, Claude.ai, MCP Inspector, others) don't share one static OAuth
|
||||||
|
client — each has its own `redirect_uri` and none know your realm in advance. They self-register
|
||||||
|
on first connect via `POST {issuer}/clients-registrations/openid-connect` (the
|
||||||
|
`registration_endpoint` from the AS metadata document, reached via the RFC 9728 Protected
|
||||||
|
Resource Metadata document `McpExtension` publishes).
|
||||||
|
|
||||||
|
**Clients → Client registration**: remove the **Trusted Hosts** policy — it rejects anonymous
|
||||||
|
registration from hosts not on an explicit allowlist (`403` / `"Host not trusted"`), which
|
||||||
|
doesn't scale to arbitrary future agents. This does not weaken end-user authentication — DCR
|
||||||
|
only grants an app a `client_id`; every user still authenticates against Keycloak's real login
|
||||||
|
screen regardless of which client asked. Lighter hygiene policies (**Max Clients Limit**,
|
||||||
|
**Consent Required**) can stay, they don't interfere.
|
||||||
|
|
||||||
|
## 2. RFC 8707 audience: mapper on `basic`, not a custom scope
|
||||||
|
|
||||||
|
`McpOidcIntegration` rejects (403) any token whose `aud` doesn't include the MCP endpoint's
|
||||||
|
canonical URL. Keycloak doesn't add this by default. The obvious fix — a custom client scope
|
||||||
|
with an Audience mapper, marked Default, added to Allowed Client Scopes — **does not work**:
|
||||||
|
clients created via the `openid-connect` DCR endpoint only ever get scopes they explicitly
|
||||||
|
request, and most MCP clients (including MCP Inspector) don't request anything beyond what a
|
||||||
|
server tells them to via `scopes_supported` (step 3). Default-scope auto-attachment, which is
|
||||||
|
how a normal manually-created client would pick up a custom Default scope, doesn't apply to
|
||||||
|
DCR-created clients at all.
|
||||||
|
|
||||||
|
`basic` is the one built-in scope Keycloak attaches to every client unconditionally, regardless
|
||||||
|
of what it registered with. Put the audience mapper there:
|
||||||
|
|
||||||
|
1. **Client scopes → `basic`** → **Mappers** → **Add mapper** → **By configuration** →
|
||||||
|
**Audience**.
|
||||||
|
2. **Included Custom Audience** = the exact value your server expects — check
|
||||||
|
`GET {parent-of-rootPath}/.well-known/oauth-protected-resource{rootPath}` on the running
|
||||||
|
server for the `resource` field it publishes (auto-derived from the request's
|
||||||
|
forwarded/`Host` headers — see `security.md`). Leave **Included Client Audience** empty (that
|
||||||
|
targets another Keycloak client, not a resource URL).
|
||||||
|
3. **Add to access token** = ON.
|
||||||
|
4. **Save.**
|
||||||
|
|
||||||
|
This is unconditional and works regardless of client cooperation — keep it even after step 3
|
||||||
|
below gets other claims flowing normally, since audience binding is a hard spec requirement
|
||||||
|
that shouldn't depend on a client bothering to request the right scope.
|
||||||
|
|
||||||
|
## 3. Other claims (username, email...): `scopes_supported` + Allowed Client Scopes
|
||||||
|
|
||||||
|
`OidcUser.username()`/`.email()`/`.name()` read `preferred_username`/`email`/`name` — normally
|
||||||
|
from the `profile`/`email` client scopes, which DCR clients don't get either, same root cause.
|
||||||
|
Unlike audience, this **is** fixable the "normal" way, because it doesn't need to survive a
|
||||||
|
completely uncooperative client:
|
||||||
|
|
||||||
|
`McpConfig.scopesSupported("openid", "profile", "email")` publishes those scopes in the PRM
|
||||||
|
document. MCP clients that read it (confirmed for MCP Inspector) echo them back in their DCR
|
||||||
|
registration request — `"scope": "openid profile email offline_access"` (`offline_access` is
|
||||||
|
Inspector's own addition, for refresh tokens). For that request to actually succeed, **Allowed
|
||||||
|
Client Scopes** needs, exactly:
|
||||||
|
|
||||||
|
- **`openid` listed explicitly.** The one genuinely non-obvious step: `openid` is not covered by
|
||||||
|
**Allow Default Scopes** (On by default) the way other realm-Default scopes are, even though
|
||||||
|
every OIDC request includes it. Until it's listed here, registration fails with a generic
|
||||||
|
`403 insufficient_scope` / `"Not permitted to use specified clientScope"` regardless of
|
||||||
|
whether everything else is configured correctly.
|
||||||
|
- **`offline_access` listed explicitly** — it's Optional, not Default, so `ALLOW_DEFAULT_SCOPES`
|
||||||
|
doesn't cover it either.
|
||||||
|
- **`profile`/`email` — do not list them here.** Mark them **Default** on the **Client scopes**
|
||||||
|
page (Assigned Type column) instead, and leave **Allow Default Scopes** = On. Adding an
|
||||||
|
already-Default scope to this list explicitly gets rejected on save
|
||||||
|
(`"Client scopes not allowed: [...]"`) — the list is for *additional* Optional scopes only.
|
||||||
|
|
||||||
|
With that, a real client's token comes back with `preferred_username`/`email` populated
|
||||||
|
normally.
|
||||||
|
|
||||||
|
### Fallback for anything else
|
||||||
|
|
||||||
|
For a claim not covered by `openid profile email` (a custom attribute, a role) — or for a client
|
||||||
|
that ignores `scopes_supported` entirely — add a **User Property** mapper to `basic` too
|
||||||
|
(Property `username` → Token Claim Name `preferred_username`, or whatever's needed), same as the
|
||||||
|
audience mapper in step 2. Unconditional, works regardless of client cooperation, costs one
|
||||||
|
mapper per claim, once, at the realm level — not per tool.
|
||||||
|
|
||||||
|
## Verifying without a full OAuth round-trip
|
||||||
|
|
||||||
|
**Clients → (any client) → Client scopes → Evaluate**: pick a user, run it — Default scopes
|
||||||
|
(including `basic`) apply automatically and won't appear in the "Select scope parameters"
|
||||||
|
picker, which only lists Optional ones — and check the **Generated Access Token** preview.
|
||||||
|
Confirms mappers work without a browser + real MCP client round-trip each time.
|
||||||
|
|
||||||
|
## If a real client still gets rejected
|
||||||
|
|
||||||
|
`McpOidcIntegration.audienceGuard` logs the actual mismatch at `WARN`:
|
||||||
|
|
||||||
|
```
|
||||||
|
[flash-ext-mcp] Rejecting token (RFC 8707): aud=<token's actual aud> does not include expected
|
||||||
|
resource identifier "<what this server expects>" — ...
|
||||||
|
```
|
||||||
|
|
||||||
|
`aud=null` → the `basic` mapper produced nothing (most common cause: **Included Custom
|
||||||
|
Audience** left blank — the mapper saves fine and silently does nothing without it). A non-null
|
||||||
|
`aud` that still doesn't match → compare byte-for-byte — the expected side is derived from the
|
||||||
|
request's own forwarded/`Host` headers, so scheme/host/trailing-slash mismatches show up here
|
||||||
|
directly, as does a proxy hop that drops `X-Forwarded-Host`.
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Security
|
||||||
|
|
||||||
|
Provider-specific setup steps (not generic OAuth2 mechanics) live in separate cookbooks —
|
||||||
|
[`keycloak.md`](keycloak.md) for Keycloak: enabling Dynamic Client Registration, why the RFC 8707
|
||||||
|
audience mapper needs to go on the built-in `basic` scope instead of a custom one, and the exact
|
||||||
|
Allowed Client Scopes configuration `scopes_supported` needs to actually work.
|
||||||
|
|
||||||
|
## `McpSecurity`
|
||||||
|
|
||||||
|
`McpConfig.security(...)` controls how the MCP endpoint reacts to `flash-ext-oidc` being
|
||||||
|
installed (`ctx.find(OidcMiddleware.class)`), resolved once at boot in `McpExtension.routes()`:
|
||||||
|
|
||||||
|
| Policy | `flash-ext-oidc` installed | `flash-ext-oidc` absent |
|
||||||
|
|---|---|---|
|
||||||
|
| `REQUIRED` | protected | **boot fails** (`IllegalStateException`) |
|
||||||
|
| `AUTO` (default) | protected | runs unprotected, logs a warning |
|
||||||
|
| `NONE` | never protected, even if oidc is installed elsewhere in the app | runs unprotected |
|
||||||
|
|
||||||
|
Use `REQUIRED` for anything you intend to run in production reachable over the network — it
|
||||||
|
turns "someone forgot to wire up OAuth2" into a startup crash instead of a silently open
|
||||||
|
endpoint. `AUTO` is meant for local development, where spinning up a real identity provider is
|
||||||
|
friction you don't want yet.
|
||||||
|
|
||||||
|
## Why `flash-ext-oidc` is an *optional* Maven dependency, concretely
|
||||||
|
|
||||||
|
Maven's `<optional>true</optional>` only affects **transitive** propagation: consumers of
|
||||||
|
`flash-ext-mcp` don't get `flash-ext-oidc` pulled in automatically unless they add it themselves.
|
||||||
|
Within `flash-ext-mcp` itself, `flash-ext-oidc`'s classes are on the compile/test classpath as
|
||||||
|
normal — this extension can (and does) reference `OidcMiddleware`/`ClaimsHolder` directly in
|
||||||
|
source.
|
||||||
|
|
||||||
|
That reference is isolated in its own class, `McpOidcIntegration`, invoked only from inside a
|
||||||
|
`catch (NoClassDefFoundError)` block. A bare class-literal like `OidcMiddleware.class` (which
|
||||||
|
`ctx.find(OidcMiddleware.class)` needs) forces the JVM to resolve that type the moment it's
|
||||||
|
evaluated — if `flash-ext-oidc` is not on the *runtime* classpath at all (a genuinely
|
||||||
|
MCP-only install, no OAuth2 anywhere in the app), the first such reference throws
|
||||||
|
`NoClassDefFoundError`. Keeping that reference inside a separate, lazily-loaded class means
|
||||||
|
`McpExtension` itself loads and works fine standalone; only the attempt to actually use OIDC
|
||||||
|
fails, and only when there's something to fail. This mirrors `OidcExtension`'s own lazy bridge to
|
||||||
|
`flash-ext-openapi` — same technique, same reason.
|
||||||
|
|
||||||
|
## OAuth2 resolution details — zero-config by default
|
||||||
|
|
||||||
|
When oidc is available and `security() != NONE`, `McpOidcIntegration` (an isolated,
|
||||||
|
lazily-loaded bridge — see its javadoc) derives everything an MCP OAuth2 resource server needs
|
||||||
|
straight from the installed `OidcMiddleware`, with no additional `McpConfig` calls required:
|
||||||
|
|
||||||
|
1. The MCP route is wrapped with `flash-ext-oidc`'s own `OidcMiddleware.protect(resourceMetadataPath)`
|
||||||
|
— the same Bearer-token/JWKS validation path used everywhere else in Flash5, plus a
|
||||||
|
`resource_metadata` challenge parameter (see below). No JWT parsing or JWKS handling is
|
||||||
|
reimplemented here.
|
||||||
|
2. An audience guard always runs after `protect(...)`: it reads the validated claims from
|
||||||
|
`ClaimsHolder` and rejects (`403`) any token whose `aud` claim does not include the resource
|
||||||
|
identifier — **RFC 8707 Resource Indicators / audience binding**, enforced unconditionally,
|
||||||
|
not opt-in. `OidcMiddleware` itself validates `aud` against its own `clientId` for ID
|
||||||
|
tokens, but deliberately does not enforce audience on access tokens (it varies by provider)
|
||||||
|
— the MCP extension adds that check on top, scoped to its own resource identifier.
|
||||||
|
3. The resource identifier is the canonical URI of the MCP endpoint, resolved **per request** by
|
||||||
|
`OidcMiddleware#selfOrigin` + `rootPath` — the same scheme/host resolution `OidcExtension`
|
||||||
|
uses for its own redirect URIs: `X-Forwarded-Host`/`X-Forwarded-Proto` when the request came
|
||||||
|
through a reverse proxy, otherwise `{selfScheme()}://{Host header}`. Behind a proxy the
|
||||||
|
`Host` alone is the upstream address the proxy dialled, which would publish a resource
|
||||||
|
identifier no client can reach. `McpConfig.resourceIdentifier(...)` still overrides it
|
||||||
|
outright for a proxy that forwards neither header.
|
||||||
|
4. The authorization server issuer is read from `OidcMiddleware#issuer()` unless
|
||||||
|
`McpConfig.authorizationServerIssuer(...)` overrides it.
|
||||||
|
|
||||||
|
## RFC 9728 Protected Resource Metadata
|
||||||
|
|
||||||
|
Whenever the endpoint ends up protected, `flash-ext-mcp` publishes a Protected Resource Metadata
|
||||||
|
document at `/.well-known/oauth-protected-resource{rootPath}` — no explicit `resourceIdentifier`/
|
||||||
|
`authorizationServerIssuer` configuration required, both are auto-derived as described above:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "resource": "https://mcp.example.com/mcp", "authorization_servers": ["https://auth.example.com/realms/myrealm"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
`resource` is computed per request from the incoming request's forwarded/`Host` headers (see
|
||||||
|
above), so the document is correct without hardcoding the server's own public URL.
|
||||||
|
|
||||||
|
### `scopes_supported`
|
||||||
|
|
||||||
|
Optional per RFC 9728, omitted from the document entirely unless set via
|
||||||
|
`McpConfig.scopesSupported("openid", "profile", "email")`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "resource": "...", "authorization_servers": ["..."], "scopes_supported": ["openid", "profile", "email"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
This is pure advertisement — token validation doesn't change based on it — but it matters in
|
||||||
|
practice: a client that ignores it and requests no scope at all (many do — see `keycloak.md`)
|
||||||
|
only gets back whatever the authorization server treats as always-included regardless of
|
||||||
|
request, which for Keycloak is just its built-in `basic` scope. A client that *does* read
|
||||||
|
`scopes_supported` and echoes it back in its authorization/token requests gets a token with the
|
||||||
|
claims those scopes actually provide (`profile` → `preferred_username`/`name`, etc.), without
|
||||||
|
needing every one of those claims hand-mapped onto `basic`. Set it to whatever scopes your
|
||||||
|
`McpTool`s actually read off `ClaimsHolder`/`OidcUser` — there's no way to auto-derive this list,
|
||||||
|
it depends entirely on what your tools do with the claims.
|
||||||
|
|
||||||
|
## `WWW-Authenticate: resource_metadata` (RFC 9728 §5.1)
|
||||||
|
|
||||||
|
The MCP Authorization spec **requires** a `401` to carry `resource_metadata` in
|
||||||
|
`WWW-Authenticate`, pointing at the Protected Resource Metadata document above — this is how a
|
||||||
|
spec-compliant client discovers the authorization server without out-of-band configuration.
|
||||||
|
`OidcMiddleware.protect(String resourceMetadataPath)` (an overload added specifically for this)
|
||||||
|
builds that challenge automatically:
|
||||||
|
|
||||||
|
```
|
||||||
|
WWW-Authenticate: Bearer realm="...", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
|
||||||
|
```
|
||||||
|
|
||||||
|
The plain `OidcMiddleware.protect()` (no argument), used by every other Flash5 app, is
|
||||||
|
unaffected — this parameter is additive and MCP-specific.
|
||||||
|
|
||||||
|
## Per-tool `@RolesAllowed`/`@ScopesAllowed`
|
||||||
|
|
||||||
|
`McpTool` subclasses can carry `flash-ext-oidc`'s `@RolesAllowed`/`@ScopesAllowed`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Tool(name = "delete_route", description = "Delete a route")
|
||||||
|
@RolesAllowed("admin")
|
||||||
|
public class DeleteRouteTool extends McpTool {
|
||||||
|
@Override public ToolResponse call(ToolArguments args) { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This does **not** reuse `flash-ext-oidc`'s per-route middleware mechanism (`ctx.addAnnotationProcessor`,
|
||||||
|
the thing that makes these annotations work on a `RequestHandler`) — it can't: every tool shares
|
||||||
|
one HTTP route (`POST {rootPath}`), already wrapped by whatever `McpSecurity` resolved above, so
|
||||||
|
there is no per-tool route to attach a different middleware chain to. Instead,
|
||||||
|
`McpOidcIntegration.compileToolPolicy` reads the annotations once at boot (`McpRegistry.scan`)
|
||||||
|
and compiles them into a closure (`McpAuthPolicy`) that `McpDispatcher` runs *after* the
|
||||||
|
route-wide auth has already succeeded and *before* invoking the specific tool named in the
|
||||||
|
`tools/call` request — narrowing what's already-authenticated, not replacing it. A denial is a
|
||||||
|
normal `isError: true` tool result (see `ToolResponse.error`), not an HTTP-level rejection — the
|
||||||
|
model sees why, the same as any other tool failure.
|
||||||
|
|
||||||
|
Roles are read via `OidcUser#hasRole` against `McpConfig.rolesClaimPath(...)` (default
|
||||||
|
`"realm_access.roles"`, matching `OidcConfig`'s own default — set this explicitly if the two
|
||||||
|
diverge; there's no way to read `OidcConfig`'s actual configured value from here). Scopes use
|
||||||
|
`OidcUser#hasScope`'s built-in default claim paths (`scope`/`scp`), no extra config needed.
|
||||||
|
`@ScopesAllowed(match = ScopesAllowed.Match.ANY)` and multi-role `@RolesAllowed({"admin",
|
||||||
|
"editor"})` (OR semantics) both work exactly as they do on a `RequestHandler`.
|
||||||
|
|
||||||
|
**`@Authenticated` alone has no effect and fails boot.** Once oidc is active for a server, every
|
||||||
|
tool call is already authenticated — there's no per-tool public/authenticated split the way
|
||||||
|
there is for HTTP routes, so a bare `@Authenticated` on a tool can't mean anything and would
|
||||||
|
silently do nothing if allowed to compile. Boot fails instead, with a message pointing at
|
||||||
|
`@RolesAllowed`/`@ScopesAllowed` as the actual narrowing mechanism.
|
||||||
|
|
||||||
|
**Annotating a tool without active OAuth2 also fails boot**, not silently at request time: if
|
||||||
|
`@RolesAllowed`/`@ScopesAllowed`/`@Authenticated` shows up on a tool while `McpSecurity` resolved
|
||||||
|
to unprotected (`NONE`, or `AUTO` with no oidc installed), that's very likely a forgotten
|
||||||
|
`OidcExtension` install or a `McpSecurity.NONE` left over from local dev — `IllegalStateException`
|
||||||
|
at `app.start()`.
|
||||||
|
|
||||||
|
## The `HttpException` safety net
|
||||||
|
|
||||||
|
`flash-ext-oidc`'s middleware throws `HttpException.unauthorized()`/`forbidden()` on auth
|
||||||
|
failure. Flash5's core does **not** special-case `HttpException` in the default exception
|
||||||
|
handler — the out-of-the-box `AbstractRouter` default always returns a generic `500`, regardless
|
||||||
|
of the thrown exception's embedded status code; only an app that explicitly calls
|
||||||
|
`FlashApp#onException(...)` (or installs something that does) gets `HttpException.status()`
|
||||||
|
honored.
|
||||||
|
|
||||||
|
To keep the MCP endpoint correct regardless of what the rest of the app configures,
|
||||||
|
`McpTransportGuards.httpExceptionGuard()` wraps the whole route and translates `HttpException`
|
||||||
|
into the right HTTP status itself, rather than letting it fall through to the app's (possibly
|
||||||
|
unconfigured) global handler. This is scoped entirely to the MCP route — it does not touch or
|
||||||
|
override the app's `onException` for any other route.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Tools, Resources, Prompts
|
||||||
|
|
||||||
|
## One class per feature
|
||||||
|
|
||||||
|
Every tool, resource, and prompt is its own class — the same shape as a Flash `RequestHandler`,
|
||||||
|
minus the HTTP-specific bits:
|
||||||
|
|
||||||
|
```java
|
||||||
|
public abstract class McpTool {
|
||||||
|
protected void onInit() {} // cache services here, once, at boot
|
||||||
|
protected <T> T require(Class<T> type) { ... } // FlashContext lookup
|
||||||
|
public abstract ToolResponse call(ToolArguments args) throws Exception; // hot path
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`McpResource` (`read()`) and `McpPrompt` (`render(PromptArguments)`) follow the exact same
|
||||||
|
shape. There is deliberately no CDI-style `@Inject` and no method-per-tool bean class — Flash5
|
||||||
|
handlers are classes, and MCP features follow that convention.
|
||||||
|
|
||||||
|
## Declaring metadata
|
||||||
|
|
||||||
|
Metadata (name, description, input schema) lives entirely in the annotation, not in reflected
|
||||||
|
method signatures — the whole JSON Schema is known at scan time and compiled once:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Tool(
|
||||||
|
name = "get_weather",
|
||||||
|
description = "Get current weather for a city",
|
||||||
|
args = {
|
||||||
|
@ToolArg(name = "city", description = "City name", required = true),
|
||||||
|
@ToolArg(name = "days", type = ToolArgType.INTEGER, description = "Forecast horizon")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
public class GetWeatherTool extends McpTool {
|
||||||
|
@Override
|
||||||
|
public ToolResponse call(ToolArguments args) {
|
||||||
|
String city = args.getString("city");
|
||||||
|
int days = args.getInt("days", 1);
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ToolArgType` maps directly to JSON Schema primitive types: `STRING`, `INTEGER`, `NUMBER`,
|
||||||
|
`BOOLEAN`, `OBJECT`, `ARRAY`. Nested object/array schemas beyond the primitive type keyword are
|
||||||
|
not modeled in this revision — declare those tools with a looser `OBJECT`/`ARRAY` type and parse
|
||||||
|
the raw shape via `ToolArguments.raw(name)`.
|
||||||
|
|
||||||
|
`ToolArguments`/`PromptArguments` are thin typed accessors over the already-parsed JSON — no
|
||||||
|
databinding, no reflection, no intermediate DTO:
|
||||||
|
|
||||||
|
```java
|
||||||
|
args.getString("city");
|
||||||
|
args.getInt("days", 1);
|
||||||
|
args.getBoolean("metric", true);
|
||||||
|
args.raw("filters"); // escape hatch: JsonNode for nested/array arguments
|
||||||
|
```
|
||||||
|
|
||||||
|
## Discovery
|
||||||
|
|
||||||
|
`McpConfig.toolsPackage("com.example.tools")` scans that package (and subpackages) for concrete
|
||||||
|
`McpTool`/`McpResource`/`McpPrompt` subclasses carrying `@Tool`/`@Resource`/`@Prompt`. Same
|
||||||
|
fail-fast contract as `FlashApp.scan()`: missing package, missing no-arg constructor, or a class
|
||||||
|
that fails to load aborts startup immediately with a clear message. Duplicate names/URIs also
|
||||||
|
fail fast at boot.
|
||||||
|
|
||||||
|
## Resources and Prompts
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Resource(uri = "config://app-settings", description = "Application settings", mimeType = "application/json")
|
||||||
|
public class AppSettingsResource extends McpResource {
|
||||||
|
@Override
|
||||||
|
public ResourceContents read() {
|
||||||
|
return TextResourceContents.of(uri(), "application/json", settingsJson());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Prompt(name = "summarize", args = @PromptArg(name = "text", required = true))
|
||||||
|
public class SummarizePrompt extends McpPrompt {
|
||||||
|
@Override
|
||||||
|
public PromptMessage render(PromptArguments args) {
|
||||||
|
return PromptMessage.withUserRole(new TextContent("Summarize: " + args.getString("text")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`McpResource.uri()` returns the URI declared on `@Resource`, cached at bind time — no repeated
|
||||||
|
annotation lookups on the hot path.
|
||||||
|
|
||||||
|
## Content types
|
||||||
|
|
||||||
|
`Content` and `ResourceContents` are `sealed`, currently permitting only `TextContent` and
|
||||||
|
`TextResourceContents` respectively. This is a deliberate v1 scope cut, not an oversight — image
|
||||||
|
content, embedded resources, and blob resources are extension points for a future revision
|
||||||
|
(extend the `permits` clause and `McpContentWriter`).
|
||||||
|
|
||||||
|
## Tool failures vs. protocol errors
|
||||||
|
|
||||||
|
A `McpTool.call(...)` that throws is caught by the dispatcher and turned into
|
||||||
|
`ToolResponse.error(message)` — per the MCP specification this is a normal JSON-RPC *result*
|
||||||
|
with `isError: true`, not a JSON-RPC error, so the calling model can see and react to it. Prefer
|
||||||
|
returning `ToolResponse.error(...)` explicitly when you can produce a better message than the
|
||||||
|
raw exception text.
|
||||||
|
|
||||||
|
`McpResource.read()`/`McpPrompt.render(...)` failures, by contrast, surface as JSON-RPC errors
|
||||||
|
(`-32603 Internal error`) — the specification does not define a soft-failure content convention
|
||||||
|
for those two.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Transport
|
||||||
|
|
||||||
|
`flash-ext-mcp` implements the **Streamable HTTP** transport from the MCP specification
|
||||||
|
(revision `2025-11-25`). `stdio` is out of scope — Flash5 is an HTTP framework, and a
|
||||||
|
subprocess-stdio transport doesn't fit its model.
|
||||||
|
|
||||||
|
## What this revision implements
|
||||||
|
|
||||||
|
- A single `POST {rootPath}` endpoint (default `/mcp`) accepting one JSON-RPC 2.0 message per
|
||||||
|
request and responding with a plain JSON object — the "standard JSON object response" mode the
|
||||||
|
specification allows as an alternative to opening a Server-Sent Events stream per request.
|
||||||
|
- `Origin` header validation (DNS-rebinding protection), configurable via
|
||||||
|
`McpConfig.allowedOrigins(...)`.
|
||||||
|
- Full JSON-RPC lifecycle: `initialize`, `notifications/initialized` (and any other
|
||||||
|
`notifications/*`/id-less message — answered with a bare `202 Accepted`, no body, per
|
||||||
|
JSON-RPC's notification semantics), `ping`, `tools/list`, `tools/call`, `resources/list`,
|
||||||
|
`resources/read`, `prompts/list`, `prompts/get`.
|
||||||
|
|
||||||
|
## What this revision deliberately does not implement
|
||||||
|
|
||||||
|
- **No `Mcp-Session-Id` / session state.** The specification says a server "MAY assign a session
|
||||||
|
ID at initialization time" — it is optional, not mandatory. This server is stateless: every
|
||||||
|
`POST` is handled independently, with no server-side session store. `initialize` does not need
|
||||||
|
to precede other calls for the server to function (there's no session to be "not initialized"
|
||||||
|
yet), which is a looser contract than a session-aware server would enforce — acceptable for a
|
||||||
|
static, boot-time-defined tool/resource/prompt catalog.
|
||||||
|
- **No Server-Sent Events stream.** `GET {rootPath}` (used by session-aware servers to open a
|
||||||
|
standing SSE stream for server-initiated pushes) is not registered — MCP clients that only
|
||||||
|
speak the request/response half of Streamable HTTP work unaffected; clients that require a
|
||||||
|
standing SSE connection are not supported by this revision.
|
||||||
|
|
||||||
|
Both are real, intentional scope cuts for a first version — not just to keep the surface area
|
||||||
|
small: a static, precompiled tool catalog (see `tools-resources-prompts.md`) has no
|
||||||
|
`listChanged` events to push and no long-running server-initiated messages to stream, so the
|
||||||
|
stateful half of the transport buys little for the common case this extension targets. Sessions
|
||||||
|
and SSE are natural extension points if a future revision needs server push (e.g. dynamic tool
|
||||||
|
registration, elicitation, or sampling requests initiated by the server).
|
||||||
|
|
||||||
|
## Why `POST`, not the new `QUERY` HTTP method
|
||||||
|
|
||||||
|
Flash5's core recently gained `HttpMethod.QUERY` (safe, idempotent, carries a body — a good
|
||||||
|
semantic fit for JSON-RPC-over-HTTP in general). It is **not** used here: the MCP Streamable
|
||||||
|
HTTP specification mandates `POST` for the client-to-server message path. Real MCP clients send
|
||||||
|
`POST`; using `QUERY` instead would break interoperability with every existing client for a
|
||||||
|
semantic nicety this extension doesn't need standalone.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-extensions</artifactId>
|
||||||
|
<version>2.1.0-SNAPSHOT</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>flash-ext-mcp</artifactId>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-oidc</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.slf4j</groupId>
|
||||||
|
<artifactId>slf4j-api</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-testing</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP tool/prompt content block. {@code sealed} to the variants this extension currently
|
||||||
|
* writes on the wire — extend the permits clause (and {@link McpContentWriter}) to add
|
||||||
|
* {@code ImageContent}, {@code EmbeddedResource}, etc. in a future revision.
|
||||||
|
*/
|
||||||
|
public sealed interface Content permits TextContent {}
|
||||||
+13
@@ -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;
|
||||||
|
}
|
||||||
+23
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonGenerator;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Direct {@link JsonGenerator} writers for the fixed, known shapes of {@link Content},
|
||||||
|
* {@link ResourceContents} and {@link PromptMessage} — no databinding, one {@code switch}
|
||||||
|
* per call, matching the fixed wire shape defined by the MCP specification.
|
||||||
|
*/
|
||||||
|
final class McpContentWriter {
|
||||||
|
|
||||||
|
private McpContentWriter() {}
|
||||||
|
|
||||||
|
static void writeContentArray(JsonGenerator gen, List<Content> items) throws IOException {
|
||||||
|
gen.writeStartArray();
|
||||||
|
for (Content c : items) writeContent(gen, c);
|
||||||
|
gen.writeEndArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void writeContent(JsonGenerator gen, Content content) throws IOException {
|
||||||
|
if (content instanceof TextContent tc) {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeStringField("type", "text");
|
||||||
|
gen.writeStringField("text", tc.text());
|
||||||
|
gen.writeEndObject();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("Unhandled Content variant: " + content.getClass());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void writeResourceContents(JsonGenerator gen, ResourceContents contents) throws IOException {
|
||||||
|
if (contents instanceof TextResourceContents trc) {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeStringField("uri", trc.uri());
|
||||||
|
gen.writeStringField("mimeType", trc.mimeType());
|
||||||
|
gen.writeStringField("text", trc.text());
|
||||||
|
gen.writeEndObject();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("Unhandled ResourceContents variant: " + contents.getClass());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void writePromptMessage(JsonGenerator gen, PromptMessage message) throws IOException {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeStringField("role", message.role().name().toLowerCase(Locale.ROOT));
|
||||||
|
gen.writeFieldName("content");
|
||||||
|
writeContent(gen, message.content());
|
||||||
|
gen.writeEndObject();
|
||||||
|
}
|
||||||
|
}
|
||||||
+253
@@ -0,0 +1,253 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonGenerator;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import dev.relism.flash.http.ContentType;
|
||||||
|
import dev.relism.flash.models.Request;
|
||||||
|
import dev.relism.flash.models.Response;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON-RPC 2.0 dispatcher for the MCP Streamable HTTP endpoint — one instance per
|
||||||
|
* {@link McpExtension}, built once at boot from a resolved {@link McpRegistry}.
|
||||||
|
*
|
||||||
|
* <p>Per the MCP specification, a {@code tools/call} failure is a normal JSON-RPC
|
||||||
|
* <em>result</em> with {@code isError: true} (see {@link ToolResponse#error}), not a JSON-RPC
|
||||||
|
* error — the model needs to see it. Everything else that goes wrong (bad params, unknown
|
||||||
|
* tool/resource/prompt name, resource/prompt handler exceptions) is a JSON-RPC error object,
|
||||||
|
* always returned with HTTP 200: the HTTP request itself succeeded, only the RPC did not. Only
|
||||||
|
* malformed HTTP-level input (unparsable JSON, not a JSON object) gets HTTP 400. A
|
||||||
|
* {@code @RolesAllowed}/{@code @ScopesAllowed} denial (see {@link McpAuthPolicy}) is the same
|
||||||
|
* category — {@code isError: true}, tool never invoked — not a transport-level rejection; the
|
||||||
|
* route-wide 401/403 for "not authenticated at all" already happened earlier, in the {@code
|
||||||
|
* OidcMiddleware}/audience-guard middleware chain, before this dispatcher ever runs.
|
||||||
|
*/
|
||||||
|
final class McpDispatcher {
|
||||||
|
|
||||||
|
/** Protocol revision this dispatcher implements. */
|
||||||
|
static final String PROTOCOL_VERSION = "2025-11-25";
|
||||||
|
|
||||||
|
private final McpRegistry registry;
|
||||||
|
private final String serverName;
|
||||||
|
private final String serverVersion;
|
||||||
|
private final String instructions;
|
||||||
|
|
||||||
|
McpDispatcher(McpRegistry registry, String serverName, String serverVersion, String instructions) {
|
||||||
|
this.registry = registry;
|
||||||
|
this.serverName = serverName;
|
||||||
|
this.serverVersion = serverVersion;
|
||||||
|
this.instructions = instructions;
|
||||||
|
}
|
||||||
|
|
||||||
|
void handle(Request req, Response res) {
|
||||||
|
byte[] body = req.body().bytes();
|
||||||
|
JsonNode root;
|
||||||
|
try {
|
||||||
|
root = McpJson.parse(body);
|
||||||
|
} catch (IOException e) {
|
||||||
|
writeError(res, 400, null, JsonRpcErrorCode.PARSE_ERROR, "Parse error: " + e.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (root == null || !root.isObject()) {
|
||||||
|
writeError(res, 400, null, JsonRpcErrorCode.INVALID_REQUEST, "Request must be a JSON object");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode idNode = root.get("id");
|
||||||
|
boolean isNotification = idNode == null;
|
||||||
|
String method = root.path("method").asText(null);
|
||||||
|
JsonNode params = root.path("params");
|
||||||
|
|
||||||
|
if (method == null || method.isBlank()) {
|
||||||
|
if (isNotification) { res.status(202); return; }
|
||||||
|
writeError(res, 400, idNode, JsonRpcErrorCode.INVALID_REQUEST, "Missing \"method\"");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (method) {
|
||||||
|
case "initialize" -> handleInitialize(res, idNode);
|
||||||
|
case "notifications/initialized", "notifications/cancelled" -> res.status(202);
|
||||||
|
case "ping" -> handlePing(res, idNode);
|
||||||
|
case "tools/list" -> handleToolsList(res, idNode);
|
||||||
|
case "tools/call" -> handleToolsCall(res, idNode, params);
|
||||||
|
case "resources/list" -> handleResourcesList(res, idNode);
|
||||||
|
case "resources/read" -> handleResourcesRead(res, idNode, params);
|
||||||
|
case "prompts/list" -> handlePromptsList(res, idNode);
|
||||||
|
case "prompts/get" -> handlePromptsGet(res, idNode, params);
|
||||||
|
default -> {
|
||||||
|
if (isNotification) { res.status(202); return; }
|
||||||
|
throw McpProtocolException.methodNotFound(method);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (McpProtocolException e) {
|
||||||
|
writeError(res, 200, idNode, e.code, e.getMessage());
|
||||||
|
} catch (Exception e) {
|
||||||
|
writeError(res, 200, idNode, JsonRpcErrorCode.INTERNAL_ERROR, "Internal error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Method handlers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void handleInitialize(Response res, JsonNode id) {
|
||||||
|
writeResult(res, id, gen -> {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeStringField("protocolVersion", PROTOCOL_VERSION);
|
||||||
|
gen.writeObjectFieldStart("capabilities");
|
||||||
|
if (registry.hasTools()) writeEmptyCapability(gen, "tools");
|
||||||
|
if (registry.hasResources()) writeEmptyCapability(gen, "resources");
|
||||||
|
if (registry.hasPrompts()) writeEmptyCapability(gen, "prompts");
|
||||||
|
gen.writeEndObject();
|
||||||
|
gen.writeObjectFieldStart("serverInfo");
|
||||||
|
gen.writeStringField("name", serverName);
|
||||||
|
gen.writeStringField("version", serverVersion);
|
||||||
|
gen.writeEndObject();
|
||||||
|
if (instructions != null && !instructions.isBlank())
|
||||||
|
gen.writeStringField("instructions", instructions);
|
||||||
|
gen.writeEndObject();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeEmptyCapability(JsonGenerator gen, String field) throws IOException {
|
||||||
|
gen.writeObjectFieldStart(field);
|
||||||
|
gen.writeBooleanField("listChanged", false);
|
||||||
|
gen.writeEndObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handlePing(Response res, JsonNode id) {
|
||||||
|
writeResult(res, id, gen -> { gen.writeStartObject(); gen.writeEndObject(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleToolsList(Response res, JsonNode id) {
|
||||||
|
writeResult(res, id, gen -> {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeFieldName("tools");
|
||||||
|
gen.writeRawValue(registry.toolsListJson());
|
||||||
|
gen.writeEndObject();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleToolsCall(Response res, JsonNode id, JsonNode params) {
|
||||||
|
String name = params.path("name").asText(null);
|
||||||
|
if (name == null || name.isBlank())
|
||||||
|
throw McpProtocolException.invalidParams("\"name\" is required");
|
||||||
|
McpRegistry.RegisteredTool tool = registry.tool(name);
|
||||||
|
if (tool == null)
|
||||||
|
throw McpProtocolException.invalidParams("Unknown tool: " + name);
|
||||||
|
|
||||||
|
ToolResponse result;
|
||||||
|
String denied = tool.policy() != null ? tool.policy().check().get() : null;
|
||||||
|
if (denied != null) {
|
||||||
|
result = ToolResponse.error("Tool \"" + name + "\" denied: " + denied);
|
||||||
|
} else {
|
||||||
|
ToolArguments args = new ToolArguments(params.path("arguments"));
|
||||||
|
try {
|
||||||
|
result = tool.instance().call(args);
|
||||||
|
} catch (Exception e) {
|
||||||
|
result = ToolResponse.error("Tool \"" + name + "\" failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolResponse finalResult = result;
|
||||||
|
writeResult(res, id, gen -> {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeBooleanField("isError", finalResult.isError());
|
||||||
|
gen.writeFieldName("content");
|
||||||
|
McpContentWriter.writeContentArray(gen, finalResult.content());
|
||||||
|
gen.writeEndObject();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleResourcesList(Response res, JsonNode id) {
|
||||||
|
writeResult(res, id, gen -> {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeFieldName("resources");
|
||||||
|
gen.writeRawValue(registry.resourcesListJson());
|
||||||
|
gen.writeEndObject();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleResourcesRead(Response res, JsonNode id, JsonNode params) throws Exception {
|
||||||
|
String uri = params.path("uri").asText(null);
|
||||||
|
if (uri == null || uri.isBlank())
|
||||||
|
throw McpProtocolException.invalidParams("\"uri\" is required");
|
||||||
|
McpRegistry.RegisteredResource resource = registry.resource(uri);
|
||||||
|
if (resource == null)
|
||||||
|
throw McpProtocolException.invalidParams("Unknown resource: " + uri);
|
||||||
|
|
||||||
|
ResourceContents contents = resource.instance().read();
|
||||||
|
writeResult(res, id, gen -> {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeArrayFieldStart("contents");
|
||||||
|
McpContentWriter.writeResourceContents(gen, contents);
|
||||||
|
gen.writeEndArray();
|
||||||
|
gen.writeEndObject();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handlePromptsList(Response res, JsonNode id) {
|
||||||
|
writeResult(res, id, gen -> {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeFieldName("prompts");
|
||||||
|
gen.writeRawValue(registry.promptsListJson());
|
||||||
|
gen.writeEndObject();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handlePromptsGet(Response res, JsonNode id, JsonNode params) throws Exception {
|
||||||
|
String name = params.path("name").asText(null);
|
||||||
|
if (name == null || name.isBlank())
|
||||||
|
throw McpProtocolException.invalidParams("\"name\" is required");
|
||||||
|
McpRegistry.RegisteredPrompt prompt = registry.prompt(name);
|
||||||
|
if (prompt == null)
|
||||||
|
throw McpProtocolException.invalidParams("Unknown prompt: " + name);
|
||||||
|
|
||||||
|
PromptArguments args = new PromptArguments(params.path("arguments"));
|
||||||
|
PromptMessage message = prompt.instance().render(args);
|
||||||
|
writeResult(res, id, gen -> {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeArrayFieldStart("messages");
|
||||||
|
McpContentWriter.writePromptMessage(gen, message);
|
||||||
|
gen.writeEndArray();
|
||||||
|
gen.writeEndObject();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Envelope writers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void writeResult(Response res, JsonNode id, McpJson.JsonWriter resultWriter) {
|
||||||
|
String body = McpJson.buildString(gen -> {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeStringField("jsonrpc", "2.0");
|
||||||
|
gen.writeFieldName("id");
|
||||||
|
writeId(gen, id);
|
||||||
|
gen.writeFieldName("result");
|
||||||
|
resultWriter.write(gen);
|
||||||
|
gen.writeEndObject();
|
||||||
|
});
|
||||||
|
res.status(200).type(ContentType.JSON).body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeError(Response res, int httpStatus, JsonNode id, int code, String message) {
|
||||||
|
String body = McpJson.buildString(gen -> {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeStringField("jsonrpc", "2.0");
|
||||||
|
gen.writeFieldName("id");
|
||||||
|
writeId(gen, id);
|
||||||
|
gen.writeObjectFieldStart("error");
|
||||||
|
gen.writeNumberField("code", code);
|
||||||
|
gen.writeStringField("message", message);
|
||||||
|
gen.writeEndObject();
|
||||||
|
gen.writeEndObject();
|
||||||
|
});
|
||||||
|
res.status(httpStatus).type(ContentType.JSON).body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeId(JsonGenerator gen, JsonNode id) throws IOException {
|
||||||
|
if (id == null || id.isNull() || id.isMissingNode()) { gen.writeNull(); return; }
|
||||||
|
if (id.isTextual()) gen.writeString(id.asText());
|
||||||
|
else if (id.isIntegralNumber()) gen.writeNumber(id.asLong());
|
||||||
|
else if (id.isFloatingPointNumber()) gen.writeNumber(id.asDouble());
|
||||||
|
else gen.writeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import dev.relism.flash.extension.FlashContext;
|
||||||
|
import dev.relism.flash.extension.FlashExtension;
|
||||||
|
import dev.relism.flash.extension.FlashRegistrar;
|
||||||
|
import dev.relism.flash.http.ContentType;
|
||||||
|
import dev.relism.flash.routing.Middleware;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP (Model Context Protocol) server extension. Streamable HTTP transport — a single
|
||||||
|
* {@code POST} JSON-RPC endpoint, stateless in this revision (no session, no SSE stream; see
|
||||||
|
* {@code docs/transport.md}) — dispatch precompiled at boot from classes annotated with
|
||||||
|
* {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} under
|
||||||
|
* {@link McpConfig#toolsPackage(String)}.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* // Standalone, no OAuth2
|
||||||
|
* FlashApp.create(8080)
|
||||||
|
* .install(new McpExtension(McpConfig.builder("my-mcp-server")
|
||||||
|
* .toolsPackage("com.example.tools")
|
||||||
|
* .build()))
|
||||||
|
* .start();
|
||||||
|
*
|
||||||
|
* // With flash-ext-oidc as the OAuth2 resource server — zero extra config: issuer, canonical
|
||||||
|
* // resource identifier, RFC 8707 audience binding and RFC 9728 metadata are all derived from
|
||||||
|
* // the installed OidcExtension.
|
||||||
|
* FlashApp.create(8080)
|
||||||
|
* .install(new OidcExtension(oidcConfig))
|
||||||
|
* .install(new McpExtension(McpConfig.builder("my-mcp-server")
|
||||||
|
* .toolsPackage("com.example.tools")
|
||||||
|
* .security(McpSecurity.REQUIRED)
|
||||||
|
* .build()))
|
||||||
|
* .start();
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p>One server per {@code McpExtension} instance — install multiple instances (distinct
|
||||||
|
* {@code rootPath}, distinct {@code toolsPackage}) for multiple MCP servers on one app,
|
||||||
|
* mirroring the {@code OidcExtension} multi-tenant pattern. See {@code docs/security.md} for
|
||||||
|
* the full OAuth2 resolution rules.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class McpExtension implements FlashExtension {
|
||||||
|
|
||||||
|
private final McpConfig config;
|
||||||
|
|
||||||
|
public McpExtension(McpConfig config) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
|
ctx.onReady(() -> registerRoutes(app, ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void registerRoutes(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
|
// Resolved before scanning so McpRegistry knows, per tool, whether @RolesAllowed/
|
||||||
|
// @ScopesAllowed are backed by real OAuth2 protection or a boot-time misconfiguration
|
||||||
|
// (see McpOidcIntegration#compileToolPolicy) — must run first, not after.
|
||||||
|
McpOidcIntegration.Resolved secured = resolveSecurity(ctx);
|
||||||
|
McpRegistry registry = McpRegistry.scan(config.toolsPackage(), ctx, secured != null,
|
||||||
|
secured == null ? null : secured.rolesClaimPath());
|
||||||
|
McpDispatcher dispatcher = new McpDispatcher(registry, config.name(), config.version(), config.instructions());
|
||||||
|
|
||||||
|
List<Middleware> chain = new ArrayList<>(3);
|
||||||
|
chain.add(McpTransportGuards.httpExceptionGuard());
|
||||||
|
chain.add(McpTransportGuards.originGuard(config.allowedOrigins()));
|
||||||
|
if (secured != null) chain.add(secured.security());
|
||||||
|
|
||||||
|
app.post(config.rootPath(), (req, res) -> { dispatcher.handle(req, res); return null; },
|
||||||
|
chain.toArray(Middleware[]::new));
|
||||||
|
|
||||||
|
registerResourceMetadata(app, secured);
|
||||||
|
}
|
||||||
|
|
||||||
|
private McpOidcIntegration.Resolved resolveSecurity(FlashContext ctx) {
|
||||||
|
if (config.security() == McpSecurity.NONE) return null;
|
||||||
|
|
||||||
|
McpOidcIntegration.Resolved resolved;
|
||||||
|
try {
|
||||||
|
resolved = McpOidcIntegration.resolve(ctx, config);
|
||||||
|
} catch (NoClassDefFoundError e) {
|
||||||
|
resolved = null; // flash-ext-oidc not on the classpath at all
|
||||||
|
}
|
||||||
|
if (resolved != null) return resolved;
|
||||||
|
|
||||||
|
if (config.security() == McpSecurity.REQUIRED) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"McpSecurity.REQUIRED but flash-ext-oidc is not installed for MCP server \"" + config.name() +
|
||||||
|
"\" — install an OidcExtension before this McpExtension, or relax security to " +
|
||||||
|
"McpSecurity.AUTO/NONE if this server is meant to be public.");
|
||||||
|
}
|
||||||
|
|
||||||
|
log.warn("[flash-ext-mcp] MCP server \"{}\" is running WITHOUT OAuth2 protection — " +
|
||||||
|
"flash-ext-oidc is not installed and McpSecurity.AUTO degrades to unprotected. " +
|
||||||
|
"Install flash-ext-oidc or set McpSecurity.REQUIRED to make this a hard failure instead.",
|
||||||
|
config.name());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 9728 Protected Resource Metadata, built once security is resolved — no longer
|
||||||
|
* conditioned on {@code resourceIdentifier}/{@code authorizationServerIssuer} being set
|
||||||
|
* explicitly, since {@link McpOidcIntegration#resolve} now derives both by default. The
|
||||||
|
* {@code resource} field is computed per request (it depends on that request's own
|
||||||
|
* forwarded/{@code Host} headers) via {@link McpOidcIntegration.Resolved#resourceIdentifier()}.
|
||||||
|
*/
|
||||||
|
private void registerResourceMetadata(FlashRegistrar<?> app, McpOidcIntegration.Resolved secured) {
|
||||||
|
if (secured == null) return;
|
||||||
|
String path = "/.well-known/oauth-protected-resource" + config.rootPath();
|
||||||
|
app.get(path, (req, res) -> {
|
||||||
|
res.type(ContentType.JSON);
|
||||||
|
return McpResourceMetadata.build(
|
||||||
|
secured.resourceIdentifier().apply(req), secured.issuer(), config.scopesSupported());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonEncoding;
|
||||||
|
import com.fasterxml.jackson.core.JsonGenerator;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal JSON access shared by the whole extension. Deliberately tree/streaming only —
|
||||||
|
* no {@code readValue(bytes, Class)} databinding anywhere in this extension. Request bodies
|
||||||
|
* are parsed once into a {@link JsonNode} (no reflection, no property matching against a
|
||||||
|
* target class); responses are written directly with {@link JsonGenerator} against the
|
||||||
|
* envelope's fixed, known shape (also no reflection).
|
||||||
|
*
|
||||||
|
* <p>Not wired to {@code flash-ext-jackson} on purpose: the MCP JSON-RPC envelope is internal
|
||||||
|
* protocol plumbing, not a user-facing serialization concern, so this extension owns its
|
||||||
|
* mapper independently — same reasoning {@code flash-ext-oidc} applies to its own JSON needs
|
||||||
|
* (see {@code json-smart} there). See {@code docs/jackson-interop.md} for the full rationale
|
||||||
|
* and how a future opt-in reuse of a shared {@code ObjectMapper} could work.
|
||||||
|
*/
|
||||||
|
final class McpJson {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
private McpJson() {}
|
||||||
|
|
||||||
|
static JsonNode parse(byte[] body) throws IOException {
|
||||||
|
return MAPPER.readTree(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
static JsonGenerator generator(OutputStream out) throws IOException {
|
||||||
|
return MAPPER.getFactory().createGenerator(out, JsonEncoding.UTF8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a small JSON document in one shot; used only for boot-time precompilation. */
|
||||||
|
static byte[] build(JsonWriter writer) {
|
||||||
|
ByteArrayOutputStream buf = new ByteArrayOutputStream(256);
|
||||||
|
try (JsonGenerator gen = generator(buf)) {
|
||||||
|
writer.write(gen);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Failed to build MCP JSON fragment", e);
|
||||||
|
}
|
||||||
|
return buf.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
static String buildString(JsonWriter writer) {
|
||||||
|
return new String(build(writer), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
interface JsonWriter {
|
||||||
|
void write(JsonGenerator gen) throws IOException;
|
||||||
|
}
|
||||||
|
}
|
||||||
+180
@@ -0,0 +1,180 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.oidc.Authenticated;
|
||||||
|
import dev.relism.flash.ext.oidc.ClaimsHolder;
|
||||||
|
import dev.relism.flash.ext.oidc.OidcMiddleware;
|
||||||
|
import dev.relism.flash.ext.oidc.OidcUser;
|
||||||
|
import dev.relism.flash.ext.oidc.RolesAllowed;
|
||||||
|
import dev.relism.flash.ext.oidc.ScopesAllowed;
|
||||||
|
import dev.relism.flash.exceptions.HttpException;
|
||||||
|
import dev.relism.flash.extension.FlashContext;
|
||||||
|
import dev.relism.flash.models.Request;
|
||||||
|
import dev.relism.flash.routing.Middleware;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy, isolated bridge to {@code flash-ext-oidc}.
|
||||||
|
*
|
||||||
|
* <p>References to OIDC types only ever resolve when {@link #resolve}/{@link #compileToolPolicy}
|
||||||
|
* are actually invoked — never at {@link McpExtension} class-load time — because they live in
|
||||||
|
* this separate nested class. The caller wraps the invocation in {@code catch
|
||||||
|
* (NoClassDefFoundError)}, exactly like {@code OidcExtension}'s own lazy bridge to {@code
|
||||||
|
* flash-ext-openapi}. This is what lets {@code flash-ext-mcp} run standalone (MCP-only, no
|
||||||
|
* OAuth2) when {@code flash-ext-oidc} is not even on the classpath. {@link Resolved}/{@link
|
||||||
|
* McpAuthPolicy} carry only oidc-free types back out ({@link Middleware}, {@link String}, a
|
||||||
|
* {@link Function}, a {@link Supplier}) so no other class in this package ever has to reference
|
||||||
|
* an OIDC type.
|
||||||
|
*
|
||||||
|
* <p>Zero-config by design: when {@code flash-ext-oidc} is installed, everything an MCP OAuth2
|
||||||
|
* resource server needs — issuer, canonical resource identifier, RFC 8707 audience binding, and
|
||||||
|
* a spec-compliant {@code WWW-Authenticate} challenge (RFC 9728 §5.1) — is derived straight from
|
||||||
|
* the installed {@link OidcMiddleware}, with no additional {@link McpConfig} calls.
|
||||||
|
* {@link McpConfig#resourceIdentifier(String)}/{@link McpConfig#authorizationServerIssuer(String)}
|
||||||
|
* remain as explicit overrides for the rare case where that guess is wrong.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
final class McpOidcIntegration {
|
||||||
|
|
||||||
|
private static final String[] NO_VALUES = new String[0];
|
||||||
|
|
||||||
|
private McpOidcIntegration() {}
|
||||||
|
|
||||||
|
/** Everything {@link McpExtension} needs once oidc security is resolved. */
|
||||||
|
record Resolved(Middleware security, String issuer, String rolesClaimPath,
|
||||||
|
Function<Request, String> resourceIdentifier) {}
|
||||||
|
|
||||||
|
/** Returns the resolved security bundle, or {@code null} if oidc is not installed. */
|
||||||
|
static Resolved resolve(FlashContext ctx, McpConfig config) {
|
||||||
|
Optional<OidcMiddleware> oidc = ctx.find(OidcMiddleware.class);
|
||||||
|
if (oidc.isEmpty()) return null;
|
||||||
|
|
||||||
|
OidcMiddleware oidcMw = oidc.get();
|
||||||
|
String resourceMetadataPath = "/.well-known/oauth-protected-resource" + config.rootPath();
|
||||||
|
String issuer = config.authorizationServerIssuer() != null
|
||||||
|
? config.authorizationServerIssuer() : oidcMw.issuer();
|
||||||
|
Function<Request, String> resourceId = req -> config.resourceIdentifier() != null
|
||||||
|
? config.resourceIdentifier()
|
||||||
|
: OidcMiddleware.selfOrigin(req, oidcMw.selfScheme()) + config.rootPath();
|
||||||
|
|
||||||
|
Middleware protect = oidcMw.protect(resourceMetadataPath);
|
||||||
|
Middleware secured = Middleware.of(protect, audienceGuard(resourceId));
|
||||||
|
return new Resolved(secured, issuer, oidcMw.rolesClaimPath(), resourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 8707 audience binding, unconditionally enforced once oidc is protecting the MCP
|
||||||
|
* route — no longer opt-in behind an explicit {@code resourceIdentifier(...)} call.
|
||||||
|
*/
|
||||||
|
private static Middleware audienceGuard(Function<Request, String> resourceIdentifier) {
|
||||||
|
return next -> (req, res) -> {
|
||||||
|
Map<String, Object> claims = ClaimsHolder.get();
|
||||||
|
String expected = resourceIdentifier.apply(req);
|
||||||
|
if (claims != null && !audienceMatches(claims.get("aud"), expected)) {
|
||||||
|
log.warn("[flash-ext-mcp] Rejecting token (RFC 8707): aud={} does not include expected " +
|
||||||
|
"resource identifier \"{}\" — the authorization server must include this exact " +
|
||||||
|
"value in the access token's aud claim (e.g. an Audience protocol mapper in " +
|
||||||
|
"Keycloak) for this MCP server to accept it.", claims.get("aud"), expected);
|
||||||
|
throw HttpException.forbidden();
|
||||||
|
}
|
||||||
|
return next.handle(req, res);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean audienceMatches(Object aud, String expected) {
|
||||||
|
if (aud instanceof String s) return s.equals(expected);
|
||||||
|
if (aud instanceof Iterable<?> it) {
|
||||||
|
for (Object o : it) if (expected.equals(String.valueOf(o))) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compiles {@code @RolesAllowed}/{@code @ScopesAllowed} on a tool class into a {@link
|
||||||
|
* McpAuthPolicy}, or returns {@code null} if the tool carries none of the three OIDC
|
||||||
|
* annotations. Called once per tool at boot ({@link McpRegistry#scan}), never on the
|
||||||
|
* request hot path — the {@link Supplier} it returns is what runs per {@code tools/call},
|
||||||
|
* closing over the already-normalized role/scope arrays so the hot path itself allocates
|
||||||
|
* nothing beyond what {@link OidcUser#hasRole}/{@link OidcUser#hasScope} already do.
|
||||||
|
*
|
||||||
|
* <p>Fails fast at boot, not silently at request time, for the two ways this can be
|
||||||
|
* misconfigured: the annotation present without OAuth2 actually protecting this MCP server
|
||||||
|
* ({@code oidcActive == false}), and {@code @Authenticated} — which has no per-tool meaning
|
||||||
|
* here (see below) — used at all.
|
||||||
|
*/
|
||||||
|
static McpAuthPolicy compileToolPolicy(Class<? extends McpTool> toolClass, boolean oidcActive,
|
||||||
|
String rolesClaimPath) {
|
||||||
|
Authenticated auth = toolClass.getAnnotation(Authenticated.class);
|
||||||
|
RolesAllowed roles = toolClass.getAnnotation(RolesAllowed.class);
|
||||||
|
ScopesAllowed scopes = toolClass.getAnnotation(ScopesAllowed.class);
|
||||||
|
if (auth == null && roles == null && scopes == null) return null;
|
||||||
|
|
||||||
|
if (!oidcActive) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"MCP tool \"" + toolClass.getSimpleName() + "\" declares @Authenticated/@RolesAllowed/" +
|
||||||
|
"@ScopesAllowed, but this MCP server has no active OAuth2 protection — flash-ext-oidc " +
|
||||||
|
"is not installed for it, or McpSecurity is NONE. These annotations require " +
|
||||||
|
"McpSecurity.AUTO/REQUIRED with an OidcExtension installed; install one, or remove the " +
|
||||||
|
"annotation from " + toolClass.getSimpleName() + ".");
|
||||||
|
}
|
||||||
|
if (auth != null) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"MCP tool \"" + toolClass.getSimpleName() + "\" is annotated @Authenticated, which has " +
|
||||||
|
"no effect on an McpTool: the whole MCP endpoint is already all-or-nothing " +
|
||||||
|
"authenticated once oidc is active (McpSecurity.AUTO/REQUIRED) — unlike a RequestHandler " +
|
||||||
|
"route, there is no per-tool public/authenticated split to opt into. Remove it, or use " +
|
||||||
|
"@RolesAllowed/@ScopesAllowed to narrow further.");
|
||||||
|
}
|
||||||
|
|
||||||
|
String[] requiredRoles = roles != null ? normalizeRequired("RolesAllowed", roles.value()) : NO_VALUES;
|
||||||
|
String[] requiredScopes = scopes != null ? normalizeRequired("ScopesAllowed", scopes.value()) : NO_VALUES;
|
||||||
|
ScopesAllowed.Match scopeMatch = scopes != null ? scopes.match() : ScopesAllowed.Match.ALL;
|
||||||
|
|
||||||
|
Supplier<String> check = () -> {
|
||||||
|
OidcUser user = ClaimsHolder.user();
|
||||||
|
if (user == null) return "not authenticated";
|
||||||
|
if (requiredRoles.length > 0 && !hasAnyRole(user, rolesClaimPath, requiredRoles))
|
||||||
|
return "missing required role (any of: " + String.join(", ", requiredRoles) + ")";
|
||||||
|
if (requiredScopes.length > 0 && !hasScopes(user, requiredScopes, scopeMatch))
|
||||||
|
return "missing required scope (" + scopeMatch + " of: " + String.join(", ", requiredScopes) + ")";
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
return new McpAuthPolicy(check);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasAnyRole(OidcUser user, String claimPath, String[] roles) {
|
||||||
|
for (String role : roles) if (user.hasRole(claimPath, role)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasScopes(OidcUser user, String[] scopes, ScopesAllowed.Match match) {
|
||||||
|
if (match == ScopesAllowed.Match.ALL) {
|
||||||
|
for (String scope : scopes) if (!user.hasScope(scope)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (String scope : scopes) if (user.hasScope(scope)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors {@code OidcAuthPolicy}'s own normalization — trim, dedupe, require non-blank. */
|
||||||
|
private static String[] normalizeRequired(String annotationName, String[] values) {
|
||||||
|
if (values == null || values.length == 0)
|
||||||
|
throw new IllegalStateException("@" + annotationName + " requires at least one value");
|
||||||
|
|
||||||
|
LinkedHashSet<String> normalized = new LinkedHashSet<>(values.length);
|
||||||
|
for (String raw : values) {
|
||||||
|
if (raw == null) continue;
|
||||||
|
String trimmed = raw.trim();
|
||||||
|
if (!trimmed.isEmpty()) normalized.add(trimmed);
|
||||||
|
}
|
||||||
|
if (normalized.isEmpty())
|
||||||
|
throw new IllegalStateException("@" + annotationName + " requires at least one non-empty value");
|
||||||
|
|
||||||
|
return normalized.toArray(String[]::new);
|
||||||
|
}
|
||||||
|
}
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import dev.relism.flash.exceptions.InitializationException;
|
||||||
|
|
||||||
|
import java.lang.reflect.Modifier;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import dev.relism.flash.extension.PackageScanner;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal classpath scanner used by {@link McpConfig#toolsPackage(String)}. Finds
|
||||||
|
* {@link McpTool}/{@link McpResource}/{@link McpPrompt} subclasses carrying the matching
|
||||||
|
* annotation ({@link Tool @Tool}, {@link Resource @Resource}, {@link Prompt @Prompt}).
|
||||||
|
* Supports both exploded directories (development) and fat JARs (deployment).
|
||||||
|
*
|
||||||
|
* <p>Deliberately not shared with {@code dev.relism.flash.extension.PackageScanner}: that
|
||||||
|
* scanner is package-private and hardcoded to {@code RequestHandler}/{@code WebSocketEndpoint}.
|
||||||
|
* The directory/JAR walking logic below intentionally mirrors it — same fail-fast contract,
|
||||||
|
* same anonymous-class filtering.
|
||||||
|
*
|
||||||
|
* <p><b>Fail-fast:</b> if the package does not exist, contains no matching class, or a class
|
||||||
|
* cannot be loaded, an {@link InitializationException} is thrown immediately at boot.
|
||||||
|
*/
|
||||||
|
final class McpPackageScanner {
|
||||||
|
|
||||||
|
private McpPackageScanner() {}
|
||||||
|
|
||||||
|
record ScanResult(List<Class<? extends McpTool>> tools,
|
||||||
|
List<Class<? extends McpResource>> resources,
|
||||||
|
List<Class<? extends McpPrompt>> prompts) {}
|
||||||
|
|
||||||
|
static ScanResult scan(String packageName) {
|
||||||
|
if (packageName == null || packageName.isBlank())
|
||||||
|
throw new InitializationException("McpConfig.toolsPackage() called with null or blank package name");
|
||||||
|
|
||||||
|
List<Class<? extends McpTool>> tools = new ArrayList<>();
|
||||||
|
List<Class<? extends McpResource>> resources = new ArrayList<>();
|
||||||
|
List<Class<? extends McpPrompt>> prompts = new ArrayList<>();
|
||||||
|
List<String> errors = new ArrayList<>();
|
||||||
|
for (Class<?> cls : PackageScanner.discover(packageName)) tryLoad(cls, tools, resources, prompts, errors);
|
||||||
|
|
||||||
|
if (!errors.isEmpty())
|
||||||
|
throw new InitializationException(
|
||||||
|
"McpConfig.toolsPackage(\"" + packageName + "\") — failed to load " + errors.size() + " class(es):\n • " +
|
||||||
|
String.join("\n • ", errors));
|
||||||
|
|
||||||
|
if (tools.isEmpty() && resources.isEmpty() && prompts.isEmpty())
|
||||||
|
throw new InitializationException(
|
||||||
|
"McpConfig.toolsPackage(\"" + packageName + "\") — no @Tool/@Resource/@Prompt classes found. " +
|
||||||
|
"Ensure classes extend McpTool/McpResource/McpPrompt, carry the matching annotation, " +
|
||||||
|
"are not abstract, and have a public no-arg constructor.");
|
||||||
|
|
||||||
|
return new ScanResult(List.copyOf(tools), List.copyOf(resources), List.copyOf(prompts));
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static void tryLoad(Class<?> cls,
|
||||||
|
List<Class<? extends McpTool>> tools,
|
||||||
|
List<Class<? extends McpResource>> resources,
|
||||||
|
List<Class<? extends McpPrompt>> prompts,
|
||||||
|
List<String> errors) {
|
||||||
|
try {
|
||||||
|
if (Modifier.isAbstract(cls.getModifiers())) return;
|
||||||
|
|
||||||
|
if (McpTool.class.isAssignableFrom(cls) && cls.isAnnotationPresent(Tool.class)) {
|
||||||
|
assertNoArgConstructor(cls, errors);
|
||||||
|
tools.add((Class<? extends McpTool>) cls);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (McpResource.class.isAssignableFrom(cls) && cls.isAnnotationPresent(Resource.class)) {
|
||||||
|
assertNoArgConstructor(cls, errors);
|
||||||
|
resources.add((Class<? extends McpResource>) cls);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (McpPrompt.class.isAssignableFrom(cls) && cls.isAnnotationPresent(Prompt.class)) {
|
||||||
|
assertNoArgConstructor(cls, errors);
|
||||||
|
prompts.add((Class<? extends McpPrompt>) cls);
|
||||||
|
}
|
||||||
|
} catch (LinkageError e) { errors.add(cls.getName() + " — linkage error: " + e.getMessage()); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertNoArgConstructor(Class<?> cls, List<String> errors) {
|
||||||
|
try { cls.getDeclaredConstructor(); }
|
||||||
|
catch (NoSuchMethodException e) { errors.add(cls.getName() + " — missing public no-arg constructor"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import dev.relism.flash.extension.FlashContext;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for a single MCP prompt template — one class per prompt, mirroring {@link McpTool}.
|
||||||
|
* Declare metadata with {@link Prompt @Prompt}, cache services in {@link #onInit()}, implement
|
||||||
|
* {@link #render(PromptArguments)} for the hot path.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* @Prompt(name = "summarize", args = @PromptArg(name = "text", required = true))
|
||||||
|
* public class SummarizePrompt extends McpPrompt {
|
||||||
|
* @Override public PromptMessage render(PromptArguments args) {
|
||||||
|
* return PromptMessage.withUserRole(new TextContent("Summarize: " + args.getString("text")));
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
public abstract class McpPrompt {
|
||||||
|
|
||||||
|
private FlashContext ctx;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called once by the framework after instantiation, before the first {@code prompts/get}.
|
||||||
|
* <b>Infrastructure method</b> — do not call from user code.
|
||||||
|
*/
|
||||||
|
public final void bind(FlashContext ctx) {
|
||||||
|
this.ctx = ctx;
|
||||||
|
onInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void onInit() {}
|
||||||
|
|
||||||
|
protected <T> T require(Class<T> type) {
|
||||||
|
checkBound();
|
||||||
|
return ctx.require(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected <T> Optional<T> find(Class<T> type) {
|
||||||
|
checkBound();
|
||||||
|
return ctx.find(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected <T> Optional<T> optional(Class<T> type) {
|
||||||
|
checkBound();
|
||||||
|
return ctx.optional(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkBound() {
|
||||||
|
if (ctx == null)
|
||||||
|
throw new IllegalStateException(
|
||||||
|
getClass().getSimpleName() + " has not been bound to a FlashContext — " +
|
||||||
|
"register via McpConfig.toolsPackage(), not by instantiating directly");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invoked on every matching {@code prompts/get} request (hot path). */
|
||||||
|
public abstract PromptMessage render(PromptArguments args) throws Exception;
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
/** Internal signal carrying a JSON-RPC error code, caught by {@link McpDispatcher} to build the error response. */
|
||||||
|
final class McpProtocolException extends RuntimeException {
|
||||||
|
|
||||||
|
final int code;
|
||||||
|
|
||||||
|
private McpProtocolException(int code, String message) {
|
||||||
|
super(message);
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
static McpProtocolException invalidRequest(String message) {
|
||||||
|
return new McpProtocolException(JsonRpcErrorCode.INVALID_REQUEST, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
static McpProtocolException methodNotFound(String method) {
|
||||||
|
return new McpProtocolException(JsonRpcErrorCode.METHOD_NOT_FOUND, "Method not found: " + method);
|
||||||
|
}
|
||||||
|
|
||||||
|
static McpProtocolException invalidParams(String message) {
|
||||||
|
return new McpProtocolException(JsonRpcErrorCode.INVALID_PARAMS, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
+204
@@ -0,0 +1,204 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonGenerator;
|
||||||
|
import dev.relism.flash.exceptions.InitializationException;
|
||||||
|
import dev.relism.flash.extension.FlashContext;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.lang.reflect.Constructor;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot-time-built registry of tools/resources/prompts for one MCP server.
|
||||||
|
*
|
||||||
|
* <p>Everything static about the catalog — the {@code tools/list}/{@code resources/list}/
|
||||||
|
* {@code prompts/list} JSON payloads — is assembled exactly once here via
|
||||||
|
* {@link McpJson#buildString}, then spliced verbatim into responses at request time
|
||||||
|
* ({@link McpDispatcher}) with {@link JsonGenerator#writeRawValue(String)}: no
|
||||||
|
* re-serialization, no reflection, no databinding, and no per-request byte[]→String
|
||||||
|
* conversion on the hot path — the string is already sitting in memory, built once at boot.
|
||||||
|
*/
|
||||||
|
final class McpRegistry {
|
||||||
|
|
||||||
|
private static final String EMPTY_ARRAY = "[]";
|
||||||
|
|
||||||
|
/** {@code policy} is {@code null} unless the tool carries @RolesAllowed/@ScopesAllowed. */
|
||||||
|
record RegisteredTool(String name, McpTool instance, McpAuthPolicy policy) {}
|
||||||
|
record RegisteredResource(String uri, McpResource instance) {}
|
||||||
|
record RegisteredPrompt(String name, McpPrompt instance) {}
|
||||||
|
|
||||||
|
private final Map<String, RegisteredTool> tools = new LinkedHashMap<>();
|
||||||
|
private final Map<String, RegisteredResource> resources = new LinkedHashMap<>();
|
||||||
|
private final Map<String, RegisteredPrompt> prompts = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
private String toolsListJson = EMPTY_ARRAY;
|
||||||
|
private String resourcesListJson = EMPTY_ARRAY;
|
||||||
|
private String promptsListJson = EMPTY_ARRAY;
|
||||||
|
|
||||||
|
private McpRegistry() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param oidcActive whether this MCP server's route is actually OAuth2-protected right
|
||||||
|
* now (see {@link McpOidcIntegration#resolve}) — gates whether
|
||||||
|
* {@code @RolesAllowed}/{@code @ScopesAllowed} on a tool are honored or
|
||||||
|
* rejected at boot as a misconfiguration; see
|
||||||
|
* {@link McpOidcIntegration#compileToolPolicy}.
|
||||||
|
* @param rolesClaimPath claim path resolved from the installed OIDC extension.
|
||||||
|
*/
|
||||||
|
static McpRegistry scan(String packageName, FlashContext ctx, boolean oidcActive, String rolesClaimPath) {
|
||||||
|
McpPackageScanner.ScanResult found = McpPackageScanner.scan(packageName);
|
||||||
|
McpRegistry registry = new McpRegistry();
|
||||||
|
|
||||||
|
for (Class<? extends McpTool> cls : found.tools()) {
|
||||||
|
Tool ann = cls.getAnnotation(Tool.class);
|
||||||
|
McpTool instance = instantiate(cls);
|
||||||
|
instance.bind(ctx);
|
||||||
|
McpAuthPolicy policy = compileToolPolicy(cls, oidcActive, rolesClaimPath);
|
||||||
|
if (registry.tools.putIfAbsent(ann.name(), new RegisteredTool(ann.name(), instance, policy)) != null)
|
||||||
|
throw new InitializationException("Duplicate MCP tool name: \"" + ann.name() + "\"");
|
||||||
|
}
|
||||||
|
for (Class<? extends McpResource> cls : found.resources()) {
|
||||||
|
Resource ann = cls.getAnnotation(Resource.class);
|
||||||
|
McpResource instance = instantiate(cls);
|
||||||
|
instance.bind(ctx);
|
||||||
|
if (registry.resources.putIfAbsent(ann.uri(), new RegisteredResource(ann.uri(), instance)) != null)
|
||||||
|
throw new InitializationException("Duplicate MCP resource uri: \"" + ann.uri() + "\"");
|
||||||
|
}
|
||||||
|
for (Class<? extends McpPrompt> cls : found.prompts()) {
|
||||||
|
Prompt ann = cls.getAnnotation(Prompt.class);
|
||||||
|
McpPrompt instance = instantiate(cls);
|
||||||
|
instance.bind(ctx);
|
||||||
|
if (registry.prompts.putIfAbsent(ann.name(), new RegisteredPrompt(ann.name(), instance)) != null)
|
||||||
|
throw new InitializationException("Duplicate MCP prompt name: \"" + ann.name() + "\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found.tools().isEmpty())
|
||||||
|
registry.toolsListJson = McpJson.buildString(gen -> writeToolsArray(gen, found.tools()));
|
||||||
|
if (!found.resources().isEmpty())
|
||||||
|
registry.resourcesListJson = McpJson.buildString(gen -> writeResourcesArray(gen, found.resources()));
|
||||||
|
if (!found.prompts().isEmpty())
|
||||||
|
registry.promptsListJson = McpJson.buildString(gen -> writePromptsArray(gen, found.prompts()));
|
||||||
|
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean hasTools() { return !tools.isEmpty(); }
|
||||||
|
boolean hasResources() { return !resources.isEmpty(); }
|
||||||
|
boolean hasPrompts() { return !prompts.isEmpty(); }
|
||||||
|
|
||||||
|
String toolsListJson() { return toolsListJson; }
|
||||||
|
String resourcesListJson() { return resourcesListJson; }
|
||||||
|
String promptsListJson() { return promptsListJson; }
|
||||||
|
|
||||||
|
RegisteredTool tool(String name) { return tools.get(name); }
|
||||||
|
RegisteredResource resource(String uri) { return resources.get(uri); }
|
||||||
|
RegisteredPrompt prompt(String name) { return prompts.get(name); }
|
||||||
|
|
||||||
|
// ── Boot-time JSON Schema / descriptor precompilation ───────────────────────
|
||||||
|
|
||||||
|
private static void writeToolsArray(JsonGenerator gen, List<Class<? extends McpTool>> classes) throws IOException {
|
||||||
|
gen.writeStartArray();
|
||||||
|
for (Class<? extends McpTool> cls : classes) writeTool(gen, cls.getAnnotation(Tool.class));
|
||||||
|
gen.writeEndArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeTool(JsonGenerator gen, Tool ann) throws IOException {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeStringField("name", ann.name());
|
||||||
|
if (!ann.title().isBlank()) gen.writeStringField("title", ann.title());
|
||||||
|
if (!ann.description().isBlank()) gen.writeStringField("description", ann.description());
|
||||||
|
gen.writeFieldName("inputSchema");
|
||||||
|
writeInputSchema(gen, ann.args());
|
||||||
|
gen.writeEndObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeInputSchema(JsonGenerator gen, ToolArg[] args) throws IOException {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeStringField("type", "object");
|
||||||
|
gen.writeObjectFieldStart("properties");
|
||||||
|
for (ToolArg arg : args) {
|
||||||
|
gen.writeObjectFieldStart(arg.name());
|
||||||
|
gen.writeStringField("type", arg.type().jsonSchemaType());
|
||||||
|
if (!arg.description().isBlank()) gen.writeStringField("description", arg.description());
|
||||||
|
gen.writeEndObject();
|
||||||
|
}
|
||||||
|
gen.writeEndObject();
|
||||||
|
if (hasRequired(args)) {
|
||||||
|
gen.writeArrayFieldStart("required");
|
||||||
|
for (ToolArg arg : args) if (arg.required()) gen.writeString(arg.name());
|
||||||
|
gen.writeEndArray();
|
||||||
|
}
|
||||||
|
gen.writeEndObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasRequired(ToolArg[] args) {
|
||||||
|
for (ToolArg arg : args) if (arg.required()) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeResourcesArray(JsonGenerator gen, List<Class<? extends McpResource>> classes) throws IOException {
|
||||||
|
gen.writeStartArray();
|
||||||
|
for (Class<? extends McpResource> cls : classes) {
|
||||||
|
Resource ann = cls.getAnnotation(Resource.class);
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeStringField("uri", ann.uri());
|
||||||
|
gen.writeStringField("name", !ann.name().isBlank() ? ann.name() : ann.uri());
|
||||||
|
if (!ann.description().isBlank()) gen.writeStringField("description", ann.description());
|
||||||
|
gen.writeStringField("mimeType", ann.mimeType());
|
||||||
|
gen.writeEndObject();
|
||||||
|
}
|
||||||
|
gen.writeEndArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writePromptsArray(JsonGenerator gen, List<Class<? extends McpPrompt>> classes) throws IOException {
|
||||||
|
gen.writeStartArray();
|
||||||
|
for (Class<? extends McpPrompt> cls : classes) {
|
||||||
|
Prompt ann = cls.getAnnotation(Prompt.class);
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeStringField("name", ann.name());
|
||||||
|
if (!ann.description().isBlank()) gen.writeStringField("description", ann.description());
|
||||||
|
gen.writeArrayFieldStart("arguments");
|
||||||
|
for (PromptArg arg : ann.args()) {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeStringField("name", arg.name());
|
||||||
|
if (!arg.description().isBlank()) gen.writeStringField("description", arg.description());
|
||||||
|
gen.writeBooleanField("required", arg.required());
|
||||||
|
gen.writeEndObject();
|
||||||
|
}
|
||||||
|
gen.writeEndArray();
|
||||||
|
gen.writeEndObject();
|
||||||
|
}
|
||||||
|
gen.writeEndArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Isolated the same way {@link McpOidcIntegration#resolve} is — {@code
|
||||||
|
* NoClassDefFoundError} here means {@code flash-ext-oidc} genuinely isn't on the runtime
|
||||||
|
* classpath, in which case a tool couldn't have been compiled against
|
||||||
|
* {@code @RolesAllowed}/{@code @ScopesAllowed} in the first place, so there's nothing to
|
||||||
|
* check (and nothing lost: {@code oidcActive} is only ever {@code true} once {@link
|
||||||
|
* McpOidcIntegration#resolve} has already succeeded once this boot, which proves those
|
||||||
|
* types resolve fine).
|
||||||
|
*/
|
||||||
|
private static McpAuthPolicy compileToolPolicy(Class<? extends McpTool> cls, boolean oidcActive,
|
||||||
|
String rolesClaimPath) {
|
||||||
|
try {
|
||||||
|
return McpOidcIntegration.compileToolPolicy(cls, oidcActive, rolesClaimPath);
|
||||||
|
} catch (NoClassDefFoundError e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <T> T instantiate(Class<T> cls) {
|
||||||
|
try {
|
||||||
|
Constructor<T> ctor = cls.getDeclaredConstructor();
|
||||||
|
return ctor.newInstance();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new InitializationException(
|
||||||
|
"Failed to instantiate " + cls.getName() +
|
||||||
|
" — ensure it has a public no-arg constructor", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import dev.relism.flash.extension.FlashContext;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for a single MCP resource — one class per resource, mirroring {@link McpTool}.
|
||||||
|
* Declare metadata with {@link Resource @Resource}, cache services in {@link #onInit()},
|
||||||
|
* implement {@link #read()} for the hot path.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* @Resource(uri = "config://app-settings", description = "Application settings", mimeType = "application/json")
|
||||||
|
* public class AppSettingsResource extends McpResource {
|
||||||
|
* @Override public ResourceContents read() {
|
||||||
|
* return TextResourceContents.of(uri(), "application/json", settingsJson());
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
public abstract class McpResource {
|
||||||
|
|
||||||
|
private FlashContext ctx;
|
||||||
|
private String uri;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called once by the framework after instantiation, before the first {@code resources/read}.
|
||||||
|
* <b>Infrastructure method</b> — do not call from user code.
|
||||||
|
*/
|
||||||
|
public final void bind(FlashContext ctx) {
|
||||||
|
this.ctx = ctx;
|
||||||
|
Resource ann = getClass().getAnnotation(Resource.class);
|
||||||
|
this.uri = ann != null ? ann.uri() : null;
|
||||||
|
onInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void onInit() {}
|
||||||
|
|
||||||
|
protected <T> T require(Class<T> type) {
|
||||||
|
checkBound();
|
||||||
|
return ctx.require(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected <T> Optional<T> find(Class<T> type) {
|
||||||
|
checkBound();
|
||||||
|
return ctx.find(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected <T> Optional<T> optional(Class<T> type) {
|
||||||
|
checkBound();
|
||||||
|
return ctx.optional(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URI declared via {@link Resource @Resource}, cached at bind time. */
|
||||||
|
protected final String uri() { return uri; }
|
||||||
|
|
||||||
|
private void checkBound() {
|
||||||
|
if (ctx == null)
|
||||||
|
throw new IllegalStateException(
|
||||||
|
getClass().getSimpleName() + " has not been bound to a FlashContext — " +
|
||||||
|
"register via McpConfig.toolsPackage(), not by instantiating directly");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invoked on every matching {@code resources/read} request (hot path). */
|
||||||
|
public abstract ResourceContents read() throws Exception;
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** RFC 9728 OAuth 2.0 Protected Resource Metadata document, built once at boot. */
|
||||||
|
final class McpResourceMetadata {
|
||||||
|
|
||||||
|
private McpResourceMetadata() {}
|
||||||
|
|
||||||
|
/** {@code scopesSupported} is optional per RFC 9728 — omitted from the document if empty. */
|
||||||
|
static String build(String resourceIdentifier, String authorizationServerIssuer, List<String> scopesSupported) {
|
||||||
|
return McpJson.buildString(gen -> {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeStringField("resource", resourceIdentifier);
|
||||||
|
gen.writeArrayFieldStart("authorization_servers");
|
||||||
|
gen.writeString(authorizationServerIssuer);
|
||||||
|
gen.writeEndArray();
|
||||||
|
if (!scopesSupported.isEmpty()) {
|
||||||
|
gen.writeArrayFieldStart("scopes_supported");
|
||||||
|
for (String scope : scopesSupported) gen.writeString(scope);
|
||||||
|
gen.writeEndArray();
|
||||||
|
}
|
||||||
|
gen.writeEndObject();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth2 requirement policy for the MCP endpoint, resolved against whether
|
||||||
|
* {@code flash-ext-oidc} is installed ({@code ctx.find(OidcMiddleware.class)}).
|
||||||
|
*/
|
||||||
|
public enum McpSecurity {
|
||||||
|
|
||||||
|
/** Fail fast at boot if {@code flash-ext-oidc} is not installed — never expose an unprotected MCP endpoint. */
|
||||||
|
REQUIRED,
|
||||||
|
|
||||||
|
/** Protect the endpoint if {@code flash-ext-oidc} is installed; otherwise run unprotected and log a warning. */
|
||||||
|
AUTO,
|
||||||
|
|
||||||
|
/** Never protect the endpoint, even if {@code flash-ext-oidc} is installed elsewhere in the app. */
|
||||||
|
NONE
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import dev.relism.flash.extension.FlashContext;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for a single MCP tool — one class per tool, mirroring
|
||||||
|
* {@link dev.relism.flash.models.RequestHandler}: declare metadata with {@link Tool @Tool},
|
||||||
|
* cache services in {@link #onInit()}, implement {@link #call(ToolArguments)} for the hot path.
|
||||||
|
*
|
||||||
|
* <p>Discovered via {@link McpConfig#toolsPackage(String)} — instantiated with its public
|
||||||
|
* no-arg constructor and bound once at boot, before the first {@code tools/call} request.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* @Tool(name = "get_weather", description = "Get current weather for a city",
|
||||||
|
* args = @ToolArg(name = "city", required = true))
|
||||||
|
* public class GetWeatherTool extends McpTool {
|
||||||
|
* private WeatherService weatherService;
|
||||||
|
*
|
||||||
|
* @Override protected void onInit() {
|
||||||
|
* weatherService = require(WeatherService.class);
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @Override public ToolResponse call(ToolArguments args) {
|
||||||
|
* return ToolResponse.success(new TextContent(weatherService.fetch(args.getString("city"))));
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
public abstract class McpTool {
|
||||||
|
|
||||||
|
private FlashContext ctx;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called once by the framework after instantiation, before the first {@code tools/call}.
|
||||||
|
* <b>Infrastructure method</b> — do not call from user code.
|
||||||
|
*/
|
||||||
|
public final void bind(FlashContext ctx) {
|
||||||
|
this.ctx = ctx;
|
||||||
|
onInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Override to cache services at boot time. See {@link #require}/{@link #find}. */
|
||||||
|
protected void onInit() {}
|
||||||
|
|
||||||
|
protected <T> T require(Class<T> type) {
|
||||||
|
checkBound();
|
||||||
|
return ctx.require(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected <T> Optional<T> find(Class<T> type) {
|
||||||
|
checkBound();
|
||||||
|
return ctx.find(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected <T> Optional<T> optional(Class<T> type) {
|
||||||
|
checkBound();
|
||||||
|
return ctx.optional(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkBound() {
|
||||||
|
if (ctx == null)
|
||||||
|
throw new IllegalStateException(
|
||||||
|
getClass().getSimpleName() + " has not been bound to a FlashContext — " +
|
||||||
|
"register via McpConfig.toolsPackage(), not by instantiating directly");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked on every matching {@code tools/call} request (hot path). {@code args} is a thin
|
||||||
|
* accessor over the already-parsed JSON arguments — no databinding.
|
||||||
|
*/
|
||||||
|
public abstract ToolResponse call(ToolArguments args) throws Exception;
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import dev.relism.flash.exceptions.HttpException;
|
||||||
|
import dev.relism.flash.http.ContentType;
|
||||||
|
import dev.relism.flash.routing.Middleware;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Transport-level guards for the MCP Streamable HTTP endpoint. */
|
||||||
|
@Slf4j
|
||||||
|
final class McpTransportGuards {
|
||||||
|
|
||||||
|
private McpTransportGuards() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the {@code Origin} header per the Streamable HTTP transport's DNS-rebinding
|
||||||
|
* protection requirement. Non-browser clients that omit {@code Origin} entirely are always
|
||||||
|
* allowed through — only a <em>present but disallowed</em> value is rejected.
|
||||||
|
*
|
||||||
|
* <p>If {@code allowedOrigins} is empty, validation is skipped and a boot-time warning is
|
||||||
|
* logged — same graceful-degradation shape as {@link McpSecurity#AUTO}.
|
||||||
|
*/
|
||||||
|
static Middleware originGuard(List<String> allowedOrigins) {
|
||||||
|
if (allowedOrigins.isEmpty()) {
|
||||||
|
log.warn("[flash-ext-mcp] No allowedOrigins configured — Origin header validation " +
|
||||||
|
"(DNS-rebinding protection) is DISABLED. Configure McpConfig.allowedOrigins(...) for production use.");
|
||||||
|
return next -> next::handle;
|
||||||
|
}
|
||||||
|
return next -> (req, res) -> {
|
||||||
|
String origin = req.header("Origin");
|
||||||
|
if (origin != null && !allowedOrigins.contains(origin)) {
|
||||||
|
throw HttpException.forbidden();
|
||||||
|
}
|
||||||
|
return next.handle(req, res);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safety net around the whole MCP route: translates {@link HttpException} (thrown by
|
||||||
|
* {@link #originGuard} or by {@code flash-ext-oidc}'s middleware) into a proper HTTP status
|
||||||
|
* directly, instead of relying on the app's global exception handler — which defaults to a
|
||||||
|
* generic 500 for every exception type unless the app owner overrides it (see
|
||||||
|
* {@code AbstractRouter}'s default {@code exceptionHandler}). Keeps the MCP endpoint
|
||||||
|
* correct out of the box regardless of what the rest of the app configures.
|
||||||
|
*/
|
||||||
|
static Middleware httpExceptionGuard() {
|
||||||
|
return next -> (req, res) -> {
|
||||||
|
try {
|
||||||
|
return next.handle(req, res);
|
||||||
|
} catch (HttpException e) {
|
||||||
|
String body = McpJson.buildString(gen -> {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeStringField("error", e.getMessage());
|
||||||
|
gen.writeEndObject();
|
||||||
|
});
|
||||||
|
res.status(e.status()).type(ContentType.JSON).body(body);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a {@link McpPrompt} subclass as an MCP prompt template and declares its metadata,
|
||||||
|
* discovered by {@link McpConfig#toolsPackage(String)}.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* @Prompt(name = "summarize", args = @PromptArg(name = "text", required = true))
|
||||||
|
* public class SummarizePrompt extends McpPrompt {
|
||||||
|
* @Override
|
||||||
|
* public PromptMessage render(PromptArguments args) {
|
||||||
|
* return PromptMessage.withUserRole(new TextContent("Summarize: " + args.getString("text")));
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@Target(ElementType.TYPE)
|
||||||
|
public @interface Prompt {
|
||||||
|
/** Unique prompt name (used by clients in {@code prompts/get}). */
|
||||||
|
String name();
|
||||||
|
|
||||||
|
String description() default "";
|
||||||
|
|
||||||
|
/** Arguments accepted by the prompt template — always strings per the MCP specification. */
|
||||||
|
PromptArg[] args() default {};
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declares one argument of a {@link Prompt}. Per the MCP specification, prompt arguments are
|
||||||
|
* always strings. Used inside {@link Prompt#args()}.
|
||||||
|
*/
|
||||||
|
public @interface PromptArg {
|
||||||
|
String name();
|
||||||
|
String description() default "";
|
||||||
|
boolean required() default false;
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.MissingNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed accessor over a {@code prompts/get} request's {@code arguments} object.
|
||||||
|
* Per the MCP specification, prompt arguments are always strings.
|
||||||
|
*/
|
||||||
|
public final class PromptArguments {
|
||||||
|
|
||||||
|
private final JsonNode node;
|
||||||
|
|
||||||
|
PromptArguments(JsonNode node) {
|
||||||
|
this.node = node != null ? node : MissingNode.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean has(String name) { return node.has(name); }
|
||||||
|
public String getString(String name) { return node.path(name).asText(null); }
|
||||||
|
public String getString(String name, String defaultValue) { return node.path(name).asText(defaultValue); }
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
/** A single message returned by a {@link McpPrompt}. */
|
||||||
|
public record PromptMessage(Role role, Content content) {
|
||||||
|
|
||||||
|
public enum Role { USER, ASSISTANT }
|
||||||
|
|
||||||
|
public static PromptMessage withUserRole(Content content) {
|
||||||
|
return new PromptMessage(Role.USER, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PromptMessage withAssistantRole(Content content) {
|
||||||
|
return new PromptMessage(Role.ASSISTANT, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a {@link McpResource} subclass as an MCP resource and declares its metadata, discovered
|
||||||
|
* by {@link McpConfig#toolsPackage(String)}.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* @Resource(uri = "config://app-settings", description = "Application settings")
|
||||||
|
* public class AppSettingsResource extends McpResource {
|
||||||
|
* @Override
|
||||||
|
* public ResourceContents read() {
|
||||||
|
* return TextResourceContents.of(uri(), "application/json", settingsJson());
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@Target(ElementType.TYPE)
|
||||||
|
public @interface Resource {
|
||||||
|
/** Unique resource URI (used by clients in {@code resources/read}). */
|
||||||
|
String uri();
|
||||||
|
|
||||||
|
String name() default "";
|
||||||
|
String description() default "";
|
||||||
|
String mimeType() default "text/plain";
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP resource contents. {@code sealed} to the variants this extension currently writes on
|
||||||
|
* the wire — extend the permits clause (and {@link McpContentWriter}) to add
|
||||||
|
* {@code BlobResourceContents} in a future revision.
|
||||||
|
*/
|
||||||
|
public sealed interface ResourceContents permits TextResourceContents {}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
/** Plain-text content block ({@code type: "text"} on the wire). */
|
||||||
|
public record TextContent(String text) implements Content {}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
/** Text resource contents returned from {@code resources/read}. */
|
||||||
|
public record TextResourceContents(String uri, String mimeType, String text) implements ResourceContents {
|
||||||
|
|
||||||
|
public static TextResourceContents of(String uri, String mimeType, String text) {
|
||||||
|
return new TextResourceContents(uri, mimeType, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a {@link McpTool} subclass as an MCP tool and declares its metadata, discovered by
|
||||||
|
* {@link McpConfig#toolsPackage(String)}.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* @Tool(
|
||||||
|
* name = "get_weather",
|
||||||
|
* description = "Get current weather for a city",
|
||||||
|
* args = @ToolArg(name = "city", description = "City name", required = true)
|
||||||
|
* )
|
||||||
|
* public class GetWeatherTool extends McpTool {
|
||||||
|
* @Override
|
||||||
|
* public ToolResponse call(ToolArguments args) {
|
||||||
|
* return ToolResponse.success(new TextContent(weatherService.fetch(args.getString("city"))));
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@Target(ElementType.TYPE)
|
||||||
|
public @interface Tool {
|
||||||
|
/** Unique tool name (used by clients in {@code tools/call}). */
|
||||||
|
String name();
|
||||||
|
|
||||||
|
/** Human/model-readable description of what the tool does. */
|
||||||
|
String description() default "";
|
||||||
|
|
||||||
|
/** Optional display title, distinct from {@link #name()}. */
|
||||||
|
String title() default "";
|
||||||
|
|
||||||
|
/** Input arguments — assembled into the tool's JSON Schema {@code inputSchema} once at boot. */
|
||||||
|
ToolArg[] args() default {};
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declares one input argument of a {@link Tool}. Used inside {@link Tool#args()} — the whole
|
||||||
|
* input JSON Schema is assembled once at scan time from these, never at call time.
|
||||||
|
*/
|
||||||
|
public @interface ToolArg {
|
||||||
|
String name();
|
||||||
|
ToolArgType type() default ToolArgType.STRING;
|
||||||
|
String description() default "";
|
||||||
|
boolean required() default false;
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
/** JSON Schema primitive types available for {@link ToolArg#type()}. */
|
||||||
|
public enum ToolArgType {
|
||||||
|
STRING, INTEGER, NUMBER, BOOLEAN, OBJECT, ARRAY;
|
||||||
|
|
||||||
|
/** JSON Schema {@code "type"} keyword value. */
|
||||||
|
String jsonSchemaType() {
|
||||||
|
return name().toLowerCase(java.util.Locale.ROOT);
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.MissingNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed accessor over a {@code tools/call} request's {@code arguments} object.
|
||||||
|
*
|
||||||
|
* <p>Wraps the already-parsed {@link JsonNode} directly — no POJO databinding, no reflection,
|
||||||
|
* no intermediate copy. Same spirit as {@code QueryParams}/{@code PathParams} in Flash core:
|
||||||
|
* a thin typed view over data that already exists in memory.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* public ToolResponse call(ToolArguments args) {
|
||||||
|
* String city = args.getString("city");
|
||||||
|
* int days = args.getInt("days", 1);
|
||||||
|
* ...
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
public final class ToolArguments {
|
||||||
|
|
||||||
|
private final JsonNode node;
|
||||||
|
|
||||||
|
ToolArguments(JsonNode node) {
|
||||||
|
this.node = node != null ? node : MissingNode.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean has(String name) { return node.has(name); }
|
||||||
|
|
||||||
|
public String getString(String name) { return node.path(name).asText(null); }
|
||||||
|
public String getString(String name, String defaultValue) { return node.path(name).asText(defaultValue); }
|
||||||
|
|
||||||
|
public int getInt(String name) { return node.path(name).asInt(); }
|
||||||
|
public int getInt(String name, int defaultValue) { return node.path(name).asInt(defaultValue); }
|
||||||
|
|
||||||
|
public long getLong(String name) { return node.path(name).asLong(); }
|
||||||
|
public long getLong(String name, long defaultValue) { return node.path(name).asLong(defaultValue); }
|
||||||
|
|
||||||
|
public double getDouble(String name) { return node.path(name).asDouble(); }
|
||||||
|
public double getDouble(String name, double defaultValue) { return node.path(name).asDouble(defaultValue); }
|
||||||
|
|
||||||
|
public boolean getBoolean(String name) { return node.path(name).asBoolean(); }
|
||||||
|
public boolean getBoolean(String name, boolean defaultValue) { return node.path(name).asBoolean(defaultValue); }
|
||||||
|
|
||||||
|
/** Escape hatch for nested/array arguments not covered by the typed accessors above. */
|
||||||
|
public JsonNode raw(String name) { return node.path(name); }
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Result of a {@link McpTool#call(ToolArguments)} invocation. */
|
||||||
|
public final class ToolResponse {
|
||||||
|
|
||||||
|
private final List<Content> content;
|
||||||
|
private final boolean isError;
|
||||||
|
|
||||||
|
private ToolResponse(List<Content> content, boolean isError) {
|
||||||
|
this.content = content;
|
||||||
|
this.isError = isError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Successful tool result carrying one or more content blocks. */
|
||||||
|
public static ToolResponse success(Content... content) {
|
||||||
|
return new ToolResponse(List.of(content), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool-level failure — per the MCP specification this is still a normal JSON-RPC
|
||||||
|
* <em>result</em> (not a JSON-RPC error) with {@code isError: true}, so the model can see
|
||||||
|
* and react to it.
|
||||||
|
*/
|
||||||
|
public static ToolResponse error(String message) {
|
||||||
|
return new ToolResponse(List.of(new TextContent(message)), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Content> content() { return content; }
|
||||||
|
boolean isError() { return isError; }
|
||||||
|
}
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import com.nimbusds.jose.JWSAlgorithm;
|
||||||
|
import com.nimbusds.jose.JWSHeader;
|
||||||
|
import com.nimbusds.jose.crypto.RSASSASigner;
|
||||||
|
import com.nimbusds.jose.jwk.JWKSet;
|
||||||
|
import com.nimbusds.jose.jwk.KeyUse;
|
||||||
|
import com.nimbusds.jose.jwk.RSAKey;
|
||||||
|
import com.nimbusds.jwt.JWTClaimsSet;
|
||||||
|
import com.nimbusds.jwt.SignedJWT;
|
||||||
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.KeyPair;
|
||||||
|
import java.security.KeyPairGenerator;
|
||||||
|
import java.security.interfaces.RSAPrivateKey;
|
||||||
|
import java.security.interfaces.RSAPublicKey;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal, self-contained fake OIDC provider for tests: real discovery document, real JWKS
|
||||||
|
* endpoint, real RS256-signed tokens — no network dependency beyond localhost, no mocking
|
||||||
|
* framework. Exercises {@code flash-ext-oidc}'s actual discovery + JWKS + JWT validation path.
|
||||||
|
*/
|
||||||
|
final class FakeOidcProvider implements AutoCloseable {
|
||||||
|
|
||||||
|
private final HttpServer server;
|
||||||
|
private final String issuer;
|
||||||
|
private final RSAKey rsaKey;
|
||||||
|
|
||||||
|
FakeOidcProvider() throws Exception {
|
||||||
|
KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
|
||||||
|
gen.initialize(2048);
|
||||||
|
KeyPair kp = gen.generateKeyPair();
|
||||||
|
this.rsaKey = new RSAKey.Builder((RSAPublicKey) kp.getPublic())
|
||||||
|
.privateKey((RSAPrivateKey) kp.getPrivate())
|
||||||
|
.keyUse(KeyUse.SIGNATURE)
|
||||||
|
.algorithm(JWSAlgorithm.RS256)
|
||||||
|
.keyID(UUID.randomUUID().toString())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
this.server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||||
|
this.issuer = "http://127.0.0.1:" + server.getAddress().getPort();
|
||||||
|
|
||||||
|
server.createContext("/.well-known/openid-configuration", ex -> respond(ex, discoveryDocument()));
|
||||||
|
server.createContext("/jwks", ex -> respond(ex, new JWKSet(rsaKey.toPublicJWK()).toJSONObject().toString()));
|
||||||
|
server.setExecutor(null);
|
||||||
|
server.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
String issuer() { return issuer; }
|
||||||
|
|
||||||
|
/** Mints a valid RS256 access token — bearer-validation only, no full authorization-code round-trip needed. */
|
||||||
|
String signToken(String subject, String audience) {
|
||||||
|
return signToken(subject, audience, null, NO_ROLES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as {@link #signToken(String, String)}, plus a {@code scope} claim (space-delimited,
|
||||||
|
* matching {@link dev.relism.flash.ext.oidc.OidcUser#hasScope}'s default claim path) and a
|
||||||
|
* Keycloak-shaped {@code realm_access.roles} claim (matching {@code McpConfig}'s default
|
||||||
|
* {@code rolesClaimPath}) when {@code roles} is non-empty.
|
||||||
|
*/
|
||||||
|
String signToken(String subject, String audience, String scope, String... roles) {
|
||||||
|
try {
|
||||||
|
JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder()
|
||||||
|
.issuer(issuer)
|
||||||
|
.subject(subject)
|
||||||
|
.audience(audience)
|
||||||
|
.issueTime(Date.from(Instant.now()))
|
||||||
|
.expirationTime(Date.from(Instant.now().plusSeconds(300)));
|
||||||
|
if (scope != null) builder.claim("scope", scope);
|
||||||
|
if (roles.length > 0) builder.claim("realm_access", Map.of("roles", List.of(roles)));
|
||||||
|
SignedJWT jwt = new SignedJWT(
|
||||||
|
new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID()).build(), builder.build());
|
||||||
|
jwt.sign(new RSASSASigner(rsaKey));
|
||||||
|
return jwt.serialize();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final String[] NO_ROLES = new String[0];
|
||||||
|
|
||||||
|
private String discoveryDocument() {
|
||||||
|
return "{"
|
||||||
|
+ "\"issuer\":\"" + issuer + "\","
|
||||||
|
+ "\"authorization_endpoint\":\"" + issuer + "/auth\","
|
||||||
|
+ "\"token_endpoint\":\"" + issuer + "/token\","
|
||||||
|
+ "\"jwks_uri\":\"" + issuer + "/jwks\""
|
||||||
|
+ "}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void respond(com.sun.net.httpserver.HttpExchange ex, String body) throws java.io.IOException {
|
||||||
|
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||||
|
ex.getResponseHeaders().add("Content-Type", "application/json");
|
||||||
|
ex.sendResponseHeaders(200, bytes.length);
|
||||||
|
try (OutputStream os = ex.getResponseBody()) { os.write(bytes); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() { server.stop(0); }
|
||||||
|
}
|
||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.oidc.OidcConfig;
|
||||||
|
import dev.relism.flash.ext.oidc.OidcExtension;
|
||||||
|
import dev.relism.flash.extension.FlashApp;
|
||||||
|
import dev.relism.flash.extension.FlashConfiguration;
|
||||||
|
import dev.relism.flash.testing.FlashResponse;
|
||||||
|
import dev.relism.flash.testing.FlashTest;
|
||||||
|
import org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code @RolesAllowed}/{@code @ScopesAllowed} on an {@link McpTool} — see
|
||||||
|
* {@link McpOidcIntegration#compileToolPolicy}. Same real-discovery/real-JWKS/real-RS256-token
|
||||||
|
* approach as {@link McpExtensionSecurityTest}, against {@code fixtures.secured}'s tools.
|
||||||
|
*/
|
||||||
|
class McpAuthPolicyTest {
|
||||||
|
|
||||||
|
private static final String SECURED_TOOLS = "dev.relism.flash.ext.mcp.authfixtures.secured";
|
||||||
|
private static final String AUTHENTICATED_ONLY_TOOLS = "dev.relism.flash.ext.mcp.authfixtures.authenticatedonly";
|
||||||
|
|
||||||
|
private static final FakeOidcProvider provider = newProvider();
|
||||||
|
|
||||||
|
@RegisterExtension
|
||||||
|
static FlashTest secured = FlashTest.of(app -> {
|
||||||
|
app.install(new OidcExtension(OidcConfig.builder(
|
||||||
|
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
|
||||||
|
app.install(new McpExtension(McpConfig.builder("secure-server")
|
||||||
|
.toolsPackage(SECURED_TOOLS)
|
||||||
|
.security(McpSecurity.REQUIRED)
|
||||||
|
.build()));
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Tokens are audience-bound to this server, so the port has to be read back after boot. */
|
||||||
|
private static String resourceId() {
|
||||||
|
return "http://127.0.0.1:" + secured.port() + "/mcp";
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
static void closeProvider() {
|
||||||
|
provider.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tool policy ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rolesAllowed_deniesWithoutRole_allowsWithRole() throws Exception {
|
||||||
|
callTool("admin_only", provider.signToken("user-1", resourceId(), null))
|
||||||
|
.expectStatus(200)
|
||||||
|
.expectBodyContains("\"isError\":true")
|
||||||
|
.expectBodyContains("missing required role");
|
||||||
|
|
||||||
|
callTool("admin_only", provider.signToken("user-1", resourceId(), null, "admin"))
|
||||||
|
.expectStatus(200)
|
||||||
|
.expectBodyContains("\"isError\":false")
|
||||||
|
.expectBodyContains("ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void scopesAllowed_deniesWithoutScope_allowsWithScope() throws Exception {
|
||||||
|
callTool("write_only", provider.signToken("user-1", resourceId(), "read"))
|
||||||
|
.expectStatus(200)
|
||||||
|
.expectBodyContains("\"isError\":true")
|
||||||
|
.expectBodyContains("missing required scope");
|
||||||
|
|
||||||
|
callTool("write_only", provider.signToken("user-1", resourceId(), "read write"))
|
||||||
|
.expectStatus(200)
|
||||||
|
.expectBodyContains("\"isError\":false")
|
||||||
|
.expectBodyContains("written");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unannotatedTool_unaffectedByOtherToolsPolicies() throws Exception {
|
||||||
|
callTool("open", provider.signToken("user-1", resourceId(), null))
|
||||||
|
.expectStatus(200)
|
||||||
|
.expectBodyContains("\"isError\":false")
|
||||||
|
.expectBodyContains("open");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FlashResponse callTool(String toolName, String token) {
|
||||||
|
return secured.request()
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.header("Authorization", "Bearer " + token)
|
||||||
|
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\""
|
||||||
|
+ toolName + "\"}}")
|
||||||
|
.post("/mcp");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Boot-time rejection ──────────────────────────────────────────────────
|
||||||
|
// These assert that start() throws, so they build the app directly rather than through
|
||||||
|
// FlashTest — a harness whose job is to boot an app is the wrong tool for asserting that
|
||||||
|
// booting fails. Port 0 still removes the old free-port dance.
|
||||||
|
|
||||||
|
private FlashApp bootFailure;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void releaseBootFailureListener() {
|
||||||
|
if (bootFailure != null) bootFailure.stop().join();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toolAnnotated_butSecurityNone_failsAtBoot() {
|
||||||
|
bootFailure = mcpApp(SECURED_TOOLS, McpSecurity.NONE);
|
||||||
|
|
||||||
|
IllegalStateException error = assertThrows(IllegalStateException.class, bootFailure::start);
|
||||||
|
assertTrue(error.getMessage().contains("no active OAuth2 protection"), error.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bareAuthenticated_hasNoEffect_failsAtBoot() {
|
||||||
|
bootFailure = mcpApp(AUTHENTICATED_ONLY_TOOLS, McpSecurity.REQUIRED);
|
||||||
|
|
||||||
|
IllegalStateException error = assertThrows(IllegalStateException.class, bootFailure::start);
|
||||||
|
assertTrue(error.getMessage().contains("no effect"), error.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FlashApp mcpApp(String toolsPackage, McpSecurity security) {
|
||||||
|
FlashApp app = FlashApp.create(FlashConfiguration.builder()
|
||||||
|
.port(0).host("127.0.0.1").shutdownDrainTimeoutMs(250).build());
|
||||||
|
app.install(new OidcExtension(OidcConfig.builder(
|
||||||
|
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
|
||||||
|
app.install(new McpExtension(McpConfig.builder("secure-server")
|
||||||
|
.toolsPackage(toolsPackage)
|
||||||
|
.security(security)
|
||||||
|
.build()));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FakeOidcProvider newProvider() {
|
||||||
|
try {
|
||||||
|
return new FakeOidcProvider();
|
||||||
|
} catch (Exception failure) {
|
||||||
|
throw new IllegalStateException("Could not start the fake OIDC provider", failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import dev.relism.flash.testing.FlashResponse;
|
||||||
|
import dev.relism.flash.testing.FlashTest;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/** End-to-end JSON-RPC lifecycle over the real Streamable HTTP endpoint — no OAuth2 involved. */
|
||||||
|
class McpExtensionIntegrationTest {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
// Every test here is a stateless JSON-RPC call against the same server, so one boot for
|
||||||
|
// the class rather than one per test.
|
||||||
|
@RegisterExtension
|
||||||
|
static FlashTest mcp = FlashTest.of(app -> app.install(new McpExtension(
|
||||||
|
McpConfig.builder("test-server")
|
||||||
|
.version("9.9.9")
|
||||||
|
.toolsPackage("dev.relism.flash.ext.mcp.fixtures")
|
||||||
|
.security(McpSecurity.NONE)
|
||||||
|
.build())));
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void initialize_returnsProtocolVersionCapabilitiesAndServerInfo() throws Exception {
|
||||||
|
JsonNode result = call(1, "initialize", "{}").get("result");
|
||||||
|
|
||||||
|
assertTrue(result.has("protocolVersion"));
|
||||||
|
assertEquals("test-server", result.get("serverInfo").get("name").asText());
|
||||||
|
assertEquals("9.9.9", result.get("serverInfo").get("version").asText());
|
||||||
|
assertTrue(result.get("capabilities").has("tools"));
|
||||||
|
assertTrue(result.get("capabilities").has("resources"));
|
||||||
|
assertTrue(result.get("capabilities").has("prompts"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toolsList_containsRegisteredTools() throws Exception {
|
||||||
|
JsonNode tools = call(2, "tools/list", "{}").get("result").get("tools");
|
||||||
|
assertEquals(2, tools.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toolsCall_echo_returnsContent() throws Exception {
|
||||||
|
JsonNode result = call(3, "tools/call", "{\"name\":\"echo\",\"arguments\":{\"text\":\"hi there\"}}").get("result");
|
||||||
|
assertFalse(result.get("isError").asBoolean());
|
||||||
|
assertEquals("hi there", result.get("content").get(0).get("text").asText());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toolsCall_failingTool_returnsIsErrorResultNotProtocolError() throws Exception {
|
||||||
|
JsonNode response = call(4, "tools/call", "{\"name\":\"boom\",\"arguments\":{}}");
|
||||||
|
assertFalse(response.has("error"));
|
||||||
|
JsonNode result = response.get("result");
|
||||||
|
assertTrue(result.get("isError").asBoolean());
|
||||||
|
assertTrue(result.get("content").get(0).get("text").asText().contains("kaboom"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toolsCall_unknownTool_returnsJsonRpcInvalidParamsError() throws Exception {
|
||||||
|
JsonNode response = call(5, "tools/call", "{\"name\":\"nope\",\"arguments\":{}}");
|
||||||
|
assertEquals(-32602, response.get("error").get("code").asInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resourcesRead_returnsTextContents() throws Exception {
|
||||||
|
JsonNode result = call(6, "resources/read", "{\"uri\":\"greeting://hello\"}").get("result");
|
||||||
|
assertEquals("hello world", result.get("contents").get(0).get("text").asText());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void promptsGet_rendersMessage() throws Exception {
|
||||||
|
JsonNode result = call(7, "prompts/get", "{\"name\":\"summarize\",\"arguments\":{\"text\":\"foo\"}}").get("result");
|
||||||
|
assertEquals("Summarize: foo", result.get("messages").get(0).get("content").get("text").asText());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notification_returns202WithEmptyBody() {
|
||||||
|
post("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}").expectStatus(202);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void malformedJson_returns400ParseError() throws Exception {
|
||||||
|
JsonNode json = MAPPER.readTree(post("not json").expectStatus(400).body());
|
||||||
|
assertEquals(-32700, json.get("error").get("code").asInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownMethod_returnsJsonRpcMethodNotFound() throws Exception {
|
||||||
|
JsonNode response = call(8, "not/a/method", "{}");
|
||||||
|
assertEquals(-32601, response.get("error").get("code").asInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private JsonNode call(int id, String method, String paramsJson) throws Exception {
|
||||||
|
String body = "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"" + method + "\",\"params\":" + paramsJson + "}";
|
||||||
|
return MAPPER.readTree(post(body).expectStatus(200).body());
|
||||||
|
}
|
||||||
|
|
||||||
|
private FlashResponse post(String body) {
|
||||||
|
return mcp.request().json(body).post("/mcp");
|
||||||
|
}
|
||||||
|
}
|
||||||
+179
@@ -0,0 +1,179 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.oidc.OidcConfig;
|
||||||
|
import dev.relism.flash.ext.oidc.OidcExtension;
|
||||||
|
import dev.relism.flash.extension.FlashApp;
|
||||||
|
import dev.relism.flash.extension.FlashApplication;
|
||||||
|
import dev.relism.flash.extension.FlashConfiguration;
|
||||||
|
import dev.relism.flash.testing.FlashRequest;
|
||||||
|
import dev.relism.flash.testing.FlashResponse;
|
||||||
|
import dev.relism.flash.testing.FlashTest;
|
||||||
|
import org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exercises the actual OAuth2 resolution rules against a real {@code flash-ext-oidc}
|
||||||
|
* installation backed by {@link FakeOidcProvider} — real discovery, real JWKS, real RS256
|
||||||
|
* tokens — plus the fail-fast/degrade behavior when oidc is absent.
|
||||||
|
*
|
||||||
|
* <p>Four server configurations differ only in how MCP security is declared, so each gets its
|
||||||
|
* own {@link FlashTest} and they share one provider.
|
||||||
|
*/
|
||||||
|
class McpExtensionSecurityTest {
|
||||||
|
|
||||||
|
private static final String TOOLS_PACKAGE = "dev.relism.flash.ext.mcp.fixtures";
|
||||||
|
private static final String EXPLICIT_RESOURCE_ID = "https://mcp.example.com/mcp";
|
||||||
|
|
||||||
|
private static final FakeOidcProvider provider = newProvider();
|
||||||
|
|
||||||
|
/** MCP asked for AUTO security with no oidc installed — should degrade to public. */
|
||||||
|
@RegisterExtension
|
||||||
|
static FlashTest degraded = FlashTest.of(app -> app.install(new McpExtension(
|
||||||
|
McpConfig.builder("auto-server")
|
||||||
|
.toolsPackage(TOOLS_PACKAGE)
|
||||||
|
.security(McpSecurity.AUTO)
|
||||||
|
.build())));
|
||||||
|
|
||||||
|
/** REQUIRED with oidc, resource identifier derived from the request. */
|
||||||
|
@RegisterExtension
|
||||||
|
static FlashTest secured = FlashTest.of(securedApp(null, null));
|
||||||
|
|
||||||
|
/** REQUIRED with oidc and an explicitly declared resource identifier. */
|
||||||
|
@RegisterExtension
|
||||||
|
static FlashTest securedWithResourceId = FlashTest.of(securedApp(EXPLICIT_RESOURCE_ID, null));
|
||||||
|
|
||||||
|
/** REQUIRED with oidc and advertised scopes. */
|
||||||
|
@RegisterExtension
|
||||||
|
static FlashTest securedWithScopes =
|
||||||
|
FlashTest.of(securedApp(null, new String[] {"openid", "profile", "email"}));
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
static void closeProvider() {
|
||||||
|
provider.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── No oidc installed ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void required_withoutOidc_throwsAtBoot() {
|
||||||
|
// Asserting that boot fails, so this one builds its app directly rather than through
|
||||||
|
// the harness; port(0) still removes the old free-port dance.
|
||||||
|
FlashApp app = FlashApp.create(FlashConfiguration.builder()
|
||||||
|
.port(0).host("127.0.0.1").shutdownDrainTimeoutMs(250).build());
|
||||||
|
app.install(new McpExtension(McpConfig.builder("secure-server")
|
||||||
|
.toolsPackage(TOOLS_PACKAGE)
|
||||||
|
.security(McpSecurity.REQUIRED)
|
||||||
|
.build()));
|
||||||
|
try {
|
||||||
|
assertThrows(IllegalStateException.class, app::start);
|
||||||
|
} finally {
|
||||||
|
app.stop().join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void auto_withoutOidc_degradesToPublic() {
|
||||||
|
post(degraded, initializeBody(), null).expectStatus(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── REQUIRED with oidc ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void required_withOidc_rejectsMissingToken() {
|
||||||
|
post(secured, initializeBody(), null).expectStatus(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void required_withOidc_rejectsWrongAudience() throws Exception {
|
||||||
|
String token = provider.signToken("user-1", "https://someone-else.example.com/resource");
|
||||||
|
|
||||||
|
post(securedWithResourceId, initializeBody(), token).expectStatus(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void required_withOidc_acceptsValidAudience() throws Exception {
|
||||||
|
String token = provider.signToken("user-1", EXPLICIT_RESOURCE_ID);
|
||||||
|
|
||||||
|
post(securedWithResourceId, initializeBody(), token)
|
||||||
|
.expectStatus(200)
|
||||||
|
.expectBodyContains("\"protocolVersion\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void required_withOidc_noExplicitResourceIdentifier_derivesFromRequestAndEnforcesAudience() throws Exception {
|
||||||
|
String derivedResourceId = "http://127.0.0.1:" + secured.port() + "/mcp";
|
||||||
|
|
||||||
|
post(secured, initializeBody(), provider.signToken("user-1", derivedResourceId))
|
||||||
|
.expectStatus(200);
|
||||||
|
post(secured, initializeBody(), provider.signToken("user-1", "https://someone-else.example.com/resource"))
|
||||||
|
.expectStatus(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void required_withOidc_missingToken_challengeIncludesResourceMetadata() {
|
||||||
|
FlashResponse response = post(secured, initializeBody(), null).expectStatus(401);
|
||||||
|
|
||||||
|
String challenge = response.header("WWW-Authenticate");
|
||||||
|
assertTrue(challenge != null && challenge.contains("resource_metadata=\"http://127.0.0.1:"
|
||||||
|
+ secured.port() + "/.well-known/oauth-protected-resource/mcp\""),
|
||||||
|
"WWW-Authenticate: " + challenge);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Protected resource metadata ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void required_withOidc_noExplicitConfig_publishesProtectedResourceMetadata() {
|
||||||
|
FlashResponse response = secured.get("/.well-known/oauth-protected-resource/mcp")
|
||||||
|
.expectStatus(200)
|
||||||
|
.expectBodyContains("\"resource\":\"http://127.0.0.1:" + secured.port() + "/mcp\"")
|
||||||
|
.expectBodyContains("\"authorization_servers\":[\"" + provider.issuer() + "\"]");
|
||||||
|
|
||||||
|
assertTrue(!response.body().contains("scopes_supported"),
|
||||||
|
"scopes_supported must be omitted when unset: " + response.body());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void scopesSupported_published_inProtectedResourceMetadata() {
|
||||||
|
securedWithScopes.get("/.well-known/oauth-protected-resource/mcp")
|
||||||
|
.expectStatus(200)
|
||||||
|
.expectBodyContains("\"scopes_supported\":[\"openid\",\"profile\",\"email\"]");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static FlashApplication securedApp(String resourceIdentifier, String[] scopesSupported) {
|
||||||
|
return app -> {
|
||||||
|
app.install(new OidcExtension(OidcConfig.builder(
|
||||||
|
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
|
||||||
|
|
||||||
|
McpConfig.Builder mcp = McpConfig.builder("secure-server")
|
||||||
|
.toolsPackage(TOOLS_PACKAGE)
|
||||||
|
.security(McpSecurity.REQUIRED);
|
||||||
|
if (resourceIdentifier != null) mcp.resourceIdentifier(resourceIdentifier);
|
||||||
|
if (scopesSupported != null) mcp.scopesSupported(scopesSupported);
|
||||||
|
app.install(new McpExtension(mcp.build()));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FlashResponse post(FlashTest server, String body, String bearerToken) {
|
||||||
|
FlashRequest request = server.request().header("Accept", "application/json").json(body);
|
||||||
|
if (bearerToken != null) request.header("Authorization", "Bearer " + bearerToken);
|
||||||
|
return request.post("/mcp");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String initializeBody() {
|
||||||
|
return "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FakeOidcProvider newProvider() {
|
||||||
|
try {
|
||||||
|
return new FakeOidcProvider();
|
||||||
|
} catch (Exception failure) {
|
||||||
|
throw new IllegalStateException("Could not start the fake OIDC provider", failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import dev.relism.flash.exceptions.InitializationException;
|
||||||
|
import dev.relism.flash.extension.FlashContext;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class McpRegistryTest {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void scan_findsAndPrecompilesToolsResourcesPrompts() throws Exception {
|
||||||
|
McpRegistry registry = McpRegistry.scan("dev.relism.flash.ext.mcp.fixtures", new FlashContext(), false, "realm_access.roles");
|
||||||
|
|
||||||
|
assertTrue(registry.hasTools());
|
||||||
|
assertTrue(registry.hasResources());
|
||||||
|
assertTrue(registry.hasPrompts());
|
||||||
|
|
||||||
|
JsonNode tools = MAPPER.readTree(registry.toolsListJson());
|
||||||
|
assertEquals(2, tools.size()); // echo + boom
|
||||||
|
JsonNode echo = findByField(tools, "name", "echo");
|
||||||
|
assertEquals("Echoes the given text", echo.get("description").asText());
|
||||||
|
assertEquals("object", echo.get("inputSchema").get("type").asText());
|
||||||
|
assertEquals("string", echo.get("inputSchema").get("properties").get("text").get("type").asText());
|
||||||
|
assertEquals("text", echo.get("inputSchema").get("required").get(0).asText());
|
||||||
|
|
||||||
|
JsonNode resources = MAPPER.readTree(registry.resourcesListJson());
|
||||||
|
assertEquals(1, resources.size());
|
||||||
|
assertEquals("greeting://hello", resources.get(0).get("uri").asText());
|
||||||
|
|
||||||
|
JsonNode prompts = MAPPER.readTree(registry.promptsListJson());
|
||||||
|
assertEquals(1, prompts.size());
|
||||||
|
assertEquals("summarize", prompts.get(0).get("name").asText());
|
||||||
|
assertTrue(prompts.get(0).get("arguments").get(0).get("required").asBoolean());
|
||||||
|
|
||||||
|
assertEquals("echo", registry.tool("echo").name());
|
||||||
|
assertEquals("greeting://hello", registry.resource("greeting://hello").uri());
|
||||||
|
assertEquals("summarize", registry.prompt("summarize").name());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void scan_emptyPackage_throwsInitializationException() {
|
||||||
|
assertThrows(InitializationException.class,
|
||||||
|
() -> McpRegistry.scan("dev.relism.flash.ext.mcp.doesnotexist", new FlashContext(), false, "realm_access.roles"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonNode findByField(JsonNode array, String field, String value) {
|
||||||
|
for (JsonNode n : array) if (value.equals(n.path(field).asText())) return n;
|
||||||
|
throw new AssertionError("No entry with " + field + "=" + value);
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class ToolArgumentsTest {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
private ToolArguments of(String json) throws Exception {
|
||||||
|
return new ToolArguments(MAPPER.readTree(json));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readsTypedFields() throws Exception {
|
||||||
|
ToolArguments args = of("{\"city\":\"Rome\",\"days\":3,\"temp\":21.5,\"metric\":true}");
|
||||||
|
|
||||||
|
assertEquals("Rome", args.getString("city"));
|
||||||
|
assertEquals(3, args.getInt("days"));
|
||||||
|
assertEquals(21.5, args.getDouble("temp"));
|
||||||
|
assertTrue(args.getBoolean("metric"));
|
||||||
|
assertTrue(args.has("city"));
|
||||||
|
assertFalse(args.has("missing"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingFieldsFallBackToDefaults() throws Exception {
|
||||||
|
ToolArguments args = of("{}");
|
||||||
|
|
||||||
|
assertNull(args.getString("missing"));
|
||||||
|
assertEquals("fallback", args.getString("missing", "fallback"));
|
||||||
|
assertEquals(0, args.getInt("missing"));
|
||||||
|
assertEquals(42, args.getInt("missing", 42));
|
||||||
|
assertFalse(args.getBoolean("missing"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullArgumentsNodeBehavesAsEmpty() {
|
||||||
|
ToolArguments args = new ToolArguments(null);
|
||||||
|
assertFalse(args.has("anything"));
|
||||||
|
assertNull(args.getString("anything"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package dev.relism.flash.ext.mcp.authfixtures.authenticatedonly;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.mcp.McpTool;
|
||||||
|
import dev.relism.flash.ext.mcp.TextContent;
|
||||||
|
import dev.relism.flash.ext.mcp.Tool;
|
||||||
|
import dev.relism.flash.ext.mcp.ToolArguments;
|
||||||
|
import dev.relism.flash.ext.mcp.ToolResponse;
|
||||||
|
import dev.relism.flash.ext.oidc.Authenticated;
|
||||||
|
|
||||||
|
/** Deliberately misconfigured fixture: bare @Authenticated has no effect on an McpTool — see
|
||||||
|
* McpOidcIntegration#compileToolPolicy. Boot must fail with a clear message, not silently no-op. */
|
||||||
|
@Tool(name = "pointless", description = "Exists only to prove @Authenticated alone fails boot")
|
||||||
|
@Authenticated
|
||||||
|
public class PointlessAuthTool extends McpTool {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ToolResponse call(ToolArguments args) {
|
||||||
|
return ToolResponse.success(new TextContent("unreachable"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package dev.relism.flash.ext.mcp.authfixtures.secured;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.mcp.McpTool;
|
||||||
|
import dev.relism.flash.ext.mcp.TextContent;
|
||||||
|
import dev.relism.flash.ext.mcp.Tool;
|
||||||
|
import dev.relism.flash.ext.mcp.ToolArguments;
|
||||||
|
import dev.relism.flash.ext.mcp.ToolResponse;
|
||||||
|
import dev.relism.flash.ext.oidc.RolesAllowed;
|
||||||
|
|
||||||
|
@Tool(name = "admin_only", description = "Only callable with the admin role")
|
||||||
|
@RolesAllowed("admin")
|
||||||
|
public class AdminOnlyTool extends McpTool {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ToolResponse call(ToolArguments args) {
|
||||||
|
return ToolResponse.success(new TextContent("ok"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package dev.relism.flash.ext.mcp.authfixtures.secured;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.mcp.McpTool;
|
||||||
|
import dev.relism.flash.ext.mcp.TextContent;
|
||||||
|
import dev.relism.flash.ext.mcp.Tool;
|
||||||
|
import dev.relism.flash.ext.mcp.ToolArguments;
|
||||||
|
import dev.relism.flash.ext.mcp.ToolResponse;
|
||||||
|
|
||||||
|
/** No role/scope annotation — any authenticated caller, confirms unrelated tools are unaffected. */
|
||||||
|
@Tool(name = "open", description = "Callable by anyone already authenticated")
|
||||||
|
public class OpenTool extends McpTool {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ToolResponse call(ToolArguments args) {
|
||||||
|
return ToolResponse.success(new TextContent("open"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package dev.relism.flash.ext.mcp.authfixtures.secured;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.mcp.McpTool;
|
||||||
|
import dev.relism.flash.ext.mcp.TextContent;
|
||||||
|
import dev.relism.flash.ext.mcp.Tool;
|
||||||
|
import dev.relism.flash.ext.mcp.ToolArguments;
|
||||||
|
import dev.relism.flash.ext.mcp.ToolResponse;
|
||||||
|
import dev.relism.flash.ext.oidc.ScopesAllowed;
|
||||||
|
|
||||||
|
@Tool(name = "write_only", description = "Only callable with the write scope")
|
||||||
|
@ScopesAllowed("write")
|
||||||
|
public class WriteScopeTool extends McpTool {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ToolResponse call(ToolArguments args) {
|
||||||
|
return ToolResponse.success(new TextContent("written"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package dev.relism.flash.ext.mcp.fixtures;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.mcp.McpTool;
|
||||||
|
import dev.relism.flash.ext.mcp.TextContent;
|
||||||
|
import dev.relism.flash.ext.mcp.Tool;
|
||||||
|
import dev.relism.flash.ext.mcp.ToolArg;
|
||||||
|
import dev.relism.flash.ext.mcp.ToolArguments;
|
||||||
|
import dev.relism.flash.ext.mcp.ToolResponse;
|
||||||
|
|
||||||
|
@Tool(name = "echo", description = "Echoes the given text",
|
||||||
|
args = @ToolArg(name = "text", description = "Text to echo", required = true))
|
||||||
|
public class EchoTool extends McpTool {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ToolResponse call(ToolArguments args) {
|
||||||
|
return ToolResponse.success(new TextContent(args.getString("text")));
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user