Compare commits
87
Commits
v2.0.0
..
829b9bf348
@@ -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
+2
-2
@@ -17,8 +17,8 @@
|
|||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/resources" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/resources" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/resources" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/resources" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-auth-oidc/src/main/java" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/resources" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-auth-oidc/src/main/resources" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/resources" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/resources" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java" charset="UTF-8" />
|
||||||
|
|||||||
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,10 @@ 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-scheduler`, `ext-data-core`, `ext-data-jdbc`,
|
||||||
|
`ext-data-hibernate`, `ext-cache-core`, `ext-cache-caffeine`, `release`, `deps`, `ci`.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
```
|
```
|
||||||
@@ -85,6 +86,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,26 @@
|
|||||||
# 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-auth-core` | Authentication seam + role/scope authorization |
|
||||||
|
| `flash-extensions/flash-ext-auth-oidc` | OIDC Authorization Code + PKCE flow |
|
||||||
|
| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, optional OAuth2 via flash-ext-auth-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) |
|
| `flash-extensions/flash-ext-validation` | Request validation — jakarta constraints, compiled once per type |
|
||||||
|
| `flash-extensions/flash-ext-scheduler` | Interval and cron background jobs on virtual threads |
|
||||||
|
| `flash-extensions/flash-ext-cache-core` | Caching contract — `Cache`, `CacheManager`, `CacheSpec` |
|
||||||
|
| `flash-extensions/flash-ext-cache-caffeine` | In-process cache backed by Caffeine |
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -57,31 +64,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 +97,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,31 +125,37 @@ 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();
|
||||||
```
|
```
|
||||||
|
|
||||||
See extension-specific READMEs for full details:
|
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-auth-core`](flash-extensions/flash-ext-auth-core/docs/README.md)
|
||||||
|
- [`flash-ext-auth-oidc`](flash-extensions/flash-ext-auth-oidc/docs/README.md)
|
||||||
|
- [`flash-ext-mcp`](flash-extensions/flash-ext-mcp/docs/README.md)
|
||||||
- [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/README.md)
|
- [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/README.md)
|
||||||
- [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md)
|
- [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md)
|
||||||
|
- [`flash-ext-validation`](flash-extensions/flash-ext-validation/docs/README.md)
|
||||||
|
- [`flash-ext-scheduler`](flash-extensions/flash-ext-scheduler/docs/README.md)
|
||||||
|
- [`flash-ext-cache-caffeine`](flash-extensions/flash-ext-cache-caffeine/docs/README.md)
|
||||||
|
- [`flash-testing`](flash-testing/docs/README.md)
|
||||||
|
|
||||||
## Error handlers
|
## Error handlers
|
||||||
|
|
||||||
@@ -163,24 +175,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 +534,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,105 @@
|
|||||||
|
# flash-ext-auth-core
|
||||||
|
|
||||||
|
Authorization, and the plumbing that carries a caller's identity through a request. It does not
|
||||||
|
know how anyone signed in — that is a `CredentialSource`, and `flash-ext-auth-oidc` ships the
|
||||||
|
OpenID Connect one.
|
||||||
|
|
||||||
|
The split follows the same shape as `flash-ext-cache-core`/`-caffeine` and
|
||||||
|
`flash-ext-data-core`/`-hibernate`: the abstract half here, the implementations beside it.
|
||||||
|
|
||||||
|
## The model
|
||||||
|
|
||||||
|
```
|
||||||
|
request ──► CredentialSource.authenticate(req, res) ──► claims
|
||||||
|
│
|
||||||
|
ClaimsHolder.set (this module only)
|
||||||
|
│
|
||||||
|
AuthMiddleware matches roles / scopes
|
||||||
|
│
|
||||||
|
handler
|
||||||
|
```
|
||||||
|
|
||||||
|
| Type | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `CredentialSource` | Turns what a request carries into claims, or rejects it. One per mechanism. |
|
||||||
|
| `AuthMiddleware` | Publishes the claims, enforces `@RolesAllowed`/`@ScopesAllowed`, clears up. |
|
||||||
|
| `ClaimsHolder` | The current request's claims. Read from anywhere; written only from here. |
|
||||||
|
| `Claims` | Typed view over a claims map — `sub()`, `email()`, `roles(path)`, `scopes()`. |
|
||||||
|
| `AuthPolicy` | What a handler's annotations compiled to, resolved once at boot. |
|
||||||
|
| `Session`, `SessionStore` | Server-side sessions for sources that keep them. |
|
||||||
|
|
||||||
|
Nothing outside this module can write `ClaimsHolder`. A source *returns* claims and the middleware
|
||||||
|
publishes them, so no code can put claims on a request that did not carry them.
|
||||||
|
|
||||||
|
## Using it
|
||||||
|
|
||||||
|
You rarely install this module directly — an extension that contributes a source does it for you:
|
||||||
|
|
||||||
|
```java
|
||||||
|
// inside your extension's configure(...)
|
||||||
|
AuthMiddleware auth = AuthMiddleware.install(ctx, AuthConfig.builder()
|
||||||
|
.rolesClaimPath("realm_access.roles")
|
||||||
|
.scopeClaimPaths("scope,scp")
|
||||||
|
.build(), mySource);
|
||||||
|
```
|
||||||
|
|
||||||
|
`install` publishes the middleware in the context and registers the annotation processor, so every
|
||||||
|
scanned handler carrying an auth annotation is mounted behind it. See
|
||||||
|
[`credential-sources.md`](credential-sources.md) to write a source of your own.
|
||||||
|
|
||||||
|
On lambda routes, take the middleware out of the context:
|
||||||
|
|
||||||
|
```java
|
||||||
|
AuthMiddleware auth = app.ctx().require(AuthMiddleware.class);
|
||||||
|
|
||||||
|
app.get("/api/me", (req, res) -> ClaimsHolder.claim("sub"), auth.protect());
|
||||||
|
app.get("/", homeHandler, auth.optional());
|
||||||
|
app.delete("/admin/users/{id}", deleteHandler, auth.requireRole("admin"));
|
||||||
|
app.post("/orders", createOrder, auth.requireScopes("orders:write"));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Annotations
|
||||||
|
|
||||||
|
On a scanned handler class, and mounted automatically:
|
||||||
|
|
||||||
|
| Annotation | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `@Authenticated` | Any accepted credential. No role check. |
|
||||||
|
| `@Authenticated(optional = true)` | Never rejects; publishes claims when there are some. |
|
||||||
|
| `@RolesAllowed({"a","b"})` | Authenticated **and** holding at least one of the roles. |
|
||||||
|
| `@ScopesAllowed({"x","y"})` | Authenticated **and** holding all of the scopes. |
|
||||||
|
| `@ScopesAllowed(value = {...}, match = ANY)` | …at least one of them. |
|
||||||
|
|
||||||
|
`@Authenticated(optional = true)` cannot be combined with a role or scope requirement — asking for
|
||||||
|
a role on a route that admits anonymous callers is a contradiction, and it fails at boot rather
|
||||||
|
than at 3am.
|
||||||
|
|
||||||
|
## Where roles and scopes are read from
|
||||||
|
|
||||||
|
`AuthConfig` names the claim paths, because every provider spells them differently:
|
||||||
|
|
||||||
|
| | Default | Common alternatives |
|
||||||
|
|---|---|---|
|
||||||
|
| `rolesClaimPath` | `roles` | `realm_access.roles` (Keycloak), `groups` (Authelia) |
|
||||||
|
| `scopeClaimPaths` | `scope,scp` | plus e.g. `permissions.scopes` |
|
||||||
|
|
||||||
|
Paths are dot-separated and walk nested maps. Scope paths are a comma-separated list tried in
|
||||||
|
order, so a token that puts scopes in `scp` and a legacy one that uses `scope` both work.
|
||||||
|
|
||||||
|
Matching is deliberate about a distinction that bites otherwise:
|
||||||
|
|
||||||
|
- a **string** claim is split on spaces, tabs, newlines and commas — `"openid orders:read"` is two
|
||||||
|
scopes;
|
||||||
|
- a **list** claim is compared entry by entry, whole and trimmed — `["a b"]` is one role named
|
||||||
|
`a b`, not two.
|
||||||
|
|
||||||
|
Prefix matches never count: `administrator` does not satisfy `admin`.
|
||||||
|
|
||||||
|
## Ordering around authentication
|
||||||
|
|
||||||
|
`AuthMiddleware.POLICY` is the boot-time key the annotation-driven node mounts under. An extension
|
||||||
|
contributing its own middleware can order itself against it:
|
||||||
|
|
||||||
|
```java
|
||||||
|
MiddlewareNode.of(MY_KEY, myMiddleware).afterIfPresent(AuthMiddleware.POLICY);
|
||||||
|
```
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Writing a credential source
|
||||||
|
|
||||||
|
A `CredentialSource` is the only thing that stands between a request and its claims. Everything
|
||||||
|
else in this module — annotations, policy, matching, the holder — works the same regardless of
|
||||||
|
which one is installed.
|
||||||
|
|
||||||
|
```java
|
||||||
|
public interface CredentialSource {
|
||||||
|
Map<String, Object> authenticate(Request req, Response res);
|
||||||
|
Map<String, Object> peek(Request req);
|
||||||
|
default String insufficientScopeChallenge(String[] requiredScopes) { return null; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## `authenticate` has three outcomes, and the last two are not the same
|
||||||
|
|
||||||
|
| Return | Means | The middleware then |
|
||||||
|
|---|---|---|
|
||||||
|
| claims | a valid credential was presented | publishes them and calls the handler |
|
||||||
|
| `null` | **no** credential, and the source has already answered the request | stops, writes nothing more |
|
||||||
|
| throws `HttpException` | a credential **was** presented and is invalid | propagates it |
|
||||||
|
|
||||||
|
Flattening the last two is the single easiest way to get this wrong. "No session, send the browser
|
||||||
|
to the sign-in page" and "this token is forged" are different answers, and a caller can tell:
|
||||||
|
the first is a `302` to a login screen, the second a `401` the client must not retry blindly.
|
||||||
|
|
||||||
|
A source that returns `null` owns the response by then — it has redirected, or written a `401` with
|
||||||
|
its own `WWW-Authenticate` header. A source that throws sets any challenge header it owes *before*
|
||||||
|
throwing, because the exception unwinds past the middleware.
|
||||||
|
|
||||||
|
`peek` is the same resolution with every rejection removed: no throwing, no redirecting, `null`
|
||||||
|
when there is nothing valid. It backs `@Authenticated(optional = true)`, where an anonymous caller
|
||||||
|
is a normal outcome. Never make `peek` refresh state that `authenticate` would not have.
|
||||||
|
|
||||||
|
## A minimal source
|
||||||
|
|
||||||
|
```java
|
||||||
|
public final class ApiKeySource implements CredentialSource {
|
||||||
|
|
||||||
|
private final Map<String, Map<String, Object>> keys; // key -> claims
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> authenticate(Request req, Response res) {
|
||||||
|
String key = req.header("X-Api-Key");
|
||||||
|
if (key == null) {
|
||||||
|
res.header("WWW-Authenticate", "ApiKey realm=\"api\"");
|
||||||
|
throw HttpException.unauthorized();
|
||||||
|
}
|
||||||
|
Map<String, Object> claims = keys.get(key);
|
||||||
|
if (claims == null) throw HttpException.unauthorized(); // presented and wrong
|
||||||
|
return claims;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> peek(Request req) {
|
||||||
|
String key = req.header("X-Api-Key");
|
||||||
|
return key != null ? keys.get(key) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This one never returns `null` from `authenticate` — it has no sign-in flow to redirect into, so
|
||||||
|
"absent" and "invalid" both mean `401`. That is a legitimate shape; the three outcomes are what
|
||||||
|
the interface *allows*, not a checklist.
|
||||||
|
|
||||||
|
## Claims are yours to shape
|
||||||
|
|
||||||
|
The claims map is whatever your mechanism produces. `Claims` reads a few conventional keys —
|
||||||
|
`sub`, `email`, `name`, `preferred_username` — so populating those makes your source work with
|
||||||
|
code written against any other. Roles and scopes are read from wherever `AuthConfig` points, so
|
||||||
|
they can live under any key you like as long as the two agree.
|
||||||
|
|
||||||
|
## Installing it
|
||||||
|
|
||||||
|
```java
|
||||||
|
public final class ApiKeyExtension implements FlashExtension {
|
||||||
|
@Override
|
||||||
|
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
|
ctx.provide(ApiKeySource.class, source);
|
||||||
|
AuthMiddleware.install(ctx, AuthConfig.builder()
|
||||||
|
.rolesClaimPath("roles")
|
||||||
|
.build(), source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`AuthMiddleware.install` also registers the annotation processor, so scanned handlers carrying
|
||||||
|
`@Authenticated` and friends are mounted behind your source with nothing further to do.
|
||||||
|
|
||||||
|
## One source at a time
|
||||||
|
|
||||||
|
`AuthMiddleware` is published in the context under its own type, so installing two extensions that
|
||||||
|
each call `install` leaves the last one winning — quietly. If an app genuinely needs to accept two
|
||||||
|
kinds of credential, that is one source that tries both, not two sources: the order they are tried
|
||||||
|
in, and what happens when the first rejects, are decisions that have to live somewhere explicit.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Sessions
|
||||||
|
|
||||||
|
A `Session` is what a credential source keeps server-side between requests, looked up by a cookie.
|
||||||
|
Core owns the container; what goes in it is the source's business.
|
||||||
|
|
||||||
|
```java
|
||||||
|
public final class Session {
|
||||||
|
String id();
|
||||||
|
Map<String, Object> claims();
|
||||||
|
Instant expiresAt();
|
||||||
|
Map<String, Object> attributes();
|
||||||
|
boolean isExpired();
|
||||||
|
Object attribute(String key);
|
||||||
|
String attributeAsString(String key);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why it expires early
|
||||||
|
|
||||||
|
`isExpired()` returns true **30 seconds before** `expiresAt`. Without that window a session can
|
||||||
|
pass the check at the top of a request and be dead by the time the handler uses it — a class of
|
||||||
|
failure that reproduces once a day and never in a test. Renewal is therefore always slightly
|
||||||
|
premature, on purpose.
|
||||||
|
|
||||||
|
## Attributes
|
||||||
|
|
||||||
|
`attributes()` is opaque to this module. `flash-ext-auth-oidc` keeps its access, id and refresh
|
||||||
|
tokens there under its own keys, which is what lets renewal stay entirely inside that extension
|
||||||
|
while the session itself carries no OAuth2 vocabulary.
|
||||||
|
|
||||||
|
Store what your source needs to renew or revoke, and nothing a handler should be reading — handlers
|
||||||
|
read `claims()`.
|
||||||
|
|
||||||
|
## The store
|
||||||
|
|
||||||
|
```java
|
||||||
|
public interface SessionStore {
|
||||||
|
void save(Session session);
|
||||||
|
Optional<Session> find(String sessionId);
|
||||||
|
void delete(String sessionId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`InMemorySessionStore` is the default: a `ConcurrentHashMap`, fine for a single instance, and it
|
||||||
|
loses every session on restart. Supply your own for Redis or JDBC when sessions have to survive a
|
||||||
|
deploy or be shared across nodes.
|
||||||
|
|
||||||
|
Sessions are immutable. Renewing one builds a new instance with the same `id()` and `save`s it
|
||||||
|
over the old — there is no mutate-in-place path, so a store can cache or serialise freely.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-extensions</artifactId>
|
||||||
|
<version>2.1.0-SNAPSHOT</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>flash-ext-auth-core</artifactId>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where authorization reads its inputs from. Deliberately small: everything about *obtaining* a
|
||||||
|
* credential belongs to the {@link CredentialSource} that produced it, and everything about
|
||||||
|
* *checking* one is right here.
|
||||||
|
*
|
||||||
|
* <p>Defaults are the generic spelling, not any one provider's. A source that knows better —
|
||||||
|
* {@code flash-ext-auth-oidc} defaults roles to Keycloak's {@code realm_access.roles} — builds
|
||||||
|
* its own {@code AuthConfig} with the paths its provider actually uses.
|
||||||
|
*/
|
||||||
|
public final class AuthConfig {
|
||||||
|
|
||||||
|
private final String rolesClaimPath;
|
||||||
|
private final String scopeClaimPaths;
|
||||||
|
|
||||||
|
private AuthConfig(Builder b) {
|
||||||
|
this.rolesClaimPath = b.rolesClaimPath;
|
||||||
|
this.scopeClaimPaths = b.scopeClaimPaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dot-separated path to the roles list in the claims (default: {@code roles}). */
|
||||||
|
public String rolesClaimPath() { return rolesClaimPath; }
|
||||||
|
|
||||||
|
/** Comma-separated claim paths scopes are read from, in order (default: {@code scope,scp}). */
|
||||||
|
public String scopeClaimPaths() { return scopeClaimPaths; }
|
||||||
|
|
||||||
|
public static Builder builder() { return new Builder(); }
|
||||||
|
|
||||||
|
public static final class Builder {
|
||||||
|
private String rolesClaimPath = "roles";
|
||||||
|
private String scopeClaimPaths = "scope,scp";
|
||||||
|
|
||||||
|
private Builder() {}
|
||||||
|
|
||||||
|
/** Dot-separated path to the roles list — e.g. {@code realm_access.roles}, {@code groups}. */
|
||||||
|
public Builder rolesClaimPath(String path) { this.rolesClaimPath = path; return this; }
|
||||||
|
/** Comma-separated claim paths scopes are read from, tried in order. */
|
||||||
|
public Builder scopeClaimPaths(String paths) { this.scopeClaimPaths = paths; return this; }
|
||||||
|
|
||||||
|
public AuthConfig build() { return new AuthConfig(this); }
|
||||||
|
}
|
||||||
|
}
|
||||||
+309
@@ -0,0 +1,309 @@
|
|||||||
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
|
import dev.relism.flash.exceptions.HttpException;
|
||||||
|
import dev.relism.flash.extension.FlashContext;
|
||||||
|
import dev.relism.flash.models.Request;
|
||||||
|
import dev.relism.flash.models.Response;
|
||||||
|
import dev.relism.flash.routing.Middleware;
|
||||||
|
import dev.relism.flash.routing.MiddlewareKey;
|
||||||
|
import dev.relism.flash.routing.MiddlewareNode;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns claims into a yes or a no. Exposed in the {@link FlashContext} for manual use on lambda
|
||||||
|
* routes, and injected automatically for handlers annotated with {@link Authenticated},
|
||||||
|
* {@link RolesAllowed} or {@link ScopesAllowed}.
|
||||||
|
*
|
||||||
|
* <p>It knows nothing about how the caller proved who they are — that is the
|
||||||
|
* {@link CredentialSource} it is built with. What lives here is the half that is the same for
|
||||||
|
* every mechanism: publish the claims for the request, match roles and scopes against them, clear
|
||||||
|
* up afterwards.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* AuthMiddleware auth = app.ctx().require(AuthMiddleware.class);
|
||||||
|
* app.get("/api/me", (req, res) -> ClaimsHolder.claim("sub"), auth.protect());
|
||||||
|
* app.delete("/admin/users/{id}", handler, auth.requireRole("admin"));
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
public class AuthMiddleware {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot-time identity of the node annotation-driven authorization mounts under. Public so an
|
||||||
|
* extension that contributes its own middleware can order itself around authentication —
|
||||||
|
* {@code MiddlewareNode.of(...).afterIfPresent(AuthMiddleware.POLICY)}.
|
||||||
|
*/
|
||||||
|
public static final MiddlewareKey POLICY = MiddlewareKey.of("flash.auth.policy");
|
||||||
|
|
||||||
|
private final AuthConfig config;
|
||||||
|
private final CredentialSource source;
|
||||||
|
private final String[] roleClaimPathParts;
|
||||||
|
private final String[][] scopeClaimPathParts;
|
||||||
|
|
||||||
|
public AuthMiddleware(AuthConfig config, CredentialSource source) {
|
||||||
|
this.config = config;
|
||||||
|
this.source = source;
|
||||||
|
this.roleClaimPathParts = splitClaimPath(config.rolesClaimPath());
|
||||||
|
this.scopeClaimPathParts = splitClaimPaths(config.scopeClaimPaths());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the middleware for {@code source}, publishes it in the context and registers the
|
||||||
|
* annotation processor that mounts {@link Authenticated}, {@link RolesAllowed} and
|
||||||
|
* {@link ScopesAllowed} on scanned handlers.
|
||||||
|
*
|
||||||
|
* <p>Every extension that contributes a {@link CredentialSource} calls this rather than
|
||||||
|
* repeating the wiring — the processor and the {@link #POLICY} key belong to one place.
|
||||||
|
*/
|
||||||
|
public static AuthMiddleware install(FlashContext ctx, AuthConfig config, CredentialSource source) {
|
||||||
|
AuthMiddleware middleware = new AuthMiddleware(config, source);
|
||||||
|
ctx.provide(AuthMiddleware.class, middleware);
|
||||||
|
ctx.addAnnotationProcessor(handlerClass -> {
|
||||||
|
AuthPolicy policy = AuthPolicy.compileFromAnnotations(handlerClass);
|
||||||
|
return policy != null
|
||||||
|
? List.of(MiddlewareNode.of(POLICY, middleware.authorize(policy)))
|
||||||
|
: List.of();
|
||||||
|
});
|
||||||
|
return middleware;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Public API -----------------------------------------------------------
|
||||||
|
|
||||||
|
/** The single configured claim path used by every transport for role checks. */
|
||||||
|
public String rolesClaimPath() { return config.rolesClaimPath(); }
|
||||||
|
|
||||||
|
/** The source this middleware authenticates with. */
|
||||||
|
public CredentialSource source() { return source; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same authorization rules against a different credential source. Used where one route
|
||||||
|
* needs a variant of an installed source — {@code flash-ext-mcp} protects {@code /mcp} with an
|
||||||
|
* OIDC source whose challenges carry RFC 9728 resource metadata, while every other route keeps
|
||||||
|
* the plain one.
|
||||||
|
*/
|
||||||
|
public AuthMiddleware withSource(CredentialSource source) {
|
||||||
|
return new AuthMiddleware(config, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejects the request unless the caller is authenticated. How it is rejected — a 401 with a
|
||||||
|
* challenge, a redirect into a sign-in flow — is the source's decision, not this one's.
|
||||||
|
*/
|
||||||
|
public Middleware protect() {
|
||||||
|
return next -> (req, res) -> {
|
||||||
|
Map<String, Object> claims = source.authenticate(req, res);
|
||||||
|
if (claims == null) return null; // the source already answered the request
|
||||||
|
ClaimsHolder.set(claims);
|
||||||
|
try {
|
||||||
|
return next.handle(req, res);
|
||||||
|
} finally {
|
||||||
|
ClaimsHolder.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes claims when the caller happens to be authenticated and never rejects anyone. Use
|
||||||
|
* it on public routes that personalise their response for signed-in callers.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* app.get("/", handler, auth.optional());
|
||||||
|
* // Inside handler: ClaimsHolder.current() is non-null iff the caller is signed in.
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
public Middleware optional() {
|
||||||
|
return next -> (req, res) -> {
|
||||||
|
Map<String, Object> claims = source.peek(req);
|
||||||
|
if (claims != null) ClaimsHolder.set(claims);
|
||||||
|
try {
|
||||||
|
return next.handle(req, res);
|
||||||
|
} finally {
|
||||||
|
ClaimsHolder.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a policy compiled once at boot from a handler's annotations. This is the path
|
||||||
|
* annotation-driven mounting takes.
|
||||||
|
*/
|
||||||
|
public Middleware authorize(AuthPolicy policy) {
|
||||||
|
if (policy.optionalAuth()) return optional();
|
||||||
|
return next -> (req, res) -> {
|
||||||
|
Map<String, Object> claims = source.authenticate(req, res);
|
||||||
|
if (claims == null) return null;
|
||||||
|
enforcePolicy(claims, policy, res);
|
||||||
|
ClaimsHolder.set(claims);
|
||||||
|
try {
|
||||||
|
return next.handle(req, res);
|
||||||
|
} finally {
|
||||||
|
ClaimsHolder.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@link #protect()} plus at least one of the given roles (OR semantics). */
|
||||||
|
public Middleware requireRole(String... roles) {
|
||||||
|
return authorize(AuthPolicy.rolesAny(roles));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@link #protect()} plus every one of the given scopes. */
|
||||||
|
public Middleware requireScopes(String... scopes) {
|
||||||
|
return authorize(AuthPolicy.scopes(scopes, ScopesAllowed.Match.ALL));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@link #protect()} plus at least one of the given scopes. */
|
||||||
|
public Middleware requireAnyScope(String... scopes) {
|
||||||
|
return authorize(AuthPolicy.scopes(scopes, ScopesAllowed.Match.ANY));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Policy enforcement ---------------------------------------------------
|
||||||
|
|
||||||
|
private void enforcePolicy(Map<String, Object> claims, AuthPolicy policy, Response res) {
|
||||||
|
checkRoles(claims, policy.requiredRoles());
|
||||||
|
checkScopes(claims, policy.requiredScopes(), policy.scopeMatch(), res);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkRoles(Map<String, Object> claims, String[] required) {
|
||||||
|
if (required.length == 0) return;
|
||||||
|
if (rolesAllowed(claims, required)) return;
|
||||||
|
throw HttpException.forbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkScopes(Map<String, Object> claims, String[] required, ScopesAllowed.Match match,
|
||||||
|
Response res) {
|
||||||
|
if (required.length == 0) return;
|
||||||
|
if (scopesAllowed(claims, required, match)) return;
|
||||||
|
String challenge = source.insufficientScopeChallenge(required);
|
||||||
|
if (challenge != null) res.header("WWW-Authenticate", challenge);
|
||||||
|
throw HttpException.forbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Claim matching -------------------------------------------------------
|
||||||
|
|
||||||
|
boolean rolesAllowed(Map<String, Object> claims, String[] required) {
|
||||||
|
Object actual = valueAtPath(claims, roleClaimPathParts);
|
||||||
|
if (actual == null) return false;
|
||||||
|
for (String role : required) {
|
||||||
|
if (containsToken(actual, role)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean scopesAllowed(Map<String, Object> claims, String[] required, ScopesAllowed.Match match) {
|
||||||
|
if (match == ScopesAllowed.Match.ALL) {
|
||||||
|
for (String scope : required) {
|
||||||
|
if (!hasScope(claims, scope)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (String scope : required) {
|
||||||
|
if (hasScope(claims, scope)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasScope(Map<String, Object> claims, String scope) {
|
||||||
|
for (String[] pathParts : scopeClaimPathParts) {
|
||||||
|
Object value = valueAtPath(claims, pathParts);
|
||||||
|
if (value != null && containsToken(value, scope)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Object valueAtPath(Map<String, Object> claims, String[] pathParts) {
|
||||||
|
Object current = claims;
|
||||||
|
for (String part : pathParts) {
|
||||||
|
if (!(current instanceof Map<?, ?> map)) return null;
|
||||||
|
current = map.get(part);
|
||||||
|
if (current == null) return null;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean containsToken(Object source, String token) {
|
||||||
|
if (source instanceof String s) return containsDelimitedToken(s, token);
|
||||||
|
if (source instanceof List<?> list) {
|
||||||
|
for (Object item : list) {
|
||||||
|
if (item == null) continue;
|
||||||
|
if (tokenEquals(item.toString(), token)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (source instanceof Object[] arr) {
|
||||||
|
for (Object item : arr) {
|
||||||
|
if (item == null) continue;
|
||||||
|
if (tokenEquals(item.toString(), token)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return tokenEquals(source.toString(), token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean containsDelimitedToken(String value, String token) {
|
||||||
|
int len = value.length();
|
||||||
|
int i = 0;
|
||||||
|
while (i < len) {
|
||||||
|
while (i < len && isScopeDelimiter(value.charAt(i))) i++;
|
||||||
|
int start = i;
|
||||||
|
while (i < len && !isScopeDelimiter(value.charAt(i))) i++;
|
||||||
|
int end = i;
|
||||||
|
if (end > start && end - start == token.length() && value.regionMatches(start, token, 0, token.length())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean tokenEquals(String value, String token) {
|
||||||
|
int start = 0;
|
||||||
|
int end = value.length();
|
||||||
|
while (start < end && Character.isWhitespace(value.charAt(start))) start++;
|
||||||
|
while (end > start && Character.isWhitespace(value.charAt(end - 1))) end--;
|
||||||
|
return end - start == token.length() && value.regionMatches(start, token, 0, token.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isScopeDelimiter(char c) {
|
||||||
|
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String[] splitClaimPath(String path) {
|
||||||
|
if (path == null || path.isBlank()) {
|
||||||
|
throw new IllegalStateException("Claim path cannot be blank");
|
||||||
|
}
|
||||||
|
List<String> parts = new ArrayList<>(4);
|
||||||
|
int start = 0;
|
||||||
|
int len = path.length();
|
||||||
|
for (int i = 0; i <= len; i++) {
|
||||||
|
if (i == len || path.charAt(i) == '.') {
|
||||||
|
String p = path.substring(start, i).trim();
|
||||||
|
if (!p.isEmpty()) parts.add(p);
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parts.isEmpty()) {
|
||||||
|
throw new IllegalStateException("Claim path cannot be blank");
|
||||||
|
}
|
||||||
|
return parts.toArray(String[]::new);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String[][] splitClaimPaths(String paths) {
|
||||||
|
String source = (paths == null || paths.isBlank()) ? "scope,scp" : paths;
|
||||||
|
List<String[]> out = new ArrayList<>(4);
|
||||||
|
int start = 0;
|
||||||
|
int len = source.length();
|
||||||
|
for (int i = 0; i <= len; i++) {
|
||||||
|
if (i == len || source.charAt(i) == ',') {
|
||||||
|
String raw = source.substring(start, i).trim();
|
||||||
|
if (!raw.isEmpty()) out.add(splitClaimPath(raw));
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (out.isEmpty()) {
|
||||||
|
return new String[][]{ splitClaimPath("scope"), splitClaimPath("scp") };
|
||||||
|
}
|
||||||
|
return out.toArray(String[][]::new);
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-18
@@ -1,4 +1,4 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -7,13 +7,13 @@ import java.util.List;
|
|||||||
* Compiled authorization policy derived from handler annotations at mount time.
|
* Compiled authorization policy derived from handler annotations at mount time.
|
||||||
* Immutable and allocation-free on the request hot path.
|
* Immutable and allocation-free on the request hot path.
|
||||||
*/
|
*/
|
||||||
final class OidcAuthPolicy {
|
public final class AuthPolicy {
|
||||||
|
|
||||||
private static final String[] EMPTY = new String[0];
|
private static final String[] EMPTY = new String[0];
|
||||||
|
|
||||||
private static final OidcAuthPolicy AUTH_REQUIRED = new OidcAuthPolicy(
|
private static final AuthPolicy AUTH_REQUIRED = new AuthPolicy(
|
||||||
false, EMPTY, EMPTY, ScopesAllowed.Match.ALL);
|
false, EMPTY, EMPTY, ScopesAllowed.Match.ALL);
|
||||||
private static final OidcAuthPolicy AUTH_OPTIONAL = new OidcAuthPolicy(
|
private static final AuthPolicy AUTH_OPTIONAL = new AuthPolicy(
|
||||||
true, EMPTY, EMPTY, ScopesAllowed.Match.ALL);
|
true, EMPTY, EMPTY, ScopesAllowed.Match.ALL);
|
||||||
|
|
||||||
private final boolean optionalAuth;
|
private final boolean optionalAuth;
|
||||||
@@ -21,7 +21,7 @@ final class OidcAuthPolicy {
|
|||||||
private final String[] requiredScopes;
|
private final String[] requiredScopes;
|
||||||
private final ScopesAllowed.Match scopeMatch;
|
private final ScopesAllowed.Match scopeMatch;
|
||||||
|
|
||||||
private OidcAuthPolicy(boolean optionalAuth,
|
private AuthPolicy(boolean optionalAuth,
|
||||||
String[] requiredRoles,
|
String[] requiredRoles,
|
||||||
String[] requiredScopes,
|
String[] requiredScopes,
|
||||||
ScopesAllowed.Match scopeMatch) {
|
ScopesAllowed.Match scopeMatch) {
|
||||||
@@ -31,19 +31,19 @@ final class OidcAuthPolicy {
|
|||||||
this.scopeMatch = scopeMatch;
|
this.scopeMatch = scopeMatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
static OidcAuthPolicy authenticated() { return AUTH_REQUIRED; }
|
public static AuthPolicy authenticated() { return AUTH_REQUIRED; }
|
||||||
|
|
||||||
static OidcAuthPolicy optional() { return AUTH_OPTIONAL; }
|
public static AuthPolicy optional() { return AUTH_OPTIONAL; }
|
||||||
|
|
||||||
static OidcAuthPolicy rolesAny(String... roles) {
|
public static AuthPolicy rolesAny(String... roles) {
|
||||||
return new OidcAuthPolicy(false, normalizeRequired("RolesAllowed", roles), EMPTY, ScopesAllowed.Match.ALL);
|
return new AuthPolicy(false, normalizeRequired("RolesAllowed", roles), EMPTY, ScopesAllowed.Match.ALL);
|
||||||
}
|
}
|
||||||
|
|
||||||
static OidcAuthPolicy scopes(String[] scopes, ScopesAllowed.Match match) {
|
public static AuthPolicy scopes(String[] scopes, ScopesAllowed.Match match) {
|
||||||
return new OidcAuthPolicy(false, EMPTY, normalizeRequired("ScopesAllowed", scopes), match);
|
return new AuthPolicy(false, EMPTY, normalizeRequired("ScopesAllowed", scopes), match);
|
||||||
}
|
}
|
||||||
|
|
||||||
static OidcAuthPolicy compileFromAnnotations(Class<?> handlerClass) {
|
public static AuthPolicy compileFromAnnotations(Class<?> handlerClass) {
|
||||||
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
|
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
|
||||||
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
|
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
|
||||||
ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class);
|
ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class);
|
||||||
@@ -60,10 +60,10 @@ final class OidcAuthPolicy {
|
|||||||
+ handlerClass.getName());
|
+ handlerClass.getName());
|
||||||
}
|
}
|
||||||
|
|
||||||
return new OidcAuthPolicy(optionalAuth, requiredRoles, requiredScopes, scopeMatch);
|
return new AuthPolicy(optionalAuth, requiredRoles, requiredScopes, scopeMatch);
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<String> openApiScopesFor(Class<?> handlerClass) {
|
public static List<String> openApiScopesFor(Class<?> handlerClass) {
|
||||||
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
|
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
|
||||||
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
|
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
|
||||||
ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class);
|
ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class);
|
||||||
@@ -72,13 +72,13 @@ final class OidcAuthPolicy {
|
|||||||
return List.of(normalizeRequired("ScopesAllowed", scopes.value()));
|
return List.of(normalizeRequired("ScopesAllowed", scopes.value()));
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean optionalAuth() { return optionalAuth; }
|
public boolean optionalAuth() { return optionalAuth; }
|
||||||
|
|
||||||
String[] requiredRoles() { return requiredRoles; }
|
public String[] requiredRoles() { return requiredRoles; }
|
||||||
|
|
||||||
String[] requiredScopes() { return requiredScopes; }
|
public String[] requiredScopes() { return requiredScopes; }
|
||||||
|
|
||||||
ScopesAllowed.Match scopeMatch() { return scopeMatch; }
|
public ScopesAllowed.Match scopeMatch() { return scopeMatch; }
|
||||||
|
|
||||||
private static String[] normalizeRequired(String annotation, String[] values) {
|
private static String[] normalizeRequired(String annotation, String[] values) {
|
||||||
if (values == null || values.length == 0)
|
if (values == null || values.length == 0)
|
||||||
+9
-9
@@ -1,4 +1,4 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
import java.lang.annotation.ElementType;
|
import java.lang.annotation.ElementType;
|
||||||
import java.lang.annotation.Retention;
|
import java.lang.annotation.Retention;
|
||||||
@@ -6,23 +6,23 @@ import java.lang.annotation.RetentionPolicy;
|
|||||||
import java.lang.annotation.Target;
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Marks a handler as requiring a valid JWT. Any bearer token that passes
|
* Marks a handler as requiring an authenticated caller. Any credential a registered source
|
||||||
* signature + expiry + issuer validation is accepted — no role check is performed.
|
* accepts is enough — no role or scope check is performed.
|
||||||
*
|
*
|
||||||
* <p>For role-based access use {@link RolesAllowed} instead (it implies authentication).
|
* <p>For role-based access use {@link RolesAllowed} instead (it implies authentication).
|
||||||
*
|
*
|
||||||
* <p>Set {@code optional = true} on public routes that personalise their response when
|
* <p>Set {@code optional = true} on public routes that personalise their response when the caller
|
||||||
* the user happens to be logged in but should remain accessible to guests. The middleware
|
* happens to be signed in but should remain reachable by guests. The middleware populates
|
||||||
* will populate {@link ClaimsHolder} if credentials are present and silently skip it
|
* {@link ClaimsHolder} when a credential is present and silently skips it otherwise — the request
|
||||||
* otherwise — the request is never rejected.
|
* is never rejected.
|
||||||
*
|
*
|
||||||
* <pre>{@code
|
* <pre>{@code
|
||||||
* // Hard auth — redirects / 401 when unauthenticated:
|
* // Hard auth — 401 or a redirect when unauthenticated:
|
||||||
* @Route(method = HttpMethod.GET, path = "/api/profile")
|
* @Route(method = HttpMethod.GET, path = "/api/profile")
|
||||||
* @Authenticated
|
* @Authenticated
|
||||||
* public class GetProfile extends JacksonHandler { ... }
|
* public class GetProfile extends JacksonHandler { ... }
|
||||||
*
|
*
|
||||||
* // Soft auth — guest-friendly, ClaimsHolder populated only when logged in:
|
* // Soft auth — guest-friendly, ClaimsHolder populated only when signed in:
|
||||||
* @Route(method = HttpMethod.GET, path = "/")
|
* @Route(method = HttpMethod.GET, path = "/")
|
||||||
* @Authenticated(optional = true)
|
* @Authenticated(optional = true)
|
||||||
* public class HomePage extends HtmlHandler { ... }
|
* public class HomePage extends HtmlHandler { ... }
|
||||||
+15
-22
@@ -1,43 +1,36 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type-safe view over the JWT claims stored in {@link ClaimsHolder}.
|
* A typed view over one request's claims — whatever the {@link CredentialSource} that
|
||||||
|
* authenticated it produced. Obtained from {@link ClaimsHolder#current()}.
|
||||||
*
|
*
|
||||||
* <p>Obtainable from any protected context via {@link ClaimsHolder#user()}.
|
* <p>The accessors name claim <em>keys</em>, not a protocol: {@code sub} is RFC 7519, and
|
||||||
* Class-based handlers that extend the {@code SessionHandler} hierarchy already
|
* {@code email}, {@code name} and {@code preferred_username} are spelled the same way by every
|
||||||
* have a provisioned DB user in {@code currentUser}; {@code OidcUser} complements
|
* token issuer worth integrating. A source that uses different keys exposes them through
|
||||||
* that by giving access to the raw OIDC claims when needed, and is the primary
|
* {@link #claim(String)} or {@link #roles(String)}.
|
||||||
* API for lambda routes.
|
|
||||||
*
|
*
|
||||||
* <pre>{@code
|
* <pre>{@code
|
||||||
* // Lambda route (OidcMiddleware injected):
|
|
||||||
* app.get("/api/whoami", (req, res) -> {
|
* app.get("/api/whoami", (req, res) -> {
|
||||||
* OidcUser u = ClaimsHolder.user();
|
* Claims c = ClaimsHolder.current();
|
||||||
* return Map.of("sub", u.sub(), "email", u.email(), "roles", u.roles("realm_access.roles"), "scopes", u.scopes());
|
* return Map.of("sub", c.sub(), "email", c.email(), "roles", c.roles("realm_access.roles"));
|
||||||
* }, oidcMw.protect());
|
* }, auth.protect());
|
||||||
*
|
|
||||||
* // Class-based handler (currentUser is the DB entity; oidcUser() for raw claims):
|
|
||||||
* protected Object handleAuthenticated(Request req, Response res) throws Exception {
|
|
||||||
* OidcUser u = oidcUser(); // same as ClaimsHolder.user()
|
|
||||||
* return json(res, currentUser); // DB entity — provisioned from OIDC sub
|
|
||||||
* }
|
|
||||||
* }</pre>
|
* }</pre>
|
||||||
*/
|
*/
|
||||||
public final class OidcUser {
|
public final class Claims {
|
||||||
|
|
||||||
private final Map<String, Object> claims;
|
private final Map<String, Object> claims;
|
||||||
|
|
||||||
OidcUser(Map<String, Object> claims) {
|
Claims(Map<String, Object> claims) {
|
||||||
this.claims = claims;
|
this.claims = claims;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Common OIDC standard claims ───────────────────────────────────────────
|
// ── Common claims ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Subject identifier — unique, stable user ID issued by the provider. */
|
/** Subject identifier — the stable, unique id of the caller. */
|
||||||
public String sub() { return str("sub"); }
|
public String sub() { return str("sub"); }
|
||||||
|
|
||||||
/** User's email address ({@code email} claim). */
|
/** User's email address ({@code email} claim). */
|
||||||
@@ -84,7 +77,7 @@ public final class OidcUser {
|
|||||||
// -- Scopes ---------------------------------------------------------------
|
// -- Scopes ---------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves OAuth2 scopes from standard OIDC/OAuth claims using fallback order:
|
* Resolves scopes using the conventional fallback order:
|
||||||
* {@code scope} then {@code scp}. Supports both space-separated string and list forms.
|
* {@code scope} then {@code scp}. Supports both space-separated string and list forms.
|
||||||
*/
|
*/
|
||||||
public List<String> scopes() {
|
public List<String> scopes() {
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current request's claims, published by {@link AuthMiddleware} before the handler runs and
|
||||||
|
* cleared in a {@code finally} afterwards.
|
||||||
|
*
|
||||||
|
* <p>Safe with virtual threads: each request gets its own, so a {@link ThreadLocal} is naturally
|
||||||
|
* isolated per request.
|
||||||
|
*
|
||||||
|
* <p>Writing is deliberately not public. A {@link CredentialSource} returns claims and the
|
||||||
|
* middleware publishes them, so no code outside this module can put claims on a request that did
|
||||||
|
* not carry them.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* // Inside any handler behind @Authenticated or @RolesAllowed:
|
||||||
|
* Claims caller = ClaimsHolder.current();
|
||||||
|
* String email = caller.email();
|
||||||
|
* List<String> roles = caller.roles("realm_access.roles");
|
||||||
|
*
|
||||||
|
* // Raw escape hatch:
|
||||||
|
* Map<String, Object> all = ClaimsHolder.map();
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
public final class ClaimsHolder {
|
||||||
|
|
||||||
|
private static final ThreadLocal<Map<String, Object>> HOLDER = new ThreadLocal<>();
|
||||||
|
|
||||||
|
private ClaimsHolder() {}
|
||||||
|
|
||||||
|
/** Called by {@link AuthMiddleware} once a source has authenticated the request. */
|
||||||
|
static void set(Map<String, Object> claims) {
|
||||||
|
HOLDER.set(claims);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called by {@link AuthMiddleware} in the {@code finally} block. */
|
||||||
|
static void clear() {
|
||||||
|
HOLDER.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A typed view of the current request's claims, or {@code null} when the route carries no
|
||||||
|
* authentication middleware or the caller is anonymous under
|
||||||
|
* {@link Authenticated}{@code (optional = true)}.
|
||||||
|
*/
|
||||||
|
public static Claims current() {
|
||||||
|
Map<String, Object> claims = HOLDER.get();
|
||||||
|
return claims != null ? new Claims(claims) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The raw claims map for the current request, or {@code null}. @see #current() */
|
||||||
|
public static Map<String, Object> map() {
|
||||||
|
return HOLDER.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single claim as a String, or {@code null} when absent or the caller is anonymous. */
|
||||||
|
public static String claim(String key) {
|
||||||
|
Map<String, Object> claims = HOLDER.get();
|
||||||
|
if (claims == null) return null;
|
||||||
|
Object v = claims.get(key);
|
||||||
|
return v != null ? v.toString() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
|
import dev.relism.flash.exceptions.HttpException;
|
||||||
|
import dev.relism.flash.models.Request;
|
||||||
|
import dev.relism.flash.models.Response;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns whatever a request carries — a bearer token, a session cookie, an API key — into the
|
||||||
|
* claims authorization runs on. One is installed per authentication mechanism;
|
||||||
|
* {@code flash-ext-auth-oidc} contributes the OpenID Connect one.
|
||||||
|
*
|
||||||
|
* <p>Implementations never touch {@link ClaimsHolder}: they produce claims and {@link
|
||||||
|
* AuthMiddleware} publishes them for the duration of the request. Nothing outside this module can
|
||||||
|
* inject claims into a request, which is the point.
|
||||||
|
*/
|
||||||
|
public interface CredentialSource {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the caller's claims, rejecting the request when it cannot.
|
||||||
|
*
|
||||||
|
* <p>Three outcomes, and the difference between the last two matters:
|
||||||
|
* <ul>
|
||||||
|
* <li>claims — the caller presented a valid credential;</li>
|
||||||
|
* <li>{@code null} — no credential was presented and this source has already answered the
|
||||||
|
* request itself (typically a redirect into a sign-in flow). The middleware stops and
|
||||||
|
* writes nothing more;</li>
|
||||||
|
* <li>{@link HttpException} — a credential <em>was</em> presented and is invalid. The source
|
||||||
|
* sets any challenge header it owes the caller before throwing.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
Map<String, Object> authenticate(Request req, Response res);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves claims without ever rejecting: {@code null} when no valid credential is present.
|
||||||
|
* Backs {@link Authenticated}{@code (optional = true)}, where an anonymous caller is a normal
|
||||||
|
* outcome rather than a failure.
|
||||||
|
*/
|
||||||
|
Map<String, Object> peek(Request req);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@code WWW-Authenticate} value to send with a 403 caused by missing scopes, or
|
||||||
|
* {@code null} when this source has no such concept. Only consulted after authentication has
|
||||||
|
* already succeeded.
|
||||||
|
*/
|
||||||
|
default String insufficientScopeChallenge(String[] requiredScopes) { return null; }
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thread-safe in-memory {@link SessionStore}.
|
||||||
|
*
|
||||||
|
* <p>Sessions are lost on restart and not shared across instances. For
|
||||||
|
* production deployments with multiple nodes or restart-persistence requirements,
|
||||||
|
* supply another implementation to whichever {@link CredentialSource} owns the session.
|
||||||
|
*/
|
||||||
|
public final class InMemorySessionStore implements SessionStore {
|
||||||
|
|
||||||
|
private final ConcurrentHashMap<String, Session> store = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
@Override public void save(Session s) { store.put(s.id(), s); }
|
||||||
|
@Override public Optional<Session> find(String id) { return Optional.ofNullable(store.get(id)); }
|
||||||
|
@Override public void delete(String id) { store.remove(id); }
|
||||||
|
}
|
||||||
+7
-8
@@ -1,4 +1,4 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
import java.lang.annotation.ElementType;
|
import java.lang.annotation.ElementType;
|
||||||
import java.lang.annotation.Retention;
|
import java.lang.annotation.Retention;
|
||||||
@@ -6,20 +6,19 @@ import java.lang.annotation.RetentionPolicy;
|
|||||||
import java.lang.annotation.Target;
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restricts a handler to callers whose JWT contains at least one of the
|
* Restricts a handler to callers holding at least one of the named roles. Authentication is
|
||||||
* specified roles. Authentication is implicitly required — no need to combine
|
* implied — there is no need to combine it with {@link Authenticated}.
|
||||||
* with {@link Authenticated}.
|
|
||||||
*
|
*
|
||||||
* <p>Roles are read from the claim configured in {@link OidcConfig#rolesClaimPath()}
|
* <p>Roles are read from the claim path the installed credential source is configured with
|
||||||
* (default: {@code "roles"}). Nested paths like {@code "realm_access.roles"} are
|
* (Keycloak's is {@code realm_access.roles}; many providers use a flat {@code roles} or
|
||||||
* supported with dot notation.
|
* {@code groups}). Nested paths use dot notation.
|
||||||
*
|
*
|
||||||
* <pre>{@code
|
* <pre>{@code
|
||||||
* @Route(method = HttpMethod.DELETE, path = "/api/admin/blogs/{id}")
|
* @Route(method = HttpMethod.DELETE, path = "/api/admin/blogs/{id}")
|
||||||
* @RolesAllowed("admin")
|
* @RolesAllowed("admin")
|
||||||
* public class DeleteBlog extends JacksonHandler { ... }
|
* public class DeleteBlog extends JacksonHandler { ... }
|
||||||
*
|
*
|
||||||
* // Multiple accepted roles (OR semantics — any one role is sufficient):
|
* // Multiple accepted roles (OR semantics — any one is sufficient):
|
||||||
* @RolesAllowed({"admin", "editor"})
|
* @RolesAllowed({"admin", "editor"})
|
||||||
* public class UpdateBlog extends JacksonHandler { ... }
|
* public class UpdateBlog extends JacksonHandler { ... }
|
||||||
* }</pre>
|
* }</pre>
|
||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
import java.lang.annotation.ElementType;
|
import java.lang.annotation.ElementType;
|
||||||
import java.lang.annotation.Retention;
|
import java.lang.annotation.Retention;
|
||||||
@@ -6,11 +6,11 @@ import java.lang.annotation.RetentionPolicy;
|
|||||||
import java.lang.annotation.Target;
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restricts a handler to callers whose token carries the required OAuth2 scopes.
|
* Restricts a handler to callers whose credential carries the required scopes.
|
||||||
* Authentication is implicitly required.
|
* Authentication is implicitly required.
|
||||||
*
|
*
|
||||||
* <p>Scopes are resolved from the configured claim paths in
|
* <p>Scopes are resolved from the configured claim paths in
|
||||||
* {@link OidcConfig#scopeClaimPaths()} (default: {@code "scope,scp"}) and support
|
* {@link AuthConfig#scopeClaimPaths()} (default: {@code "scope,scp"}) and support
|
||||||
* both standard formats:
|
* both standard formats:
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>{@code scope}: space-separated string</li>
|
* <li>{@code scope}: space-separated string</li>
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A signed-in caller's server-side session — saved in a {@link SessionStore} and looked up by a
|
||||||
|
* cookie on every request.
|
||||||
|
*
|
||||||
|
* <p>Immutable: renewing one produces a new instance that replaces the old under the same
|
||||||
|
* {@link #id()}.
|
||||||
|
*
|
||||||
|
* <p>{@link #attributes()} is whatever the {@link CredentialSource} needs to keep alongside the
|
||||||
|
* claims and nothing this module interprets — OpenID Connect stores its access, id and refresh
|
||||||
|
* tokens there so that renewal is its business rather than core's.
|
||||||
|
*/
|
||||||
|
public final class Session {
|
||||||
|
|
||||||
|
/** Renew this far before the real expiry, so a session cannot lapse mid-request. */
|
||||||
|
private static final long EAGER_RENEWAL_SECONDS = 30;
|
||||||
|
|
||||||
|
private final String id;
|
||||||
|
private final Map<String, Object> claims;
|
||||||
|
private final Instant expiresAt;
|
||||||
|
private final Map<String, Object> attributes;
|
||||||
|
|
||||||
|
public Session(String id, Map<String, Object> claims, Instant expiresAt,
|
||||||
|
Map<String, Object> attributes) {
|
||||||
|
this.id = id;
|
||||||
|
this.claims = Map.copyOf(claims);
|
||||||
|
this.expiresAt = expiresAt;
|
||||||
|
this.attributes = attributes == null ? Map.of() : Map.copyOf(attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True once the session is within {@value #EAGER_RENEWAL_SECONDS} seconds of expiring. */
|
||||||
|
public boolean isExpired() {
|
||||||
|
return Instant.now().isAfter(expiresAt.minusSeconds(EAGER_RENEWAL_SECONDS));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One attribute, or {@code null} when the source never stored it. */
|
||||||
|
public Object attribute(String key) { return attributes.get(key); }
|
||||||
|
|
||||||
|
/** One attribute as a String, or {@code null}. */
|
||||||
|
public String attributeAsString(String key) {
|
||||||
|
Object v = attributes.get(key);
|
||||||
|
return v != null ? v.toString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String id() { return id; }
|
||||||
|
public Map<String, Object> claims() { return claims; }
|
||||||
|
public Instant expiresAt() { return expiresAt; }
|
||||||
|
public Map<String, Object> attributes() { return attributes; }
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where {@link Session}s live between requests. {@link InMemorySessionStore} is the default;
|
||||||
|
* supply another for Redis, JDBC, or anything that survives a restart or spans instances.
|
||||||
|
*/
|
||||||
|
public interface SessionStore {
|
||||||
|
void save(Session session);
|
||||||
|
Optional<Session> find(String sessionId);
|
||||||
|
void delete(String sessionId);
|
||||||
|
}
|
||||||
+11
-11
@@ -1,4 +1,4 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ import java.util.List;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
class OidcAuthPolicyTest {
|
class AuthPolicyTest {
|
||||||
|
|
||||||
static class PlainHandler {}
|
static class PlainHandler {}
|
||||||
|
|
||||||
@@ -33,12 +33,12 @@ class OidcAuthPolicyTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void compileFromAnnotations_noSecurityAnnotations_returnsNull() {
|
void compileFromAnnotations_noSecurityAnnotations_returnsNull() {
|
||||||
assertNull(OidcAuthPolicy.compileFromAnnotations(PlainHandler.class));
|
assertNull(AuthPolicy.compileFromAnnotations(PlainHandler.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void compileFromAnnotations_authenticated_createsRequiredAuthPolicy() {
|
void compileFromAnnotations_authenticated_createsRequiredAuthPolicy() {
|
||||||
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(AuthenticatedHandler.class);
|
AuthPolicy policy = AuthPolicy.compileFromAnnotations(AuthenticatedHandler.class);
|
||||||
assertNotNull(policy);
|
assertNotNull(policy);
|
||||||
assertFalse(policy.optionalAuth());
|
assertFalse(policy.optionalAuth());
|
||||||
assertEquals(0, policy.requiredRoles().length);
|
assertEquals(0, policy.requiredRoles().length);
|
||||||
@@ -47,14 +47,14 @@ class OidcAuthPolicyTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void compileFromAnnotations_optionalAuth_createsOptionalPolicy() {
|
void compileFromAnnotations_optionalAuth_createsOptionalPolicy() {
|
||||||
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(OptionalHandler.class);
|
AuthPolicy policy = AuthPolicy.compileFromAnnotations(OptionalHandler.class);
|
||||||
assertNotNull(policy);
|
assertNotNull(policy);
|
||||||
assertTrue(policy.optionalAuth());
|
assertTrue(policy.optionalAuth());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void compileFromAnnotations_rolesAndScopes_areNormalizedAndMerged() {
|
void compileFromAnnotations_rolesAndScopes_areNormalizedAndMerged() {
|
||||||
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(CombinedHandler.class);
|
AuthPolicy policy = AuthPolicy.compileFromAnnotations(CombinedHandler.class);
|
||||||
assertNotNull(policy);
|
assertNotNull(policy);
|
||||||
assertFalse(policy.optionalAuth());
|
assertFalse(policy.optionalAuth());
|
||||||
assertArrayEquals(new String[]{"admin"}, policy.requiredRoles());
|
assertArrayEquals(new String[]{"admin"}, policy.requiredRoles());
|
||||||
@@ -64,7 +64,7 @@ class OidcAuthPolicyTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void compileFromAnnotations_scopesAny_preservesMatchModeAndDedupes() {
|
void compileFromAnnotations_scopesAny_preservesMatchModeAndDedupes() {
|
||||||
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(ScopesHandler.class);
|
AuthPolicy policy = AuthPolicy.compileFromAnnotations(ScopesHandler.class);
|
||||||
assertNotNull(policy);
|
assertNotNull(policy);
|
||||||
assertArrayEquals(new String[]{"orders:write", "payments:write"}, policy.requiredScopes());
|
assertArrayEquals(new String[]{"orders:write", "payments:write"}, policy.requiredScopes());
|
||||||
assertEquals(ScopesAllowed.Match.ANY, policy.scopeMatch());
|
assertEquals(ScopesAllowed.Match.ANY, policy.scopeMatch());
|
||||||
@@ -73,22 +73,22 @@ class OidcAuthPolicyTest {
|
|||||||
@Test
|
@Test
|
||||||
void compileFromAnnotations_optionalCannotBeCombinedWithConstraints() {
|
void compileFromAnnotations_optionalCannotBeCombinedWithConstraints() {
|
||||||
assertThrows(IllegalStateException.class,
|
assertThrows(IllegalStateException.class,
|
||||||
() -> OidcAuthPolicy.compileFromAnnotations(InvalidOptionalHandler.class));
|
() -> AuthPolicy.compileFromAnnotations(InvalidOptionalHandler.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void openApiScopesFor_returnsScopesWhenPresent() {
|
void openApiScopesFor_returnsScopesWhenPresent() {
|
||||||
assertEquals(List.of("orders:write", "payments:write"),
|
assertEquals(List.of("orders:write", "payments:write"),
|
||||||
OidcAuthPolicy.openApiScopesFor(ScopesHandler.class));
|
AuthPolicy.openApiScopesFor(ScopesHandler.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void openApiScopesFor_rolesOnly_returnsEmptyList() {
|
void openApiScopesFor_rolesOnly_returnsEmptyList() {
|
||||||
assertEquals(List.of(), OidcAuthPolicy.openApiScopesFor(RolesHandler.class));
|
assertEquals(List.of(), AuthPolicy.openApiScopesFor(RolesHandler.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void openApiScopesFor_noSecurity_returnsNull() {
|
void openApiScopesFor_noSecurity_returnsNull() {
|
||||||
assertNull(OidcAuthPolicy.openApiScopesFor(PlainHandler.class));
|
assertNull(AuthPolicy.openApiScopesFor(PlainHandler.class));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+209
@@ -0,0 +1,209 @@
|
|||||||
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Characterisation tests for claim matching — the part of authorization that has nothing to do
|
||||||
|
* with OIDC: given a claims map, does the caller hold a role or a scope.
|
||||||
|
*
|
||||||
|
* <p>Written to pin the <em>current</em> behaviour, including the edges that are easy to change by
|
||||||
|
* accident: which characters separate scopes in a string claim, whether a list entry is trimmed
|
||||||
|
* before comparison, what an empty requirement means under each match mode, and how a claim path
|
||||||
|
* that walks into a non-map resolves. Every assertion here reflects what the code does today, not
|
||||||
|
* what it arguably should do.
|
||||||
|
*/
|
||||||
|
class ClaimMatchingTest {
|
||||||
|
|
||||||
|
/** No credential source: every assertion here is about claims that are already resolved. */
|
||||||
|
private static AuthMiddleware middleware(String rolesPath, String scopePaths) {
|
||||||
|
return new AuthMiddleware(AuthConfig.builder()
|
||||||
|
.rolesClaimPath(rolesPath)
|
||||||
|
.scopeClaimPaths(scopePaths)
|
||||||
|
.build(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AuthMiddleware middleware() {
|
||||||
|
return middleware("realm_access.roles", "scope,scp");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Claim path traversal ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aPathWalksNestedMaps() {
|
||||||
|
Map<String, Object> claims = Map.of("a", Map.of("b", Map.of("c", List.of("x"))));
|
||||||
|
assertTrue(middleware("a.b.c", "scope").rolesAllowed(claims, new String[]{"x"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aPathThatWalksIntoANonMapResolvesToNothing() {
|
||||||
|
// "a" is a string, so "a.b" has nowhere to go — not an error, just no match.
|
||||||
|
Map<String, Object> claims = Map.of("a", "not-a-map");
|
||||||
|
assertFalse(middleware("a.b", "scope").rolesAllowed(claims, new String[]{"anything"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aMissingPathResolvesToNothing() {
|
||||||
|
assertFalse(middleware().rolesAllowed(Map.of("other", "value"), new String[]{"admin"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptySegmentsInAPathAreSkipped() {
|
||||||
|
// "realm_access..roles" collapses to the same two segments.
|
||||||
|
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("admin")));
|
||||||
|
assertTrue(middleware("realm_access..roles", "scope").rolesAllowed(claims, new String[]{"admin"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void segmentsAreTrimmed() {
|
||||||
|
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("admin")));
|
||||||
|
assertTrue(middleware(" realm_access . roles ", "scope").rolesAllowed(claims, new String[]{"admin"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aBlankRolesPathIsRejectedAtConstruction() {
|
||||||
|
assertThrows(IllegalStateException.class, () -> middleware(" ", "scope"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aNullClaimValueResolvesToNothing() {
|
||||||
|
Map<String, Object> nested = new HashMap<>();
|
||||||
|
nested.put("roles", null);
|
||||||
|
Map<String, Object> claims = Map.of("realm_access", nested);
|
||||||
|
assertFalse(middleware().rolesAllowed(claims, new String[]{"admin"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Roles: ANY semantics ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anyOneOfTheRequiredRolesIsEnough() {
|
||||||
|
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("user")));
|
||||||
|
assertTrue(middleware().rolesAllowed(claims, new String[]{"admin", "user"}));
|
||||||
|
assertFalse(middleware().rolesAllowed(claims, new String[]{"admin", "ops"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requiringNoRoleAtAllMatchesNothing() {
|
||||||
|
// The loop never runs, so the answer is false even when the claim is present.
|
||||||
|
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("admin")));
|
||||||
|
assertFalse(middleware().rolesAllowed(claims, new String[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── What counts as "contains" ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aListClaimMatchesEntrywiseAndTrimsEachEntry() {
|
||||||
|
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of(" admin ", "user")));
|
||||||
|
assertTrue(middleware().rolesAllowed(claims, new String[]{"admin"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aListEntryIsNeverSplitOnDelimiters() {
|
||||||
|
// Unlike a string claim, a list entry is compared whole: "a b" is one role named "a b".
|
||||||
|
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("a b")));
|
||||||
|
assertFalse(middleware().rolesAllowed(claims, new String[]{"a"}));
|
||||||
|
assertTrue(middleware().rolesAllowed(claims, new String[]{"a b"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullEntriesInAListAreSkipped() {
|
||||||
|
Map<String, Object> claims = Map.of("realm_access",
|
||||||
|
Map.of("roles", Arrays.asList(null, "admin")));
|
||||||
|
assertTrue(middleware().rolesAllowed(claims, new String[]{"admin"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anArrayClaimBehavesLikeAList() {
|
||||||
|
Map<String, Object> claims = Map.of("realm_access",
|
||||||
|
Map.of("roles", (Object) new String[]{"admin", "user"}));
|
||||||
|
assertTrue(middleware().rolesAllowed(claims, new String[]{"user"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aScalarClaimIsComparedWhole() {
|
||||||
|
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", 42));
|
||||||
|
assertTrue(middleware().rolesAllowed(claims, new String[]{"42"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aStringClaimIsSplitOnSpacesTabsNewlinesAndCommas() {
|
||||||
|
for (String separator : List.of(" ", "\t", "\n", "\r", ",")) {
|
||||||
|
Map<String, Object> claims = Map.of("realm_access",
|
||||||
|
Map.of("roles", "admin" + separator + "user"));
|
||||||
|
assertTrue(middleware().rolesAllowed(claims, new String[]{"user"}),
|
||||||
|
"separator " + separator.strip().isEmpty() + " should split the claim");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aStringClaimDoesNotMatchAPrefixOrASubstring() {
|
||||||
|
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", "administrator"));
|
||||||
|
assertFalse(middleware().rolesAllowed(claims, new String[]{"admin"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void repeatedDelimitersProduceNoEmptyTokens() {
|
||||||
|
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", " ,, admin ,, "));
|
||||||
|
assertTrue(middleware().rolesAllowed(claims, new String[]{"admin"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scopes: ALL vs ANY, across several claim paths ───────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allRequiresEveryScope() {
|
||||||
|
Map<String, Object> claims = Map.of("scope", "openid orders:read");
|
||||||
|
assertTrue(middleware().scopesAllowed(claims, new String[]{"openid", "orders:read"}, ScopesAllowed.Match.ALL));
|
||||||
|
assertFalse(middleware().scopesAllowed(claims, new String[]{"openid", "orders:write"}, ScopesAllowed.Match.ALL));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anyRequiresOne() {
|
||||||
|
Map<String, Object> claims = Map.of("scope", "openid");
|
||||||
|
assertTrue(middleware().scopesAllowed(claims, new String[]{"nope", "openid"}, ScopesAllowed.Match.ANY));
|
||||||
|
assertFalse(middleware().scopesAllowed(claims, new String[]{"nope", "neither"}, ScopesAllowed.Match.ANY));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requiringNoScopeIsVacuouslyTrueUnderAllAndFalseUnderAny() {
|
||||||
|
// The asymmetry falls out of the loops and is load-bearing for @ScopesAllowed's validation,
|
||||||
|
// which rejects an empty value list before it can ever reach here.
|
||||||
|
Map<String, Object> claims = Map.of("scope", "openid");
|
||||||
|
assertTrue(middleware().scopesAllowed(claims, new String[0], ScopesAllowed.Match.ALL));
|
||||||
|
assertFalse(middleware().scopesAllowed(claims, new String[0], ScopesAllowed.Match.ANY));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void scopesAreLookedForInEveryConfiguredPathUntilOneMatches() {
|
||||||
|
AuthMiddleware mw = middleware("roles", "scope, scp , permissions.scopes");
|
||||||
|
Map<String, Object> claims = Map.of(
|
||||||
|
"scp", List.of("payments:write"),
|
||||||
|
"permissions", Map.of("scopes", "orders:approve"));
|
||||||
|
|
||||||
|
assertTrue(mw.scopesAllowed(claims, new String[]{"payments:write"}, ScopesAllowed.Match.ALL));
|
||||||
|
assertTrue(mw.scopesAllowed(claims, new String[]{"orders:approve"}, ScopesAllowed.Match.ALL));
|
||||||
|
// ALL is satisfied even when the two scopes come from different claims.
|
||||||
|
assertTrue(mw.scopesAllowed(claims,
|
||||||
|
new String[]{"payments:write", "orders:approve"}, ScopesAllowed.Match.ALL));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blankScopePathsFallBackToScopeAndScp() {
|
||||||
|
AuthMiddleware mw = middleware("roles", " ");
|
||||||
|
assertTrue(mw.scopesAllowed(Map.of("scope", "a"), new String[]{"a"}, ScopesAllowed.Match.ALL));
|
||||||
|
assertTrue(mw.scopesAllowed(Map.of("scp", "b"), new String[]{"b"}, ScopesAllowed.Match.ALL));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aScopePathListOfOnlySeparatorsFallsBackToScopeAndScp() {
|
||||||
|
AuthMiddleware mw = middleware("roles", " , , ");
|
||||||
|
assertTrue(mw.scopesAllowed(Map.of("scp", "b"), new String[]{"b"}, ScopesAllowed.Match.ALL));
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-6
@@ -1,4 +1,4 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -7,11 +7,11 @@ import java.util.Map;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
class OidcUserScopesTest {
|
class ClaimsScopesTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void scopes_readsStandardScopeString() {
|
void scopes_readsStandardScopeString() {
|
||||||
OidcUser user = new OidcUser(Map.of("scope", "openid profile orders:read"));
|
Claims user = new Claims(Map.of("scope", "openid profile orders:read"));
|
||||||
|
|
||||||
assertEquals(List.of("openid", "profile", "orders:read"), user.scopes());
|
assertEquals(List.of("openid", "profile", "orders:read"), user.scopes());
|
||||||
assertTrue(user.hasScope("orders:read"));
|
assertTrue(user.hasScope("orders:read"));
|
||||||
@@ -20,7 +20,7 @@ class OidcUserScopesTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void scopes_fallsBackToScpArray() {
|
void scopes_fallsBackToScpArray() {
|
||||||
OidcUser user = new OidcUser(Map.of("scp", List.of("orders:write", "payments:write")));
|
Claims user = new Claims(Map.of("scp", List.of("orders:write", "payments:write")));
|
||||||
|
|
||||||
assertEquals(List.of("orders:write", "payments:write"), user.scopes());
|
assertEquals(List.of("orders:write", "payments:write"), user.scopes());
|
||||||
assertTrue(user.hasScope("payments:write"));
|
assertTrue(user.hasScope("payments:write"));
|
||||||
@@ -28,7 +28,7 @@ class OidcUserScopesTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void scopes_supportsCustomClaimPaths() {
|
void scopes_supportsCustomClaimPaths() {
|
||||||
OidcUser user = new OidcUser(Map.of("permissions", Map.of("scopes", List.of("a", "b"))));
|
Claims user = new Claims(Map.of("permissions", Map.of("scopes", List.of("a", "b"))));
|
||||||
|
|
||||||
assertEquals(List.of("a", "b"), user.scopes("permissions.scopes"));
|
assertEquals(List.of("a", "b"), user.scopes("permissions.scopes"));
|
||||||
assertTrue(user.hasScope("permissions.scopes", "a"));
|
assertTrue(user.hasScope("permissions.scopes", "a"));
|
||||||
@@ -37,7 +37,7 @@ class OidcUserScopesTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void scopes_combinesMultipleClaimPathsInOrder() {
|
void scopes_combinesMultipleClaimPathsInOrder() {
|
||||||
OidcUser user = new OidcUser(Map.of(
|
Claims user = new Claims(Map.of(
|
||||||
"scope", "openid",
|
"scope", "openid",
|
||||||
"scp", List.of("profile", "orders:read")
|
"scp", List.of("profile", "orders:read")
|
||||||
));
|
));
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
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.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two things a session has to get right: it reports itself expired early enough that it
|
||||||
|
* cannot lapse midway through a request, and it hands back what a credential source stored on it
|
||||||
|
* without interpreting any of it.
|
||||||
|
*/
|
||||||
|
class SessionTest {
|
||||||
|
|
||||||
|
private static Session at(Instant expiry, Map<String, Object> attributes) {
|
||||||
|
return new Session("s1", Map.of("sub", "u1"), expiry, attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aSessionIsExpiredWellBeforeItsDeadline() {
|
||||||
|
// The eager window is what stops a session from lapsing between the check and the handler.
|
||||||
|
assertFalse(at(Instant.now().plusSeconds(120), Map.of()).isExpired());
|
||||||
|
assertTrue(at(Instant.now().plusSeconds(10), Map.of()).isExpired());
|
||||||
|
assertTrue(at(Instant.now().minusSeconds(1), Map.of()).isExpired());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void attributesAreReturnedUninterpreted() {
|
||||||
|
Session session = at(Instant.now().plusSeconds(60), Map.of("oidc.id_token", "abc", "n", 7));
|
||||||
|
assertEquals("abc", session.attributeAsString("oidc.id_token"));
|
||||||
|
assertEquals("7", session.attributeAsString("n"));
|
||||||
|
assertEquals(7, session.attribute("n"));
|
||||||
|
assertNull(session.attributeAsString("absent"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aSessionWithoutAttributesIsUsableRatherThanNull() {
|
||||||
|
assertNull(at(Instant.now().plusSeconds(60), null).attributeAsString("anything"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void claimsAndAttributesAreCopiedAndImmutable() {
|
||||||
|
Map<String, Object> mutable = new HashMap<>(Map.of("k", "v"));
|
||||||
|
Session session = at(Instant.now().plusSeconds(60), mutable);
|
||||||
|
mutable.put("k", "changed");
|
||||||
|
|
||||||
|
assertEquals("v", session.attributeAsString("k"));
|
||||||
|
assertThrows(UnsupportedOperationException.class, () -> session.attributes().put("x", "y"));
|
||||||
|
assertThrows(UnsupportedOperationException.class, () -> session.claims().put("x", "y"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
-39
@@ -1,4 +1,4 @@
|
|||||||
# flash-ext-oidc
|
# flash-ext-auth-oidc
|
||||||
|
|
||||||
Full OIDC Authorization Code + PKCE flow for the Flash HTTP server.
|
Full OIDC Authorization Code + PKCE flow for the Flash HTTP server.
|
||||||
Supports Keycloak, Authelia, Auth0, Google, and any RFC 8414-compliant provider.
|
Supports Keycloak, Authelia, Auth0, Google, and any RFC 8414-compliant provider.
|
||||||
@@ -6,6 +6,11 @@ Supports Keycloak, Authelia, Auth0, Google, and any RFC 8414-compliant provider.
|
|||||||
Standards alignment focuses on OIDC Core + OAuth2 bearer APIs while preserving Flash's
|
Standards alignment focuses on OIDC Core + OAuth2 bearer APIs while preserving Flash's
|
||||||
hot-path model (middleware compiled at mount time, no heavy runtime work).
|
hot-path model (middleware compiled at mount time, no heavy runtime work).
|
||||||
|
|
||||||
|
This extension is the OpenID Connect **credential source** for
|
||||||
|
[`flash-ext-auth-core`](../flash-ext-auth-core/docs/README.md), which owns everything downstream of
|
||||||
|
identifying the caller. Shorter guides live in [`docs/`](docs/README.md), including
|
||||||
|
[migration notes](docs/interop.md#migrating-from-flash-ext-oidc) from `flash-ext-oidc`.
|
||||||
|
|
||||||
## What it provides
|
## What it provides
|
||||||
|
|
||||||
| Component | Description |
|
| Component | Description |
|
||||||
@@ -13,24 +18,25 @@ hot-path model (middleware compiled at mount time, no heavy runtime work).
|
|||||||
| `GET {prefix}/login` | Starts the OIDC flow: builds the authorization URL with PKCE + state, redirects |
|
| `GET {prefix}/login` | Starts the OIDC flow: builds the authorization URL with PKCE + state, redirects |
|
||||||
| `GET {prefix}/callback` | Exchanges the code, validates the ID token, creates a session, redirects |
|
| `GET {prefix}/callback` | Exchanges the code, validates the ID token, creates a session, redirects |
|
||||||
| `POST {prefix}/logout` | Invalidates the session, redirects to the provider's `end_session_endpoint` |
|
| `POST {prefix}/logout` | Invalidates the session, redirects to the provider's `end_session_endpoint` |
|
||||||
| `@Authenticated` | Annotation: protects a class-based handler (redirects browsers, 401 for API clients) |
|
| `OidcCredentialSource` | The `CredentialSource` this extension contributes to `flash-ext-auth-core` |
|
||||||
| `@RolesAllowed(...)` | Annotation: protects with role check (OR semantics) |
|
|
||||||
| `@ScopesAllowed(...)` | Annotation: protects with scope check (`ALL` default, `ANY` optional) |
|
|
||||||
| `OidcMiddleware` | Programmatic middleware for lambda routes |
|
|
||||||
| `ClaimsHolder` / `OidcUser` | Thread-local user info accessible from any protected handler |
|
|
||||||
| `JwtValidator` | JWKS-backed JWT validator (PKCE + key rotation + caching) |
|
| `JwtValidator` | JWKS-backed JWT validator (PKCE + key rotation + caching) |
|
||||||
|
|
||||||
|
`@Authenticated`, `@RolesAllowed`, `@ScopesAllowed`, `AuthMiddleware`, `ClaimsHolder` and `Claims`
|
||||||
|
belong to [`flash-ext-auth-core`](../flash-ext-auth-core/docs/README.md) and work the same behind
|
||||||
|
any credential source. Installing this extension brings them in and wires them up — you do not
|
||||||
|
install auth-core yourself.
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash-ext-oidc</artifactId>
|
<artifactId>flash-ext-auth-oidc</artifactId>
|
||||||
<version>1.0-SNAPSHOT</version>
|
<version>2.1.0-SNAPSHOT</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
```
|
```
|
||||||
|
|
||||||
Transitive: `nimbus-jose-jwt`, `json-smart`.
|
Transitive: `flash-ext-auth-core`, `nimbus-jose-jwt`, `json-smart`.
|
||||||
Optional: `flash-ext-openapi` — if present, OIDC security schemes are added to the OpenAPI spec automatically.
|
Optional: `flash-ext-openapi` — if present, OIDC security schemes are added to the OpenAPI spec automatically.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
@@ -98,7 +104,7 @@ OidcConfig.builder("https://auth.example.com", "my-client", "secret", "/auth/cal
|
|||||||
| `.scopeClaimPaths("scope,scp")` | `"scope,scp"` | Comma-separated claim paths used to resolve OAuth scopes |
|
| `.scopeClaimPaths("scope,scp")` | `"scope,scp"` | Comma-separated claim paths used to resolve OAuth scopes |
|
||||||
| `.algorithm("RS256")` | `"RS256"` | JWS algorithm for token validation |
|
| `.algorithm("RS256")` | `"RS256"` | JWS algorithm for token validation |
|
||||||
| `.postLogoutRedirectUri("/")` | `"/"` | Where to redirect after logout |
|
| `.postLogoutRedirectUri("/")` | `"/"` | Where to redirect after logout |
|
||||||
| `.sessionStore(store)` | `InMemoryOidcSessionStore` | Custom session store (see below) |
|
| `.sessionStore(store)` | `InMemorySessionStore` | Custom session store (see below) |
|
||||||
| `.clientAuthMethod(ClientAuthMethod.POST)` | `POST` | `POST` = credentials in body; `BASIC` = `Authorization: Basic` |
|
| `.clientAuthMethod(ClientAuthMethod.POST)` | `POST` | `POST` = credentials in body; `BASIC` = `Authorization: Basic` |
|
||||||
| `.insecureTls()` | `false` | Disables TLS certificate verification — **development only** |
|
| `.insecureTls()` | `false` | Disables TLS certificate verification — **development only** |
|
||||||
| `.schemeName("myscheme")` | derived from issuer | OpenAPI security scheme name |
|
| `.schemeName("myscheme")` | derived from issuer | OpenAPI security scheme name |
|
||||||
@@ -130,7 +136,7 @@ OIDC_CLIENT_AUTH_METHOD default: POST
|
|||||||
public class MePage extends JacksonHandler {
|
public class MePage extends JacksonHandler {
|
||||||
@Override
|
@Override
|
||||||
public Object handle(Request req, Response res) {
|
public Object handle(Request req, Response res) {
|
||||||
OidcUser u = ClaimsHolder.user();
|
Claims u = ClaimsHolder.current();
|
||||||
return json(res, Map.of("sub", u.sub(), "email", u.email()));
|
return json(res, Map.of("sub", u.sub(), "email", u.email()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,49 +172,50 @@ Annotation composition rules:
|
|||||||
|
|
||||||
### Lambda routes (manual middleware)
|
### Lambda routes (manual middleware)
|
||||||
|
|
||||||
For lambda routes, pass the middleware as a varargs argument. Retrieve `OidcMiddleware`
|
For lambda routes, pass the middleware as a varargs argument. Retrieve `AuthMiddleware`
|
||||||
from the context inside another extension's `routes()` phase, or after `start()`:
|
from the context inside another extension's `routes()` phase, or after `start()`:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class);
|
AuthMiddleware auth = app.ctx().require(AuthMiddleware.class);
|
||||||
|
|
||||||
// Authentication only
|
// Authentication only
|
||||||
app.get("/api/me", (req, res) -> {
|
app.get("/api/me", (req, res) -> {
|
||||||
OidcUser u = ClaimsHolder.user(); // never null here
|
Claims u = ClaimsHolder.current(); // never null here
|
||||||
return Map.of("sub", u.sub(), "email", u.email());
|
return Map.of("sub", u.sub(), "email", u.email());
|
||||||
}, oidc.protect());
|
}, auth.protect());
|
||||||
|
|
||||||
// Authentication + role check
|
// Authentication + role check
|
||||||
app.delete("/api/admin/users/{id}", (req, res) -> {
|
app.delete("/api/admin/users/{id}", (req, res) -> {
|
||||||
OidcUser u = ClaimsHolder.user();
|
Claims u = ClaimsHolder.current();
|
||||||
// ...
|
// ...
|
||||||
}, oidc.requireRole("admin"));
|
}, auth.requireRole("admin"));
|
||||||
|
|
||||||
// Multiple roles (OR): passes if user holds any one of them
|
// Multiple roles (OR): passes if user holds any one of them
|
||||||
app.get("/api/reports", (req, res) -> { ... }, oidc.requireRole("admin", "reports-viewer"));
|
app.get("/api/reports", (req, res) -> { ... }, auth.requireRole("admin", "reports-viewer"));
|
||||||
|
|
||||||
// Require all listed scopes
|
// Require all listed scopes
|
||||||
app.post("/api/orders", (req, res) -> { ... }, oidc.requireScopes("orders:write", "payments:write"));
|
app.post("/api/orders", (req, res) -> { ... }, auth.requireScopes("orders:write", "payments:write"));
|
||||||
|
|
||||||
// Require at least one listed scope
|
// Require at least one listed scope
|
||||||
app.post("/api/payments", (req, res) -> { ... }, oidc.requireAnyScope("payments:write", "payments:admin"));
|
app.post("/api/payments", (req, res) -> { ... }, auth.requireAnyScope("payments:write", "payments:admin"));
|
||||||
```
|
```
|
||||||
|
|
||||||
`oidc.protect()` / `oidc.requireRole(...)` / `oidc.requireScopes(...)` return a `Middleware` — a composable
|
`auth.protect()` / `auth.requireRole(...)` / `auth.requireScopes(...)` return a `Middleware` — a composable
|
||||||
`Handler → Handler` wrapper. Flash applies middleware right-to-left so the OIDC check
|
`Handler → Handler` wrapper. Flash applies middleware right-to-left so the authentication check
|
||||||
runs before your handler.
|
runs before your handler.
|
||||||
|
|
||||||
## Accessing the authenticated user
|
## Accessing the authenticated user
|
||||||
|
|
||||||
`ClaimsHolder` holds the JWT claims for the current request in a `ThreadLocal`.
|
`ClaimsHolder` holds the JWT claims for the current request in a `ThreadLocal`.
|
||||||
It is populated by the OIDC middleware before your handler runs and cleared in the
|
It is populated by `AuthMiddleware` — from `flash-ext-auth-core` — once this source has
|
||||||
`finally` block afterward. It is safe with virtual threads (each request gets its
|
authenticated the request, and cleared in the `finally` block afterward. Nothing outside that
|
||||||
|
module can write to it. It is safe with virtual threads (each request gets its
|
||||||
own virtual thread, so `ThreadLocal` values are naturally isolated).
|
own virtual thread, so `ThreadLocal` values are naturally isolated).
|
||||||
|
|
||||||
### OidcUser (preferred)
|
### Claims (preferred)
|
||||||
|
|
||||||
```java
|
```java
|
||||||
OidcUser u = ClaimsHolder.user(); // never null inside a protected handler
|
Claims u = ClaimsHolder.current(); // never null inside a protected handler
|
||||||
|
|
||||||
String sub = u.sub(); // unique user ID
|
String sub = u.sub(); // unique user ID
|
||||||
String email = u.email();
|
String email = u.email();
|
||||||
@@ -241,7 +248,7 @@ Map<String, Object> all = u.claims();
|
|||||||
### Raw access (escape hatch)
|
### Raw access (escape hatch)
|
||||||
|
|
||||||
```java
|
```java
|
||||||
Map<String, Object> claims = ClaimsHolder.get();
|
Map<String, Object> claims = ClaimsHolder.map();
|
||||||
String email = ClaimsHolder.claim("email");
|
String email = ClaimsHolder.claim("email");
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -340,7 +347,7 @@ Quick path to test `@ScopesAllowed` end-to-end:
|
|||||||
- Include it in `OidcConfig.scopes(...)`, e.g. `"openid profile email orders:write"`
|
- Include it in `OidcConfig.scopes(...)`, e.g. `"openid profile email orders:write"`
|
||||||
5. **Protect a handler**
|
5. **Protect a handler**
|
||||||
- `@ScopesAllowed("orders:write")` on class-based handlers
|
- `@ScopesAllowed("orders:write")` on class-based handlers
|
||||||
- or `oidc.requireScopes("orders:write")` for lambda routes
|
- or `auth.requireScopes("orders:write")` for lambda routes
|
||||||
6. **Verify behavior**
|
6. **Verify behavior**
|
||||||
- token with scope -> 200
|
- token with scope -> 200
|
||||||
- token without scope -> 403 + `WWW-Authenticate: ... insufficient_scope`
|
- token without scope -> 403 + `WWW-Authenticate: ... insufficient_scope`
|
||||||
@@ -354,24 +361,29 @@ Useful token inspection flow while testing:
|
|||||||
|
|
||||||
## Session store
|
## Session store
|
||||||
|
|
||||||
The default `InMemoryOidcSessionStore` is sufficient for single-instance deployments.
|
Sessions live in `flash-ext-auth-core`'s `Session`/`SessionStore`; this extension keeps its
|
||||||
For clustered deployments, implement `OidcSessionStore`:
|
access, id and refresh tokens in `Session.attributes()` under its own keys, so renewal stays here
|
||||||
|
and core carries no OAuth2 vocabulary. See
|
||||||
|
[`../flash-ext-auth-core/docs/sessions.md`](../flash-ext-auth-core/docs/sessions.md).
|
||||||
|
|
||||||
|
The default `InMemorySessionStore` is sufficient for single-instance deployments.
|
||||||
|
For clustered deployments, implement `SessionStore`:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
public interface OidcSessionStore {
|
public interface SessionStore {
|
||||||
void save(OidcSession session);
|
void save(Session session);
|
||||||
Optional<OidcSession> find(String sessionId);
|
Optional<Session> find(String sessionId);
|
||||||
void delete(String sessionId);
|
void delete(String sessionId);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```java
|
```java
|
||||||
OidcConfig.builder(...)
|
OidcConfig.builder(...)
|
||||||
.sessionStore(new RedisOidcSessionStore(redisClient))
|
.sessionStore(new RedisSessionStore(redisClient))
|
||||||
.build()
|
.build()
|
||||||
```
|
```
|
||||||
|
|
||||||
`OidcSession` fields: `id`, `accessToken`, `idToken`, `refreshToken`, `expiresAt` (`Instant`), `claims` (merged map).
|
`Session` fields: `id`, `accessToken`, `idToken`, `refreshToken`, `expiresAt` (`Instant`), `claims` (merged map).
|
||||||
|
|
||||||
## Logout
|
## Logout
|
||||||
|
|
||||||
@@ -401,7 +413,7 @@ Authorization: Bearer <access_token>
|
|||||||
```
|
```
|
||||||
|
|
||||||
The token must be a JWT (opaque tokens are not supported). Claims are available via
|
The token must be a JWT (opaque tokens are not supported). Claims are available via
|
||||||
`ClaimsHolder.user()` as usual.
|
`ClaimsHolder.current()` as usual.
|
||||||
|
|
||||||
## Multi-tenant
|
## Multi-tenant
|
||||||
|
|
||||||
@@ -420,7 +432,7 @@ app.install(new OidcExtension(tenantA))
|
|||||||
```
|
```
|
||||||
|
|
||||||
To reference a specific tenant's middleware on lambda routes, keep the extension instances
|
To reference a specific tenant's middleware on lambda routes, keep the extension instances
|
||||||
and retrieve `OidcMiddleware` from context after `start()`:
|
and retrieve `AuthMiddleware` from context after `start()`:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
OidcExtension extA = new OidcExtension(tenantA);
|
OidcExtension extA = new OidcExtension(tenantA);
|
||||||
@@ -432,10 +444,10 @@ FlashApp app = FlashApp.create(8080)
|
|||||||
.start()
|
.start()
|
||||||
.join(); // wait for bind
|
.join(); // wait for bind
|
||||||
|
|
||||||
OidcMiddleware mwA = app.ctx().require(OidcMiddleware.class); // last registered = tenantB
|
AuthMiddleware mwA = app.ctx().require(AuthMiddleware.class); // last registered = tenantB
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** because both extensions register `OidcMiddleware.class` in the same context,
|
> **Note:** because both extensions register `AuthMiddleware.class` in the same context,
|
||||||
> only the last one wins under that key. For multi-tenant setups, use distinct context
|
> only the last one wins under that key. For multi-tenant setups, use distinct context
|
||||||
> keys or provide middleware under a wrapper/alias type, or use lambda routes with explicit
|
> keys or provide middleware under a wrapper/alias type, or use lambda routes with explicit
|
||||||
> middleware captured from the extension instance before `install()`.
|
> middleware captured from the extension instance before `install()`.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# flash-ext-auth-oidc
|
||||||
|
|
||||||
|
OpenID Connect for Flash: the authorization-code flow with PKCE, JWKS-validated bearer tokens,
|
||||||
|
server-side sessions with silent refresh, and single logout.
|
||||||
|
|
||||||
|
It is a **credential source** for [`flash-ext-auth-core`](../../flash-ext-auth-core/docs/README.md),
|
||||||
|
which owns everything downstream of "who is this caller" — `@Authenticated`, `@RolesAllowed`,
|
||||||
|
`@ScopesAllowed`, `ClaimsHolder`. Installing this extension installs that machinery too; you do not
|
||||||
|
install `flash-ext-auth-core` yourself.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```java
|
||||||
|
app.install(new OidcExtension(
|
||||||
|
OidcConfig.builder(
|
||||||
|
"https://keycloak.example.com/realms/myrealm",
|
||||||
|
"my-app", "secret", "/auth/callback")
|
||||||
|
.rolesClaimPath("realm_access.roles")
|
||||||
|
.https()
|
||||||
|
.build()));
|
||||||
|
```
|
||||||
|
|
||||||
|
That is the whole integration. Discovery runs at boot and fails fast if the issuer is unreachable,
|
||||||
|
so a misconfigured provider is a startup crash rather than a 500 on the first login.
|
||||||
|
|
||||||
|
`OidcConfig.fromEnv()` reads the same settings from `OIDC_*` environment variables, and
|
||||||
|
`OidcConfig.keycloak(serverUrl, realm, ...)` builds the issuer URL for you.
|
||||||
|
|
||||||
|
## What it registers
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `GET {prefix}/login` | Builds the authorization URL with PKCE + state and redirects |
|
||||||
|
| `GET {prefix}/callback` | Validates state and nonce, exchanges the code, creates the session |
|
||||||
|
| `POST {prefix}/logout` | Ends the session and redirects to the provider's end-session endpoint |
|
||||||
|
|
||||||
|
`{prefix}` is `routePrefix` (default `/auth`). Logout is a `POST` on purpose — a `GET` logout is
|
||||||
|
one `<img>` tag away from being triggered by any page the user visits.
|
||||||
|
|
||||||
|
In the context it provides `AuthMiddleware` (from auth-core), `OidcCredentialSource` and
|
||||||
|
`JwtValidator`.
|
||||||
|
|
||||||
|
## How a request is resolved
|
||||||
|
|
||||||
|
1. `Authorization: Bearer …` — validated against the issuer's JWKS.
|
||||||
|
2. `oidc_session` cookie — looked up in the `SessionStore`; if the access token has expired and a
|
||||||
|
refresh token is present, refreshed transparently and the session replaced.
|
||||||
|
3. Neither, and the client sent `Accept: application/json` → `401` with a
|
||||||
|
`WWW-Authenticate: Bearer` challenge.
|
||||||
|
4. Neither, and it looks like a browser → redirect to `{prefix}/login?redirect={path}`.
|
||||||
|
|
||||||
|
Points 3 and 4 are why the source distinguishes "no credential" from "bad credential": an API
|
||||||
|
client must not be redirected into an HTML sign-in page, and a browser must not be left staring at
|
||||||
|
a bare 401.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Setting | Default | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `issuer`, `clientId`, `clientSecret`, `redirectUri` | — | required |
|
||||||
|
| `scopes` | `openid profile email` | |
|
||||||
|
| `routePrefix` | `/auth` | |
|
||||||
|
| `selfScheme` | `http` | `https()` behind TLS; only used when no `X-Forwarded-Proto` |
|
||||||
|
| `rolesClaimPath` | `realm_access.roles` | Keycloak's spelling; `groups` for Authelia |
|
||||||
|
| `scopeClaimPaths` | `scope,scp` | comma-separated, tried in order |
|
||||||
|
| `algorithm` | `RS256` | |
|
||||||
|
| `postLogoutRedirectUri` | `/` | |
|
||||||
|
| `sessionStore` | `InMemorySessionStore` | swap for Redis/JDBC across instances |
|
||||||
|
| `clientAuthMethod` | `POST` | token endpoint client authentication |
|
||||||
|
| `insecureTls()` | off | dev only, skips certificate validation |
|
||||||
|
| `schemeName` | derived from the issuer | OpenAPI security scheme name |
|
||||||
|
|
||||||
|
A relative `redirectUri` (starting with `/`) is resolved per request against the incoming `Host`,
|
||||||
|
or `X-Forwarded-Host`/`-Proto` when behind a proxy — so one build works in dev and behind TLS
|
||||||
|
without a second configuration.
|
||||||
|
|
||||||
|
## Sessions
|
||||||
|
|
||||||
|
A session holds the claims plus the access, id and refresh tokens, the last three in
|
||||||
|
`Session.attributes()` under this extension's own keys. Core never reads them; renewal happens
|
||||||
|
here. See [`../../flash-ext-auth-core/docs/sessions.md`](../../flash-ext-auth-core/docs/sessions.md).
|
||||||
|
|
||||||
|
## Multiple providers
|
||||||
|
|
||||||
|
Two issuers on one server, each with its own route prefix:
|
||||||
|
|
||||||
|
```java
|
||||||
|
app.install(new OidcExtension(tenantAConfig)) // routePrefix("/tenantA/auth")
|
||||||
|
.install(new OidcExtension(tenantBConfig)); // routePrefix("/tenantB/auth")
|
||||||
|
```
|
||||||
|
|
||||||
|
Both are known at boot. Registering an issuer at runtime — a customer connecting their own IdP from
|
||||||
|
a settings page — is not supported.
|
||||||
|
|
||||||
|
## Interop
|
||||||
|
|
||||||
|
See [`interop.md`](interop.md) for how this extension fits with `flash-ext-auth-core`,
|
||||||
|
`flash-ext-openapi` and `flash-ext-mcp`.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Interop
|
||||||
|
|
||||||
|
## flash-ext-auth-core
|
||||||
|
|
||||||
|
A hard dependency, and the reason this extension is as small as it is. The division:
|
||||||
|
|
||||||
|
| Here | `flash-ext-auth-core` |
|
||||||
|
|---|---|
|
||||||
|
| Discovery, JWKS, PKCE, token endpoint | `@Authenticated`, `@RolesAllowed`, `@ScopesAllowed` |
|
||||||
|
| `/login`, `/callback`, `/logout` | `ClaimsHolder`, `Claims` |
|
||||||
|
| Bearer and cookie resolution, refresh | Role and scope matching |
|
||||||
|
| RFC 6750 `WWW-Authenticate` challenges | `Session`, `SessionStore` |
|
||||||
|
|
||||||
|
`OidcExtension` builds an `OidcCredentialSource`, hands it to `AuthMiddleware.install(...)`, and
|
||||||
|
that publishes the middleware and registers the annotation processor. Everything a handler
|
||||||
|
annotation does is core's code running against claims this extension produced.
|
||||||
|
|
||||||
|
Consequence worth knowing: `@RolesAllowed` is not OIDC-specific and never was. An app that swaps
|
||||||
|
this extension for another credential source keeps every annotation it had.
|
||||||
|
|
||||||
|
## flash-ext-openapi
|
||||||
|
|
||||||
|
Optional, and resolved lazily so this extension runs standalone when openapi is not on the
|
||||||
|
classpath. When it is, an `OpenApiContributor` is registered that emits an `oauth2` security scheme
|
||||||
|
with the `authorizationCode` flow, filled in from the discovery document:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"securitySchemes": {
|
||||||
|
"myrealm": {
|
||||||
|
"type": "oauth2",
|
||||||
|
"flows": { "authorizationCode": { "authorizationUrl": "…", "tokenUrl": "…", "scopes": {…} } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-operation security comes from the same annotations the middleware reads, so the spec and the
|
||||||
|
enforcement cannot drift: both call `AuthPolicy.compileFromAnnotations`.
|
||||||
|
|
||||||
|
The scheme name is `schemeName`, derived from the last path segment of the issuer (a Keycloak realm
|
||||||
|
name, usually) unless set explicitly.
|
||||||
|
|
||||||
|
## flash-ext-mcp
|
||||||
|
|
||||||
|
`McpSecurity` asks whether **this** extension is installed — `ctx.find(OidcCredentialSource.class)`
|
||||||
|
— and not merely whether something authenticates:
|
||||||
|
|
||||||
|
| Policy | this extension installed | absent |
|
||||||
|
|---|---|---|
|
||||||
|
| `REQUIRED` | protected | **boot fails** |
|
||||||
|
| `AUTO` | protected | unprotected, warning logged |
|
||||||
|
| `NONE` | never protected | unprotected |
|
||||||
|
|
||||||
|
That distinction is deliberate. `REQUIRED` means "a real OAuth2 authorization server is protecting
|
||||||
|
this endpoint", because everything it turns on — RFC 9728 Protected Resource Metadata, RFC 8707
|
||||||
|
audience binding, `WWW-Authenticate` challenges carrying `resource_metadata` — is meaningless
|
||||||
|
without an issuer. An app that authenticates some other way must not satisfy it by accident.
|
||||||
|
|
||||||
|
When it is installed, `McpOidcIntegration` derives the whole resource-server configuration from the
|
||||||
|
source with no extra `McpConfig` calls:
|
||||||
|
|
||||||
|
- the MCP route is wrapped with `authMw.withSource(source.withResourceMetadata(path)).protect()` —
|
||||||
|
the same validation every other route uses, plus the `resource_metadata` challenge parameter;
|
||||||
|
- an audience guard runs after it and rejects any token whose `aud` does not include this
|
||||||
|
endpoint's resource identifier;
|
||||||
|
- the resource identifier is resolved per request from `X-Forwarded-Host`/`-Proto`, or the `Host`
|
||||||
|
header and `selfScheme`.
|
||||||
|
|
||||||
|
An app that does **not** use OAuth2 can still guard `/mcp`: set `McpSecurity.NONE` and pass its own
|
||||||
|
guard to `McpConfig.middleware(...)`.
|
||||||
|
|
||||||
|
## Migrating from flash-ext-oidc
|
||||||
|
|
||||||
|
The module was renamed and its generic half moved. Mechanically:
|
||||||
|
|
||||||
|
| Was | Now |
|
||||||
|
|---|---|
|
||||||
|
| `flash-ext-oidc` (artifact) | `flash-ext-auth-oidc` |
|
||||||
|
| `dev.relism.flash.ext.oidc.Authenticated` (and `RolesAllowed`, `ScopesAllowed`) | `dev.relism.flash.ext.auth.…` |
|
||||||
|
| `OidcMiddleware` | `AuthMiddleware` (`dev.relism.flash.ext.auth`) |
|
||||||
|
| `ctx.find(OidcMiddleware.class)` | `ctx.find(AuthMiddleware.class)` |
|
||||||
|
| `OidcUser` | `Claims` |
|
||||||
|
| `ClaimsHolder.user()` | `ClaimsHolder.current()` |
|
||||||
|
| `ClaimsHolder.get()` | `ClaimsHolder.map()` |
|
||||||
|
| `OidcSession`, `OidcSessionStore`, `InMemoryOidcSessionStore` | `Session`, `SessionStore`, `InMemorySessionStore` |
|
||||||
|
| `session.isAccessTokenExpired()` | `session.isExpired()` |
|
||||||
|
| `session.idToken()` | `session.attributeAsString(OidcCredentialSource.ID_TOKEN)` |
|
||||||
|
|
||||||
|
`OidcConfig`, `OidcExtension` and every setting on them are unchanged.
|
||||||
+6
-2
@@ -7,12 +7,16 @@
|
|||||||
<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-oidc</artifactId>
|
<artifactId>flash-ext-auth-oidc</artifactId>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-auth-core</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash</artifactId>
|
<artifactId>flash</artifactId>
|
||||||
+9
-6
@@ -1,5 +1,8 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
package dev.relism.flash.ext.oidc;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.auth.InMemorySessionStore;
|
||||||
|
import dev.relism.flash.ext.auth.SessionStore;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full OIDC client configuration. Build via
|
* Full OIDC client configuration. Build via
|
||||||
* {@link #builder(String, String, String, String)} or {@link #fromEnv()}.
|
* {@link #builder(String, String, String, String)} or {@link #fromEnv()}.
|
||||||
@@ -50,7 +53,7 @@ public final class OidcConfig {
|
|||||||
private final String scopeClaimPaths;
|
private final String scopeClaimPaths;
|
||||||
private final String algorithm;
|
private final String algorithm;
|
||||||
private final String postLogoutRedirectUri;
|
private final String postLogoutRedirectUri;
|
||||||
private final OidcSessionStore sessionStore;
|
private final SessionStore sessionStore;
|
||||||
private final boolean insecureTls;
|
private final boolean insecureTls;
|
||||||
private final ClientAuthMethod clientAuthMethod;
|
private final ClientAuthMethod clientAuthMethod;
|
||||||
private final String schemeName;
|
private final String schemeName;
|
||||||
@@ -68,7 +71,7 @@ public final class OidcConfig {
|
|||||||
this.algorithm = b.algorithm;
|
this.algorithm = b.algorithm;
|
||||||
this.postLogoutRedirectUri = b.postLogoutRedirectUri;
|
this.postLogoutRedirectUri = b.postLogoutRedirectUri;
|
||||||
this.sessionStore = b.sessionStore != null ? b.sessionStore
|
this.sessionStore = b.sessionStore != null ? b.sessionStore
|
||||||
: new InMemoryOidcSessionStore();
|
: new InMemorySessionStore();
|
||||||
this.insecureTls = b.insecureTls;
|
this.insecureTls = b.insecureTls;
|
||||||
this.clientAuthMethod = b.clientAuthMethod;
|
this.clientAuthMethod = b.clientAuthMethod;
|
||||||
this.schemeName = b.schemeName != null ? b.schemeName : deriveScheme(this.issuer);
|
this.schemeName = b.schemeName != null ? b.schemeName : deriveScheme(this.issuer);
|
||||||
@@ -88,7 +91,7 @@ public final class OidcConfig {
|
|||||||
public String scopeClaimPaths() { return scopeClaimPaths; }
|
public String scopeClaimPaths() { return scopeClaimPaths; }
|
||||||
public String algorithm() { return algorithm; }
|
public String algorithm() { return algorithm; }
|
||||||
public String postLogoutRedirectUri() { return postLogoutRedirectUri; }
|
public String postLogoutRedirectUri() { return postLogoutRedirectUri; }
|
||||||
public OidcSessionStore sessionStore() { return sessionStore; }
|
public SessionStore sessionStore() { return sessionStore; }
|
||||||
/** If {@code true}, TLS certificate validation is skipped. <b>Never use in production.</b> */
|
/** If {@code true}, TLS certificate validation is skipped. <b>Never use in production.</b> */
|
||||||
public boolean insecureTls() { return insecureTls; }
|
public boolean insecureTls() { return insecureTls; }
|
||||||
public ClientAuthMethod clientAuthMethod() { return clientAuthMethod; }
|
public ClientAuthMethod clientAuthMethod() { return clientAuthMethod; }
|
||||||
@@ -190,7 +193,7 @@ public final class OidcConfig {
|
|||||||
private String scopeClaimPaths = "scope,scp";
|
private String scopeClaimPaths = "scope,scp";
|
||||||
private String algorithm = "RS256";
|
private String algorithm = "RS256";
|
||||||
private String postLogoutRedirectUri = "/";
|
private String postLogoutRedirectUri = "/";
|
||||||
private OidcSessionStore sessionStore;
|
private SessionStore sessionStore;
|
||||||
private boolean insecureTls = false;
|
private boolean insecureTls = false;
|
||||||
private ClientAuthMethod clientAuthMethod = ClientAuthMethod.POST;
|
private ClientAuthMethod clientAuthMethod = ClientAuthMethod.POST;
|
||||||
private String schemeName = null;
|
private String schemeName = null;
|
||||||
@@ -218,8 +221,8 @@ public final class OidcConfig {
|
|||||||
public Builder algorithm(String algorithm) { this.algorithm = algorithm; return this; }
|
public Builder algorithm(String algorithm) { this.algorithm = algorithm; return this; }
|
||||||
/** Where to redirect after logout (default: {@code /}). */
|
/** Where to redirect after logout (default: {@code /}). */
|
||||||
public Builder postLogoutRedirectUri(String uri) { this.postLogoutRedirectUri = uri; return this; }
|
public Builder postLogoutRedirectUri(String uri) { this.postLogoutRedirectUri = uri; return this; }
|
||||||
/** Custom session store (default: {@link InMemoryOidcSessionStore}). */
|
/** Custom session store (default: {@link InMemorySessionStore}). */
|
||||||
public Builder sessionStore(OidcSessionStore store) { this.sessionStore = store; return this; }
|
public Builder sessionStore(SessionStore store) { this.sessionStore = store; return this; }
|
||||||
/**
|
/**
|
||||||
* Disables TLS certificate verification for all HTTP calls made by this extension.
|
* Disables TLS certificate verification for all HTTP calls made by this extension.
|
||||||
* <b>Only use in development with self-signed certificates — never in production.</b>
|
* <b>Only use in development with self-signed certificates — never in production.</b>
|
||||||
+333
@@ -0,0 +1,333 @@
|
|||||||
|
package dev.relism.flash.ext.oidc;
|
||||||
|
|
||||||
|
import dev.relism.flash.exceptions.HttpException;
|
||||||
|
import dev.relism.flash.ext.auth.CredentialSource;
|
||||||
|
import dev.relism.flash.ext.auth.Session;
|
||||||
|
import dev.relism.flash.models.Response;
|
||||||
|
import dev.relism.flash.models.Request;
|
||||||
|
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The OpenID Connect {@link CredentialSource}: it turns what a request carries into claims, and
|
||||||
|
* rejects it the way OAuth2 says to when it cannot. Authorization on those claims is
|
||||||
|
* {@code flash-ext-auth-core}'s job, not this class's.
|
||||||
|
*
|
||||||
|
* <p>Resolution order on each request:
|
||||||
|
* <ol>
|
||||||
|
* <li>{@code Authorization: Bearer ...} header — validated via JWKS ({@link JwtValidator}).</li>
|
||||||
|
* <li>{@code oidc_session} cookie — looked up in {@link dev.relism.flash.ext.auth.SessionStore}; transparently
|
||||||
|
* refreshed if the access token is expired.</li>
|
||||||
|
* <li>Browser clients (no {@code Accept: application/json}) → redirect to
|
||||||
|
* {@code {routePrefix}/login?redirect={path}}.</li>
|
||||||
|
* <li>API clients → 401 with a {@code WWW-Authenticate: Bearer} challenge.</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
public final class OidcCredentialSource implements CredentialSource {
|
||||||
|
|
||||||
|
private static final String BEARER = "Bearer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys this source stores its OAuth2 tokens under in {@link Session#attributes()}. Core keeps
|
||||||
|
* the session; the tokens inside it are nobody else's business.
|
||||||
|
*/
|
||||||
|
static final String ACCESS_TOKEN = "oidc.access_token";
|
||||||
|
static final String ID_TOKEN = "oidc.id_token";
|
||||||
|
static final String REFRESH_TOKEN = "oidc.refresh_token";
|
||||||
|
|
||||||
|
/** The one place an OIDC session is built, so its attribute keys stay in one place too. */
|
||||||
|
static Session newSession(String id, String accessToken, String idToken, String refreshToken,
|
||||||
|
Instant expiresAt, Map<String, Object> claims) {
|
||||||
|
Map<String, Object> attributes = new HashMap<>(3);
|
||||||
|
if (accessToken != null) attributes.put(ACCESS_TOKEN, accessToken);
|
||||||
|
if (idToken != null) attributes.put(ID_TOKEN, idToken);
|
||||||
|
if (refreshToken != null) attributes.put(REFRESH_TOKEN, refreshToken);
|
||||||
|
return new Session(id, claims, expiresAt, attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final JwtValidator validator;
|
||||||
|
private final OidcConfig config;
|
||||||
|
private final OidcProviderMetadata meta;
|
||||||
|
private final TokenClient tokenClient;
|
||||||
|
private final String resourceMetadataPath;
|
||||||
|
|
||||||
|
OidcCredentialSource(JwtValidator validator, OidcConfig config,
|
||||||
|
OidcProviderMetadata meta, TokenClient tokenClient) {
|
||||||
|
this(validator, config, meta, tokenClient, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private OidcCredentialSource(JwtValidator validator, OidcConfig config,
|
||||||
|
OidcProviderMetadata meta, TokenClient tokenClient,
|
||||||
|
String resourceMetadataPath) {
|
||||||
|
this.validator = validator;
|
||||||
|
this.config = config;
|
||||||
|
this.meta = meta;
|
||||||
|
this.tokenClient = tokenClient;
|
||||||
|
this.resourceMetadataPath = resourceMetadataPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- CredentialSource -----------------------------------------------------
|
||||||
|
|
||||||
|
/** OIDC issuer this source validates tokens against — the {@code iss} claim it enforces. */
|
||||||
|
public String issuer() { return config.issuer(); }
|
||||||
|
|
||||||
|
/** Scheme used to build this app's own absolute URLs — see {@link OidcConfig#selfScheme()}. */
|
||||||
|
public String selfScheme() { return config.selfScheme(); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A copy of this source whose 401 challenges also carry {@code resource_metadata}
|
||||||
|
* (RFC 9728 §5.1), resolved against the request's own scheme and host exactly like
|
||||||
|
* {@link OidcExtension}'s redirect URIs. {@code path} is absolute, e.g.
|
||||||
|
* {@code "/.well-known/oauth-protected-resource/mcp"}.
|
||||||
|
*
|
||||||
|
* <p>Used by {@code flash-ext-mcp} to make its Protected Resource Metadata document
|
||||||
|
* discoverable straight from the {@code WWW-Authenticate} header, per the MCP Authorization
|
||||||
|
* spec.
|
||||||
|
*/
|
||||||
|
public OidcCredentialSource withResourceMetadata(String path) {
|
||||||
|
return new OidcCredentialSource(validator, config, meta, tokenClient, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> authenticate(Request req, Response res) {
|
||||||
|
return resolve(req, res, resourceMetadataPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> peek(Request req) {
|
||||||
|
return resolveQuiet(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String insufficientScopeChallenge(String[] requiredScopes) {
|
||||||
|
return bearerChallenge() + ", error=\"insufficient_scope\", scope=\""
|
||||||
|
+ quoted(spaceDelimited(requiredScopes)) + "\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Internals ------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like {@link #resolve} but never redirects or throws — returns {@code null} silently
|
||||||
|
* when no valid credentials are present. Used by {@link #optional()}.
|
||||||
|
*/
|
||||||
|
private Map<String, Object> resolveQuiet(Request req) {
|
||||||
|
String bearerToken = extractBearerToken(req.header("Authorization"));
|
||||||
|
if (bearerToken != null) {
|
||||||
|
try {
|
||||||
|
return validator.validate(bearerToken);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String sessionId = cookieValue(req, "oidc_session");
|
||||||
|
if (sessionId != null) {
|
||||||
|
Optional<Session> found = config.sessionStore().find(sessionId);
|
||||||
|
if (found.isPresent()) {
|
||||||
|
Session session = found.get();
|
||||||
|
if (!session.isExpired())
|
||||||
|
return session.claims();
|
||||||
|
if (session.attributeAsString(REFRESH_TOKEN) != null) {
|
||||||
|
try {
|
||||||
|
Session refreshed = doRefresh(session);
|
||||||
|
config.sessionStore().save(refreshed);
|
||||||
|
return refreshed.claims();
|
||||||
|
} catch (Exception ignored) { }
|
||||||
|
}
|
||||||
|
config.sessionStore().delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns claims on success, or {@code null} if a redirect was already written to
|
||||||
|
* {@code res}. Throws {@link HttpException} 401/403 for API clients.
|
||||||
|
*/
|
||||||
|
private Map<String, Object> resolve(Request req, Response res) {
|
||||||
|
return resolve(req, res, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> resolve(Request req, Response res, String resourceMetadataPath) {
|
||||||
|
// 1. Bearer token
|
||||||
|
String bearerToken = extractBearerToken(req.header("Authorization"));
|
||||||
|
if (bearerToken != null) {
|
||||||
|
try {
|
||||||
|
return validator.validate(bearerToken);
|
||||||
|
} catch (HttpException e) {
|
||||||
|
res.header("WWW-Authenticate", invalidTokenChallenge(req, resourceMetadataPath));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Session cookie
|
||||||
|
String sessionId = cookieValue(req, "oidc_session");
|
||||||
|
if (sessionId != null) {
|
||||||
|
Optional<Session> found = config.sessionStore().find(sessionId);
|
||||||
|
if (found.isPresent()) {
|
||||||
|
Session session = found.get();
|
||||||
|
|
||||||
|
if (!session.isExpired())
|
||||||
|
return session.claims();
|
||||||
|
|
||||||
|
// Access token expired — try silent refresh
|
||||||
|
if (session.attributeAsString(REFRESH_TOKEN) != null) {
|
||||||
|
try {
|
||||||
|
Session refreshed = doRefresh(session);
|
||||||
|
config.sessionStore().save(refreshed);
|
||||||
|
return refreshed.claims();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// Refresh failed — fall through to re-authenticate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config.sessionStore().delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. No valid credentials
|
||||||
|
String accept = req.header("Accept");
|
||||||
|
if (accept != null && accept.contains("application/json")) {
|
||||||
|
res.header("WWW-Authenticate", bearerChallenge(req, resourceMetadataPath));
|
||||||
|
throw HttpException.unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browser — redirect to login, preserving the original URL in state
|
||||||
|
String loginUrl = config.routePrefix() + "/login?redirect="
|
||||||
|
+ URLEncoder.encode(req.path(), StandardCharsets.UTF_8);
|
||||||
|
res.redirect(loginUrl);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Session doRefresh(Session old) throws Exception {
|
||||||
|
OidcTokenResponse tokens = tokenClient.refresh(
|
||||||
|
meta.tokenEndpoint(), old.attributeAsString(REFRESH_TOKEN));
|
||||||
|
|
||||||
|
return newSession(
|
||||||
|
old.id(),
|
||||||
|
tokens.accessToken(),
|
||||||
|
tokens.idToken() != null ? tokens.idToken() : old.attributeAsString(ID_TOKEN),
|
||||||
|
tokens.refreshToken() != null ? tokens.refreshToken() : old.attributeAsString(REFRESH_TOKEN),
|
||||||
|
Instant.now().plusSeconds(tokens.expiresIn()),
|
||||||
|
mergeRefreshedClaims(tokens, old)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String extractBearerToken(String authorizationHeader) {
|
||||||
|
if (authorizationHeader == null) return null;
|
||||||
|
int len = authorizationHeader.length();
|
||||||
|
int start = 0;
|
||||||
|
while (start < len && Character.isWhitespace(authorizationHeader.charAt(start))) start++;
|
||||||
|
int schemeEnd = start + BEARER.length();
|
||||||
|
if (schemeEnd > len || !authorizationHeader.regionMatches(true, start, BEARER, 0, BEARER.length())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (schemeEnd == len || !Character.isWhitespace(authorizationHeader.charAt(schemeEnd))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int tokenStart = schemeEnd;
|
||||||
|
while (tokenStart < len && Character.isWhitespace(authorizationHeader.charAt(tokenStart))) tokenStart++;
|
||||||
|
if (tokenStart >= len) return null;
|
||||||
|
int tokenEnd = len;
|
||||||
|
while (tokenEnd > tokenStart && Character.isWhitespace(authorizationHeader.charAt(tokenEnd - 1))) tokenEnd--;
|
||||||
|
return tokenEnd > tokenStart ? authorizationHeader.substring(tokenStart, tokenEnd) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String bearerChallenge() {
|
||||||
|
return bearerChallenge(null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String bearerChallenge(Request req, String resourceMetadataPath) {
|
||||||
|
String base = BEARER + " realm=\"" + quoted(config.schemeName()) + "\"";
|
||||||
|
if (resourceMetadataPath == null) return base;
|
||||||
|
return base + ", resource_metadata=\"" + quoted(absoluteSelf(req, resourceMetadataPath)) + "\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
String invalidTokenChallenge() {
|
||||||
|
return invalidTokenChallenge(null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String invalidTokenChallenge(Request req, String resourceMetadataPath) {
|
||||||
|
return bearerChallenge(req, resourceMetadataPath) + ", error=\"invalid_token\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String absoluteSelf(Request req, String path) {
|
||||||
|
if (!path.startsWith("/")) return path;
|
||||||
|
return selfOrigin(req, config.selfScheme()) + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code scheme://host} clients actually reach this app on — the basis for every absolute
|
||||||
|
* URL it publishes about itself (OAuth2 {@code redirect_uri}, the RFC 9728 resource
|
||||||
|
* identifier and the {@code resource_metadata} challenge). Behind a reverse proxy the
|
||||||
|
* request's own {@code Host} is the upstream address the proxy dialled, so
|
||||||
|
* {@code X-Forwarded-Host}/{@code -Proto} win whenever present: without them the app would
|
||||||
|
* name an address no client can resolve, and OAuth2 discovery fails with no error anyone
|
||||||
|
* can trace back to here. Trusted unconditionally — a caller able to reach this app without
|
||||||
|
* passing the proxy can do worse than spoof a self URL.
|
||||||
|
*/
|
||||||
|
public static String selfOrigin(Request req, String fallbackScheme) {
|
||||||
|
String forwardedHost = req.header("X-Forwarded-Host");
|
||||||
|
if (forwardedHost == null) return fallbackScheme + "://" + req.header("Host");
|
||||||
|
String forwardedProto = req.header("X-Forwarded-Proto");
|
||||||
|
return (forwardedProto != null ? forwardedProto : fallbackScheme) + "://" + forwardedHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String spaceDelimited(String[] values) {
|
||||||
|
if (values == null || values.length == 0) return "";
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (int i = 0; i < values.length; i++) {
|
||||||
|
if (i > 0) sb.append(' ');
|
||||||
|
sb.append(values[i]);
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String quoted(String value) {
|
||||||
|
StringBuilder out = new StringBuilder(value.length() + 8);
|
||||||
|
for (int i = 0; i < value.length(); i++) {
|
||||||
|
char c = value.charAt(i);
|
||||||
|
if (c == '"' || c == '\\') out.append('\\');
|
||||||
|
out.append(c);
|
||||||
|
}
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> mergeRefreshedClaims(OidcTokenResponse tokens, Session old) {
|
||||||
|
Map<String, Object> merged = new HashMap<>();
|
||||||
|
// Fall back to old claims first, then overlay fresh token claims
|
||||||
|
merged.putAll(old.claims());
|
||||||
|
if (tokens.accessToken() != null)
|
||||||
|
merged.putAll(JwtUtils.parseClaims(tokens.accessToken()));
|
||||||
|
if (tokens.idToken() != null)
|
||||||
|
merged.putAll(JwtUtils.parseClaims(tokens.idToken()));
|
||||||
|
return Map.copyOf(merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Shared cookie utility (also used by OidcExtension) -------------------
|
||||||
|
|
||||||
|
static String cookieValue(Request req, String name) {
|
||||||
|
String header = req.header("Cookie");
|
||||||
|
if (header == null || header.isBlank()) return null;
|
||||||
|
int len = header.length();
|
||||||
|
int start = 0;
|
||||||
|
while (start < len) {
|
||||||
|
int semi = header.indexOf(';', start);
|
||||||
|
int end = semi < 0 ? len : semi;
|
||||||
|
int eq = header.indexOf('=', start);
|
||||||
|
if (eq > start && eq < end) {
|
||||||
|
int ns = start, ne = eq;
|
||||||
|
while (ns < ne && header.charAt(ns) == ' ') ns++;
|
||||||
|
while (ne > ns && header.charAt(ne-1) == ' ') ne--;
|
||||||
|
if (ne - ns == name.length() && header.regionMatches(ns, name, 0, name.length()))
|
||||||
|
return header.substring(eq + 1, end).strip();
|
||||||
|
}
|
||||||
|
start = end + 1;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-21
@@ -4,6 +4,13 @@ import dev.relism.flash.ext.openapi.OpenApiContributor;
|
|||||||
import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
|
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.ext.auth.AuthConfig;
|
||||||
|
import dev.relism.flash.ext.auth.Session;
|
||||||
|
import dev.relism.flash.ext.auth.AuthMiddleware;
|
||||||
|
import dev.relism.flash.ext.auth.AuthPolicy;
|
||||||
|
import dev.relism.flash.ext.auth.Authenticated;
|
||||||
|
import dev.relism.flash.ext.auth.RolesAllowed;
|
||||||
|
import dev.relism.flash.ext.auth.ScopesAllowed;
|
||||||
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.extension.FlashRegistrar;
|
||||||
@@ -26,7 +33,8 @@ import java.util.*;
|
|||||||
* <p>At {@link #provide}, the extension:
|
* <p>At {@link #provide}, the extension:
|
||||||
* <ol>
|
* <ol>
|
||||||
* <li>Fetches the provider discovery document — fail-fast at startup.</li>
|
* <li>Fetches the provider discovery document — fail-fast at startup.</li>
|
||||||
* <li>Provides {@link OidcMiddleware} and {@link JwtValidator} in the context.</li>
|
* <li>Provides {@link AuthMiddleware}, {@link OidcCredentialSource} and {@link JwtValidator}
|
||||||
|
* in the context.</li>
|
||||||
* <li>Registers annotation processors for {@link Authenticated}, {@link RolesAllowed}
|
* <li>Registers annotation processors for {@link Authenticated}, {@link RolesAllowed}
|
||||||
* and {@link ScopesAllowed}.</li>
|
* and {@link ScopesAllowed}.</li>
|
||||||
* </ol>
|
* </ol>
|
||||||
@@ -62,7 +70,8 @@ public class OidcExtension implements FlashExtension {
|
|||||||
private OidcStateStore stateStore;
|
private OidcStateStore stateStore;
|
||||||
private TokenClient tokenClient;
|
private TokenClient tokenClient;
|
||||||
private JwtValidator validator;
|
private JwtValidator validator;
|
||||||
private OidcMiddleware oidcMw;
|
private OidcCredentialSource source;
|
||||||
|
private AuthMiddleware authMw;
|
||||||
|
|
||||||
public OidcExtension(OidcConfig config) {
|
public OidcExtension(OidcConfig config) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
@@ -71,7 +80,7 @@ public class OidcExtension implements FlashExtension {
|
|||||||
// ── Phase 1: services ─────────────────────────────────────────────────────
|
// ── Phase 1: services ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void provide(FlashContext ctx) {
|
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
HttpClient http = buildHttpClient(config);
|
HttpClient http = buildHttpClient(config);
|
||||||
|
|
||||||
// Discover provider endpoints (blocking; fail fast at startup).
|
// Discover provider endpoints (blocking; fail fast at startup).
|
||||||
@@ -84,21 +93,19 @@ public class OidcExtension implements FlashExtension {
|
|||||||
validator = new JwtValidator(meta.jwksUri(), config.issuer(), config.clientId(), config.algorithm(), http);
|
validator = new JwtValidator(meta.jwksUri(), config.issuer(), config.clientId(), config.algorithm(), http);
|
||||||
stateStore = new OidcStateStore();
|
stateStore = new OidcStateStore();
|
||||||
tokenClient = new TokenClient(http, config);
|
tokenClient = new TokenClient(http, config);
|
||||||
oidcMw = new OidcMiddleware(validator, config, meta, tokenClient);
|
source = new OidcCredentialSource(validator, config, meta, tokenClient);
|
||||||
|
authMw = AuthMiddleware.install(ctx, AuthConfig.builder()
|
||||||
|
.rolesClaimPath(config.rolesClaimPath())
|
||||||
|
.scopeClaimPaths(config.scopeClaimPaths())
|
||||||
|
.build(), source);
|
||||||
|
|
||||||
ctx.provide(OidcMiddleware.class, oidcMw);
|
ctx.provide(OidcCredentialSource.class, source);
|
||||||
ctx.provide(JwtValidator.class, validator);
|
ctx.provide(JwtValidator.class, validator);
|
||||||
|
|
||||||
ctx.addAnnotationProcessor(handlerClass -> {
|
ctx.onReady(() -> registerRoutes(app, ctx));
|
||||||
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
|
|
||||||
return policy != null ? List.of(oidcMw.policyMiddleware(policy)) : List.of();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Phase 2: routes ───────────────────────────────────────────────────────
|
private void registerRoutes(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
|
|
||||||
@Override
|
|
||||||
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
|
|
||||||
String prefix = config.routePrefix();
|
String prefix = config.routePrefix();
|
||||||
|
|
||||||
// ── GET {prefix}/login ────────────────────────────────────────────────
|
// ── GET {prefix}/login ────────────────────────────────────────────────
|
||||||
@@ -163,7 +170,7 @@ public class OidcExtension implements FlashExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object> claims = mergeClaims(tokens);
|
Map<String, Object> claims = mergeClaims(tokens);
|
||||||
OidcSession session = new OidcSession(
|
Session session = OidcCredentialSource.newSession(
|
||||||
UUID.randomUUID().toString(),
|
UUID.randomUUID().toString(),
|
||||||
tokens.accessToken(), tokens.idToken(), tokens.refreshToken(),
|
tokens.accessToken(), tokens.idToken(), tokens.refreshToken(),
|
||||||
Instant.now().plusSeconds(tokens.expiresIn()), claims);
|
Instant.now().plusSeconds(tokens.expiresIn()), claims);
|
||||||
@@ -177,12 +184,12 @@ public class OidcExtension implements FlashExtension {
|
|||||||
// ── POST {prefix}/logout ──────────────────────────────────────────────
|
// ── POST {prefix}/logout ──────────────────────────────────────────────
|
||||||
// Invalidates the local session and redirects to end_session_endpoint.
|
// Invalidates the local session and redirects to end_session_endpoint.
|
||||||
app.post(prefix + "/logout", (req, res) -> {
|
app.post(prefix + "/logout", (req, res) -> {
|
||||||
String sessionId = OidcMiddleware.cookieValue(req, "oidc_session");
|
String sessionId = OidcCredentialSource.cookieValue(req, "oidc_session");
|
||||||
String idTokenHint = null;
|
String idTokenHint = null;
|
||||||
|
|
||||||
if (sessionId != null) {
|
if (sessionId != null) {
|
||||||
OidcSession session = config.sessionStore().find(sessionId).orElse(null);
|
Session session = config.sessionStore().find(sessionId).orElse(null);
|
||||||
if (session != null) idTokenHint = session.idToken();
|
if (session != null) idTokenHint = session.attributeAsString(OidcCredentialSource.ID_TOKEN);
|
||||||
config.sessionStore().delete(sessionId);
|
config.sessionStore().delete(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,7 +259,7 @@ public class OidcExtension implements FlashExtension {
|
|||||||
|
|
||||||
private String absoluteSelf(Request req, String uri) {
|
private String absoluteSelf(Request req, String uri) {
|
||||||
if (!uri.startsWith("/")) return uri;
|
if (!uri.startsWith("/")) return uri;
|
||||||
return config.selfScheme() + "://" + req.header("Host") + uri;
|
return OidcCredentialSource.selfOrigin(req, config.selfScheme()) + uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String enc(String v) {
|
private static String enc(String v) {
|
||||||
@@ -298,12 +305,12 @@ public class OidcExtension implements FlashExtension {
|
|||||||
OpenApiOperationContribution.Builder out =
|
OpenApiOperationContribution.Builder out =
|
||||||
OpenApiOperationContribution.builder();
|
OpenApiOperationContribution.builder();
|
||||||
|
|
||||||
List<String> operationScopes = OidcAuthPolicy.openApiScopesFor(handlerClass);
|
List<String> operationScopes = AuthPolicy.openApiScopesFor(handlerClass);
|
||||||
if (operationScopes != null) {
|
if (operationScopes != null) {
|
||||||
out.security(config.schemeName(), operationScopes);
|
out.security(config.schemeName(), operationScopes);
|
||||||
}
|
}
|
||||||
|
|
||||||
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
|
AuthPolicy policy = AuthPolicy.compileFromAnnotations(handlerClass);
|
||||||
if (policy == null || policy.optionalAuth()) return out.build();
|
if (policy == null || policy.optionalAuth()) return out.build();
|
||||||
|
|
||||||
out.response(401, OpenApiResponseContribution.of("Authentication required"));
|
out.response(401, OpenApiResponseContribution.of("Authentication required"));
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
package dev.relism.flash.ext.oidc;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What stayed behind when authorization moved to {@code flash-ext-auth-core}: reading a bearer
|
||||||
|
* token off the wire, and the RFC 6750 challenges this source answers with. The matching of
|
||||||
|
* claims those credentials produce is {@code ClaimMatchingTest}'s job now.
|
||||||
|
*/
|
||||||
|
class OidcCredentialSourceTest {
|
||||||
|
|
||||||
|
private static OidcCredentialSource source() {
|
||||||
|
return new OidcCredentialSource(null, OidcConfig
|
||||||
|
.builder("https://idp.example.com", "client", "secret", "/auth/callback")
|
||||||
|
.build(), null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void extractBearerToken_acceptsCaseInsensitiveBearerAndTrimsSpaces() {
|
||||||
|
assertEquals("abc.def.ghi", OidcCredentialSource.extractBearerToken("Bearer abc.def.ghi"));
|
||||||
|
assertEquals("abc", OidcCredentialSource.extractBearerToken(" bearer abc "));
|
||||||
|
assertNull(OidcCredentialSource.extractBearerToken("Basic Zm9vOmJhcg=="));
|
||||||
|
assertNull(OidcCredentialSource.extractBearerToken("Bearer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bearerChallenge_containsRealmAndRfcErrors() {
|
||||||
|
OidcCredentialSource src = source();
|
||||||
|
|
||||||
|
String basic = src.bearerChallenge();
|
||||||
|
String invalid = src.invalidTokenChallenge();
|
||||||
|
String insufficient = src.insufficientScopeChallenge(new String[]{"orders:read", "payments:write"});
|
||||||
|
|
||||||
|
assertTrue(basic.startsWith("Bearer realm=\""));
|
||||||
|
assertTrue(invalid.contains("error=\"invalid_token\""));
|
||||||
|
assertTrue(insufficient.contains("error=\"insufficient_scope\""));
|
||||||
|
assertTrue(insufficient.contains("scope=\"orders:read payments:write\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -4,6 +4,10 @@ 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.ext.openapi.OpenApiContributor;
|
import dev.relism.flash.ext.openapi.OpenApiContributor;
|
||||||
|
import dev.relism.flash.ext.auth.AuthPolicy;
|
||||||
|
import dev.relism.flash.ext.auth.Authenticated;
|
||||||
|
import dev.relism.flash.ext.auth.RolesAllowed;
|
||||||
|
import dev.relism.flash.ext.auth.ScopesAllowed;
|
||||||
import dev.relism.flash.extension.FlashContext;
|
import dev.relism.flash.extension.FlashContext;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -104,6 +108,7 @@ class OidcOpenApiInteropTest {
|
|||||||
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);
|
||||||
|
ctx.complete();
|
||||||
|
|
||||||
OidcConfig config = OidcConfig.builder("https://issuer", "c", "s", "/cb").build();
|
OidcConfig config = OidcConfig.builder("https://issuer", "c", "s", "/cb").build();
|
||||||
OidcProviderMetadata meta = new OidcProviderMetadata("a", "t", "u", "j", "e");
|
OidcProviderMetadata meta = new OidcProviderMetadata("a", "t", "u", "j", "e");
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# flash-ext-cache-caffeine
|
||||||
|
|
||||||
|
In-process caching backed by [Caffeine](https://github.com/ben-manes/caffeine). Implements
|
||||||
|
[`flash-ext-cache-core`](../../flash-ext-cache-core/docs/README.md).
|
||||||
|
|
||||||
|
## Dependency
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-cache-caffeine</artifactId>
|
||||||
|
<version>${flash.version}</version>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```java
|
||||||
|
FlashApp.create(8080)
|
||||||
|
.install(new CaffeineCacheExtension())
|
||||||
|
.scan("dev.example.api");
|
||||||
|
```
|
||||||
|
|
||||||
|
```java
|
||||||
|
@GET("/api/users/{id}")
|
||||||
|
public final class GetUser extends RequestHandler {
|
||||||
|
|
||||||
|
private Cache<String, User> users;
|
||||||
|
private UserRepository repo;
|
||||||
|
|
||||||
|
@Override protected void onInit() {
|
||||||
|
repo = require(UserRepository.class);
|
||||||
|
users = require(CacheManager.class).build("users", spec -> spec
|
||||||
|
.maxSize(10_000)
|
||||||
|
.ttl(Duration.ofMinutes(10)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public Object handle(Request req, Response res) {
|
||||||
|
return users.get(req.param("id"), repo::findById);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The extension takes no configuration. Each cache declares its own size and TTL where it is built.
|
||||||
|
|
||||||
|
## Why Caffeine and not a `LinkedHashMap`
|
||||||
|
|
||||||
|
An LRU on top of `LinkedHashMap` is about sixty lines, and for a cache that is genuinely
|
||||||
|
low-traffic it is the right answer — `ConcurrentHashMap::computeIfAbsent` is one line and has no
|
||||||
|
hit rate to get wrong.
|
||||||
|
|
||||||
|
This module exists for the case where that stops being true. Caffeine's W-TinyLFU admission,
|
||||||
|
striped frequency counters and amortised eviction are not a weekend's work to reproduce, and the
|
||||||
|
failure mode of getting them wrong is a cache that is *slower* than no cache — lock contention on
|
||||||
|
every lookup, or an eviction policy that throws away exactly the entries you were about to want.
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
Caches are released through `FlashContext.onClose`, so `app.stop()` drops every entry. That is
|
||||||
|
invisible in production with one app per process and matters immediately under test, where many
|
||||||
|
apps start and stop in one JVM.
|
||||||
|
|
||||||
|
## Statistics
|
||||||
|
|
||||||
|
```java
|
||||||
|
CacheStats stats = users.stats();
|
||||||
|
stats.hitRate(); // 0.0 until something is looked up
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires `recordStats()` on the spec. Without it you get `CacheStats.DISABLED`, which is honest
|
||||||
|
about being unmeasured rather than reporting zeroes that look like a cold cache.
|
||||||
|
|
||||||
|
`manager.names()` lists every cache built so far, for an ops endpoint.
|
||||||
|
|
||||||
|
## What this is not
|
||||||
|
|
||||||
|
**HTTP caching.** If what you want is for the *client* to stop asking — `Cache-Control`, `ETag`,
|
||||||
|
`304 Not Modified` — that is a middleware, not an object cache, and it saves the whole request
|
||||||
|
rather than the lookup inside it. Reach for that first: it is cheaper, and the two solve different
|
||||||
|
problems.
|
||||||
|
|
||||||
|
**A shared cache.** Every replica has its own. Two instances will hold different values for the
|
||||||
|
same key, and an invalidation on one does not reach the other. When that becomes a problem the
|
||||||
|
answer is a networked backend — see the note on `flash-ext-cache-redis` — and the semantics change
|
||||||
|
with it: a cache that can fail is no longer transparent.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-extensions</artifactId>
|
||||||
|
<version>2.1.0-SNAPSHOT</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>flash-ext-cache-caffeine</artifactId>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-cache-core</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!--
|
||||||
|
Caffeine rather than a hand-rolled LRU: W-TinyLFU admission, striped counters and
|
||||||
|
amortised eviction are not a weekend's work to get right, and getting them wrong is a
|
||||||
|
cache that is slower than no cache.
|
||||||
|
-->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||||
|
<artifactId>caffeine</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-testing</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
package dev.relism.flash.ext.cache.caffeine;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.cache.Cache;
|
||||||
|
import dev.relism.flash.ext.cache.CacheStats;
|
||||||
|
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link Cache} over a Caffeine cache. A thin adapter by design: every method delegates directly,
|
||||||
|
* adding no wrapper object, no copy and no synchronisation of its own.
|
||||||
|
*/
|
||||||
|
final class CaffeineCache<K, V> implements Cache<K, V> {
|
||||||
|
|
||||||
|
private final com.github.benmanes.caffeine.cache.Cache<K, V> delegate;
|
||||||
|
private final boolean statsRecorded;
|
||||||
|
|
||||||
|
CaffeineCache(com.github.benmanes.caffeine.cache.Cache<K, V> delegate, boolean statsRecorded) {
|
||||||
|
this.delegate = delegate;
|
||||||
|
this.statsRecorded = statsRecorded;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public V get(K key, Function<? super K, ? extends V> loader) {
|
||||||
|
// Caffeine's own get(key, mappingFunction) already guarantees the loader runs once per key
|
||||||
|
// across concurrent callers; wrapping it in anything of ours would only add a race.
|
||||||
|
return delegate.get(key, loader);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public V getIfPresent(K key) { return delegate.getIfPresent(key); }
|
||||||
|
@Override public void put(K key, V value) { delegate.put(key, value); }
|
||||||
|
@Override public void invalidate(K key) { delegate.invalidate(key); }
|
||||||
|
@Override public void invalidateAll() { delegate.invalidateAll(); }
|
||||||
|
@Override public long estimatedSize() { return delegate.estimatedSize(); }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CacheStats stats() {
|
||||||
|
if (!statsRecorded) return CacheStats.DISABLED;
|
||||||
|
com.github.benmanes.caffeine.cache.stats.CacheStats snapshot = delegate.stats();
|
||||||
|
return new CacheStats(snapshot.hitCount(), snapshot.missCount(), snapshot.evictionCount());
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package dev.relism.flash.ext.cache.caffeine;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.cache.CacheManager;
|
||||||
|
import dev.relism.flash.extension.FlashContext;
|
||||||
|
import dev.relism.flash.extension.FlashExtension;
|
||||||
|
import dev.relism.flash.extension.FlashRegistrar;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs an in-process {@link CacheManager} backed by Caffeine.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* FlashApp.create(8080)
|
||||||
|
* .install(new CaffeineCacheExtension())
|
||||||
|
* .scan("dev.example.api");
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p>No configuration. Each cache declares its own size and TTL where it is built, because those
|
||||||
|
* are properties of what is being cached, not of the process caching it.
|
||||||
|
*
|
||||||
|
* <p>Caches are dropped through {@link FlashContext#onClose}, so a stopped app does not keep its
|
||||||
|
* values alive — which matters when many apps start and stop in one JVM, as they do under test.
|
||||||
|
*/
|
||||||
|
public final class CaffeineCacheExtension implements FlashExtension {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
|
ctx.supply(CacheManager.class, services -> {
|
||||||
|
CaffeineCacheManager manager = new CaffeineCacheManager();
|
||||||
|
services.onClose(manager::clear);
|
||||||
|
return manager;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
package dev.relism.flash.ext.cache.caffeine;
|
||||||
|
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||||
|
import dev.relism.flash.ext.cache.Cache;
|
||||||
|
import dev.relism.flash.ext.cache.CacheManager;
|
||||||
|
import dev.relism.flash.ext.cache.CacheSpec;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/** In-process {@link CacheManager} backed by Caffeine. */
|
||||||
|
final class CaffeineCacheManager implements CacheManager {
|
||||||
|
|
||||||
|
private final Map<String, Entry> caches = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public <K, V> Cache<K, V> build(String name, java.util.function.Consumer<CacheSpec> configure) {
|
||||||
|
CacheSpec spec = CacheSpec.of();
|
||||||
|
configure.accept(spec);
|
||||||
|
|
||||||
|
Entry entry = caches.computeIfAbsent(name, key -> new Entry(describe(spec), create(spec)));
|
||||||
|
// Two handlers sharing a cache is the point; two handlers disagreeing about its size or
|
||||||
|
// TTL is a bug that would otherwise resolve to whichever one ran first.
|
||||||
|
String requested = describe(spec);
|
||||||
|
if (!entry.signature.equals(requested))
|
||||||
|
throw new IllegalStateException("Cache '" + name + "' already exists as " + entry.signature
|
||||||
|
+ " but was requested as " + requested);
|
||||||
|
return (Cache<K, V>) entry.cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public <K, V> Cache<K, V> cache(String name) {
|
||||||
|
Entry entry = caches.get(name);
|
||||||
|
return entry == null ? null : (Cache<K, V>) entry.cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Set<String> names() {
|
||||||
|
return Set.copyOf(caches.keySet());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Releases every entry so a stopped app does not keep its values alive. */
|
||||||
|
void clear() {
|
||||||
|
caches.values().forEach(entry -> entry.cache.invalidateAll());
|
||||||
|
caches.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CaffeineCache<Object, Object> create(CacheSpec spec) {
|
||||||
|
Caffeine<Object, Object> builder = Caffeine.newBuilder();
|
||||||
|
if (spec.bounded()) builder.maximumSize(spec.maxSize());
|
||||||
|
if (spec.ttl() != null) builder.expireAfterWrite(spec.ttl());
|
||||||
|
if (spec.ttlAfterAccess() != null) builder.expireAfterAccess(spec.ttlAfterAccess());
|
||||||
|
if (spec.statsRecorded()) builder.recordStats();
|
||||||
|
return new CaffeineCache<>(builder.build(), spec.statsRecorded());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String describe(CacheSpec spec) {
|
||||||
|
return "maxSize=" + spec.maxSize() + " ttl=" + spec.ttl()
|
||||||
|
+ " ttlAfterAccess=" + spec.ttlAfterAccess() + " stats=" + spec.statsRecorded();
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Entry(String signature, CaffeineCache<Object, Object> cache) {}
|
||||||
|
}
|
||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
package dev.relism.flash.ext.cache.caffeine;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.cache.Cache;
|
||||||
|
import dev.relism.flash.ext.cache.CacheManager;
|
||||||
|
import dev.relism.flash.ext.cache.CacheStats;
|
||||||
|
import dev.relism.flash.extension.FlashApp;
|
||||||
|
import dev.relism.flash.extension.FlashConfiguration;
|
||||||
|
import dev.relism.flash.testing.FlashTest;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class CaffeineCacheTest {
|
||||||
|
|
||||||
|
private static final AtomicInteger loads = new AtomicInteger();
|
||||||
|
|
||||||
|
@RegisterExtension
|
||||||
|
static FlashTest app = FlashTest.of(configured -> {
|
||||||
|
configured.install(new CaffeineCacheExtension());
|
||||||
|
configured.ctx().onReady(() -> {
|
||||||
|
Cache<String, String> users = configured.ctx().require(CacheManager.class)
|
||||||
|
.build("users", spec -> spec.maxSize(100).ttl(Duration.ofMinutes(5)).recordStats());
|
||||||
|
configured.get("/users/{id}", (req, res) ->
|
||||||
|
users.get(req.param("id"), id -> "loaded:" + id + ":" + loads.incrementAndGet()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
private static CacheManager manager() {
|
||||||
|
return app.app().ctx().require(CacheManager.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aRepeatedRequestIsServedFromCache() {
|
||||||
|
String first = app.get("/users/alice").expectStatus(200).body();
|
||||||
|
String second = app.get("/users/alice").expectStatus(200).body();
|
||||||
|
|
||||||
|
assertEquals(first, second);
|
||||||
|
assertTrue(first.startsWith("loaded:alice:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void distinctKeysLoadSeparately() {
|
||||||
|
assertNotEquals(app.get("/users/bob").body(), app.get("/users/carol").body());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void statsCountHitsAndMisses() {
|
||||||
|
Cache<String, String> cache = manager().build("stats-probe", spec -> spec.maxSize(10).recordStats());
|
||||||
|
cache.get("k", key -> "v");
|
||||||
|
cache.get("k", key -> "v");
|
||||||
|
|
||||||
|
CacheStats stats = cache.stats();
|
||||||
|
assertEquals(1, stats.misses());
|
||||||
|
assertEquals(1, stats.hits());
|
||||||
|
assertEquals(0.5, stats.hitRate());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void statsAreDisabledUnlessAskedFor() {
|
||||||
|
Cache<String, String> cache = manager().build("no-stats", spec -> spec.maxSize(10));
|
||||||
|
cache.get("k", key -> "v");
|
||||||
|
|
||||||
|
assertEquals(CacheStats.DISABLED, cache.stats());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theLoaderRunsOncePerKeyUnderConcurrency() throws Exception {
|
||||||
|
Cache<String, String> cache = manager().build("single-flight", spec -> spec.maxSize(10));
|
||||||
|
AtomicInteger invocations = new AtomicInteger();
|
||||||
|
int threads = 16;
|
||||||
|
CountDownLatch start = new CountDownLatch(1);
|
||||||
|
CountDownLatch done = new CountDownLatch(threads);
|
||||||
|
|
||||||
|
for (int i = 0; i < threads; i++) {
|
||||||
|
Thread.ofVirtual().start(() -> {
|
||||||
|
try {
|
||||||
|
start.await();
|
||||||
|
cache.get("hot", key -> {
|
||||||
|
invocations.incrementAndGet();
|
||||||
|
return "value";
|
||||||
|
});
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
} finally {
|
||||||
|
done.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
start.countDown();
|
||||||
|
assertTrue(done.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||||
|
|
||||||
|
assertEquals(1, invocations.get(), "concurrent callers must share one load, not race");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aNullLoaderResultStoresNothing() {
|
||||||
|
Cache<String, String> cache = manager().build("nulls", spec -> spec.maxSize(10));
|
||||||
|
|
||||||
|
assertNull(cache.get("missing", key -> null));
|
||||||
|
assertNull(cache.getIfPresent("missing"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidateDropsOneKeyAndInvalidateAllDropsEverything() {
|
||||||
|
Cache<String, String> cache = manager().build("invalidation", spec -> spec.maxSize(10));
|
||||||
|
cache.put("a", "1");
|
||||||
|
cache.put("b", "2");
|
||||||
|
|
||||||
|
cache.invalidate("a");
|
||||||
|
assertNull(cache.getIfPresent("a"));
|
||||||
|
assertEquals("2", cache.getIfPresent("b"));
|
||||||
|
|
||||||
|
cache.invalidateAll();
|
||||||
|
assertNull(cache.getIfPresent("b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void buildIsIdempotentPerName() {
|
||||||
|
Cache<String, String> first = manager().build("shared", spec -> spec.maxSize(10));
|
||||||
|
Cache<String, String> second = manager().build("shared", spec -> spec.maxSize(10));
|
||||||
|
|
||||||
|
assertSame(first, second, "two handlers asking for one cache must get one cache");
|
||||||
|
assertSame(first, manager().cache("shared"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void disagreeingOnASharedCacheIsARejectedMistakeNotASilentWinner() {
|
||||||
|
manager().build("contested", spec -> spec.maxSize(10));
|
||||||
|
|
||||||
|
IllegalStateException conflict = assertThrows(IllegalStateException.class,
|
||||||
|
() -> manager().build("contested", spec -> spec.maxSize(999)));
|
||||||
|
assertTrue(conflict.getMessage().contains("contested"), conflict.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownNameReturnsNullRatherThanBuildingOne() {
|
||||||
|
assertNull(manager().cache("never-built"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why CaffeineCacheExtension registers onClose: values must not outlive the app holding them. */
|
||||||
|
@Test
|
||||||
|
void stoppingTheAppReleasesEveryCache() {
|
||||||
|
FlashApp standalone = FlashApp.create(FlashConfiguration.builder()
|
||||||
|
.port(0).host("127.0.0.1").shutdownDrainTimeoutMs(250).build())
|
||||||
|
.install(new CaffeineCacheExtension());
|
||||||
|
standalone.start();
|
||||||
|
CacheManager manager = standalone.ctx().require(CacheManager.class);
|
||||||
|
manager.build("scoped", spec -> spec.maxSize(10)).put("k", "v");
|
||||||
|
assertEquals(1, manager.names().size());
|
||||||
|
|
||||||
|
standalone.stop().join();
|
||||||
|
|
||||||
|
assertTrue(manager.names().isEmpty(), "caches must be released when the app stops");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# flash-ext-cache-core
|
||||||
|
|
||||||
|
The caching contract, shared across backends. Like `flash-ext-data-core`, this module talks to
|
||||||
|
nothing: it defines the abstractions and a backend implements them.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
- `Cache<K, V>` — a named cache. `get(key, loader)` is the method that matters.
|
||||||
|
- `CacheManager` — creates and hands back named caches.
|
||||||
|
- `CacheSpec` — size and expiry for one cache.
|
||||||
|
- `CacheStats` — hit/miss/eviction counters.
|
||||||
|
|
||||||
|
Install a backend, not this module: [`flash-ext-cache-caffeine`](../../flash-ext-cache-caffeine/docs/README.md)
|
||||||
|
for in-process caching.
|
||||||
|
|
||||||
|
## The one shape that matters
|
||||||
|
|
||||||
|
```java
|
||||||
|
User user = users.get(id, repo::findById);
|
||||||
|
```
|
||||||
|
|
||||||
|
Compute-if-absent is the only cache operation most code needs, and the only one that is hard to
|
||||||
|
get right — the loader runs **once per key** across concurrent callers, and the rest wait rather
|
||||||
|
than each computing their own. `getIfPresent`, `put`, `invalidate` and `invalidateAll` exist for
|
||||||
|
what it cannot express.
|
||||||
|
|
||||||
|
A loader returning `null` stores nothing and returns `null`. Caching absence is a decision, not a
|
||||||
|
default; wrap it in an `Optional` or a sentinel if you want it.
|
||||||
|
|
||||||
|
## Naming and sharing
|
||||||
|
|
||||||
|
`CacheManager.build(name, spec)` is idempotent per name: two handlers asking for `"users"` get one
|
||||||
|
cache, not two, so nobody has to coordinate who creates it first.
|
||||||
|
|
||||||
|
If they disagree about the spec, that throws. The alternative is a cache whose size depends on
|
||||||
|
which handler happened to initialise first, which is the kind of bug that only shows up under
|
||||||
|
load.
|
||||||
|
|
||||||
|
## Specs
|
||||||
|
|
||||||
|
```java
|
||||||
|
CacheSpec.of()
|
||||||
|
.maxSize(10_000)
|
||||||
|
.ttl(Duration.ofMinutes(10))
|
||||||
|
.recordStats();
|
||||||
|
```
|
||||||
|
|
||||||
|
Every field is optional, but a spec that sets neither `maxSize` nor `ttl` is an unbounded cache
|
||||||
|
that never expires — a memory leak wearing a hat. Set at least one.
|
||||||
|
|
||||||
|
`recordStats()` is off by default: counting costs a pair of atomic increments on every lookup, and
|
||||||
|
a cache nobody is measuring should not pay for numbers nobody reads. Without it, `stats()` returns
|
||||||
|
`CacheStats.DISABLED` rather than silently zero.
|
||||||
|
|
||||||
|
## Where the spec lives
|
||||||
|
|
||||||
|
On the cache, at the point it is built — not in application config. Size and TTL are properties of
|
||||||
|
*what is being cached*, not of the process doing the caching, and a TTL in a config file is a TTL
|
||||||
|
nobody can relate back to the data it governs.
|
||||||
|
|
||||||
|
## Writing a backend
|
||||||
|
|
||||||
|
Implement `CacheManager` and `Cache`, provide the manager from a `FlashExtension`, and register
|
||||||
|
cleanup with `FlashContext.onClose` so a stopped app does not keep its values alive.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-extensions</artifactId>
|
||||||
|
<version>2.1.0-SNAPSHOT</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>flash-ext-cache-core</artifactId>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
package dev.relism.flash.ext.cache;
|
||||||
|
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A named cache. Obtained from a {@link CacheManager}, safe to hold in a handler field and share
|
||||||
|
* across threads.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* User user = users.get(id, repo::findById);
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p>{@link #get} is the only method most code needs. The rest exist for the cases it cannot
|
||||||
|
* express: reading without populating, writing a value computed elsewhere, and invalidating.
|
||||||
|
*
|
||||||
|
* @param <K> key type — must have a stable {@code hashCode}/{@code equals}
|
||||||
|
* @param <V> value type
|
||||||
|
*/
|
||||||
|
public interface Cache<K, V> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the cached value, computing and storing it with {@code loader} if absent.
|
||||||
|
*
|
||||||
|
* <p>The loader runs at most once per key across concurrent callers; the others wait for it
|
||||||
|
* rather than each computing their own. A loader returning {@code null} stores nothing and
|
||||||
|
* {@code null} is returned.
|
||||||
|
*/
|
||||||
|
V get(K key, Function<? super K, ? extends V> loader);
|
||||||
|
|
||||||
|
/** The cached value, or {@code null} if absent. Never invokes a loader. */
|
||||||
|
V getIfPresent(K key);
|
||||||
|
|
||||||
|
/** Stores {@code value}, replacing any existing entry. */
|
||||||
|
void put(K key, V value);
|
||||||
|
|
||||||
|
/** Drops {@code key}. Does nothing if it was absent. */
|
||||||
|
void invalidate(K key);
|
||||||
|
|
||||||
|
/** Drops every entry. */
|
||||||
|
void invalidateAll();
|
||||||
|
|
||||||
|
/** Approximate entry count. Approximate because eviction is asynchronous in most backends. */
|
||||||
|
long estimatedSize();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hit/miss counters since this cache was built, or {@link CacheStats#DISABLED} when the
|
||||||
|
* backend was not asked to record them.
|
||||||
|
*/
|
||||||
|
CacheStats stats();
|
||||||
|
}
|
||||||
Vendored
+31
@@ -0,0 +1,31 @@
|
|||||||
|
package dev.relism.flash.ext.cache;
|
||||||
|
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and hands back named caches. Resolve it with {@code require(CacheManager.class)}; a
|
||||||
|
* backend extension such as {@code flash-ext-cache-caffeine} provides the implementation.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* @Override protected void onInit() {
|
||||||
|
* users = require(CacheManager.class).build("users", spec -> spec
|
||||||
|
* .maxSize(10_000)
|
||||||
|
* .ttl(Duration.ofMinutes(10)));
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p>{@link #build} is idempotent per name: calling it twice returns the same cache rather than
|
||||||
|
* two, so several handlers can share one without coordinating who creates it. The spec of the
|
||||||
|
* first call wins; a later call with a different spec is a configuration mistake and throws.
|
||||||
|
*/
|
||||||
|
public interface CacheManager {
|
||||||
|
|
||||||
|
/** Creates the named cache, or returns the existing one. */
|
||||||
|
<K, V> Cache<K, V> build(String name, Consumer<CacheSpec> spec);
|
||||||
|
|
||||||
|
/** The named cache, or {@code null} if {@link #build} has not been called for it. */
|
||||||
|
<K, V> Cache<K, V> cache(String name);
|
||||||
|
|
||||||
|
/** Every cache name built so far, for an ops endpoint. */
|
||||||
|
java.util.Set<String> names();
|
||||||
|
}
|
||||||
Vendored
+64
@@ -0,0 +1,64 @@
|
|||||||
|
package dev.relism.flash.ext.cache;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How one cache should behave. Every field is optional — a spec that sets nothing gives an
|
||||||
|
* unbounded cache that never expires, which is a memory leak wearing a hat, so set at least one
|
||||||
|
* of {@link #maxSize} or {@link #ttl}.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* CacheSpec.of().maxSize(10_000).ttl(Duration.ofMinutes(10))
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p>Mutable builder rather than a record with {@code withX} copies: it is constructed once at
|
||||||
|
* boot inside a lambda and never shared.
|
||||||
|
*/
|
||||||
|
public final class CacheSpec {
|
||||||
|
|
||||||
|
private long maxSize = -1;
|
||||||
|
private Duration ttl;
|
||||||
|
private Duration ttlAfterAccess;
|
||||||
|
private boolean recordStats;
|
||||||
|
|
||||||
|
private CacheSpec() {}
|
||||||
|
|
||||||
|
public static CacheSpec of() {
|
||||||
|
return new CacheSpec();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maximum entries before the backend starts evicting. Negative means unbounded. */
|
||||||
|
public CacheSpec maxSize(long maxSize) {
|
||||||
|
this.maxSize = maxSize;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entries expire this long after they were written. */
|
||||||
|
public CacheSpec ttl(Duration ttl) {
|
||||||
|
this.ttl = ttl;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entries expire this long after they were last read or written. */
|
||||||
|
public CacheSpec ttlAfterAccess(Duration ttlAfterAccess) {
|
||||||
|
this.ttlAfterAccess = ttlAfterAccess;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records hit/miss counters for {@link Cache#stats()}.
|
||||||
|
*
|
||||||
|
* <p>Off by default: counting costs a pair of atomic increments on every lookup, and a cache
|
||||||
|
* nobody is measuring should not pay for numbers nobody reads.
|
||||||
|
*/
|
||||||
|
public CacheSpec recordStats() {
|
||||||
|
this.recordStats = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long maxSize() { return maxSize; }
|
||||||
|
public Duration ttl() { return ttl; }
|
||||||
|
public Duration ttlAfterAccess() { return ttlAfterAccess; }
|
||||||
|
public boolean statsRecorded() { return recordStats; }
|
||||||
|
public boolean bounded() { return maxSize >= 0; }
|
||||||
|
}
|
||||||
Vendored
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package dev.relism.flash.ext.cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hit/miss counters for one cache.
|
||||||
|
*
|
||||||
|
* @param hits lookups that found a value
|
||||||
|
* @param misses lookups that had to load
|
||||||
|
* @param evictions entries dropped to respect {@link CacheSpec#maxSize()}
|
||||||
|
*/
|
||||||
|
public record CacheStats(long hits, long misses, long evictions) {
|
||||||
|
|
||||||
|
/** Returned when {@link CacheSpec#recordStats()} was not set — all zero, and says so. */
|
||||||
|
public static final CacheStats DISABLED = new CacheStats(0, 0, 0);
|
||||||
|
|
||||||
|
/** Hits divided by lookups, or 0 when nothing has been looked up yet. */
|
||||||
|
public double hitRate() {
|
||||||
|
long total = hits + misses;
|
||||||
|
return total == 0 ? 0 : (double) hits / total;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# flash-ext-cache-redis — planned
|
||||||
|
|
||||||
|
Not implemented. This directory holds the design so the decision is written down rather than
|
||||||
|
rediscovered; there is deliberately **no module, no pom and no source**, because an empty module
|
||||||
|
that builds an empty jar is dead weight in the reactor and in everyone's dependency tree.
|
||||||
|
|
||||||
|
Add it when there is a second replica that actually needs shared state.
|
||||||
|
|
||||||
|
## What it would implement
|
||||||
|
|
||||||
|
`CacheManager` and `Cache` from [`flash-ext-cache-core`](../../flash-ext-cache-core/docs/README.md),
|
||||||
|
so switching backend is an install-line change:
|
||||||
|
|
||||||
|
```java
|
||||||
|
.install(new RedisCacheExtension(RedisConfig.of("redis://localhost:6379")))
|
||||||
|
```
|
||||||
|
|
||||||
|
## The part that is not a drop-in
|
||||||
|
|
||||||
|
`flash-ext-cache-caffeine` cannot fail. A networked cache can, and that changes the contract in
|
||||||
|
ways an adapter cannot hide:
|
||||||
|
|
||||||
|
- **`get(key, loader)` can fail before reaching the loader.** The honest default is to fall
|
||||||
|
through to the loader and serve the value uncached, so Redis being down degrades throughput
|
||||||
|
rather than taking the application with it. That has to be a decision, not an accident.
|
||||||
|
- **Values must be serialized.** Caffeine stores references. A `byte[]` codec belongs in the spec,
|
||||||
|
and the natural default is whatever `flash-ext-jackson` is already configured with.
|
||||||
|
- **`invalidateAll()` is not free.** Against a shared keyspace it is either a scan or a key
|
||||||
|
prefix per cache name. The prefix is the right answer, and it means cache names become part of
|
||||||
|
the wire contract.
|
||||||
|
- **Stats are per-client, not per-cache.** Hit rate stays meaningful; eviction count does not,
|
||||||
|
because Redis evicts on its own policy.
|
||||||
|
|
||||||
|
## Why it is not built yet
|
||||||
|
|
||||||
|
Nothing in the codebase has two replicas sharing cache state. Building it now would mean choosing
|
||||||
|
a client library, a serialization format and a failure policy with no real usage to check them
|
||||||
|
against — and the failure policy in particular is the kind of decision that is wrong until a
|
||||||
|
production incident tells you otherwise.
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
-91
@@ -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();
|
||||||
|
|||||||
+5
-1
@@ -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))); }
|
||||||
@@ -19,4 +23,4 @@ public record Sort(List<Column> columns) {
|
|||||||
next.add(new Column(column, asc));
|
next.add(new Column(column, asc));
|
||||||
return new Sort(next);
|
return new Sort(next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+77
-87
@@ -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,9 +151,17 @@ 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>
|
||||||
|
|||||||
+95
-63
@@ -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));
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user