Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6b0831121 | ||
|
|
26d6386427 | ||
|
|
515dceed7e | ||
|
|
e7fa1d8639 |
@@ -1,24 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
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,17 +0,0 @@
|
|||||||
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0
|
|
||||||
http://maven.apache.org/xsd/settings-1.0.0.xsd">
|
|
||||||
<servers>
|
|
||||||
<server>
|
|
||||||
<id>Personal</id>
|
|
||||||
<username>${env.MAVEN_USERNAME}</username>
|
|
||||||
<password>${env.MAVEN_PASSWORD}</password>
|
|
||||||
</server>
|
|
||||||
<server>
|
|
||||||
<id>Personal-snapshots</id>
|
|
||||||
<username>${env.MAVEN_USERNAME}</username>
|
|
||||||
<password>${env.MAVEN_PASSWORD}</password>
|
|
||||||
</server>
|
|
||||||
</servers>
|
|
||||||
</settings>
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
- 'feature/**'
|
|
||||||
- 'fix/**'
|
|
||||||
- 'hotfix/**'
|
|
||||||
tags-ignore:
|
|
||||||
- 'v*'
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build & Test
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Temurin 21
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: 21
|
|
||||||
cache: maven
|
|
||||||
server-id: Personal
|
|
||||||
server-username: MAVEN_USERNAME
|
|
||||||
server-password: MAVEN_PASSWORD
|
|
||||||
|
|
||||||
- name: 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
|
|
||||||
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:
|
|
||||||
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
|
||||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Publish test results
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
name: test-results
|
|
||||||
path: '**/target/surefire-reports/*.xml'
|
|
||||||
retention-days: 7
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
name: Prepare Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: 'Release version (e.g. 2.0.0)'
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
next_version:
|
|
||||||
description: 'Next development version without -SNAPSHOT (e.g. 2.1.0)'
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
prepare:
|
|
||||||
name: Bump, Tag & Push
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Set up Temurin 21
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: 21
|
|
||||||
cache: maven
|
|
||||||
|
|
||||||
- name: Configure Git
|
|
||||||
run: |
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
|
|
||||||
- name: Set release version
|
|
||||||
run: mvn -B --settings .github/settings.xml versions:set -DnewVersion=${{ inputs.version }}
|
|
||||||
env:
|
|
||||||
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
|
||||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Commit release version
|
|
||||||
run: |
|
|
||||||
git add -A
|
|
||||||
git commit -m "chore(release): ${{ inputs.version }}"
|
|
||||||
|
|
||||||
- name: Tag release
|
|
||||||
run: git tag v${{ inputs.version }}
|
|
||||||
|
|
||||||
- name: Set next snapshot version
|
|
||||||
run: mvn -B --settings .github/settings.xml versions:set -DnewVersion=${{ inputs.next_version }}-SNAPSHOT
|
|
||||||
env:
|
|
||||||
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
|
||||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Commit next snapshot version
|
|
||||||
run: |
|
|
||||||
git add -A
|
|
||||||
git commit -m "chore(release): prepare ${{ inputs.next_version }}-SNAPSHOT"
|
|
||||||
|
|
||||||
- name: Push commits and tag
|
|
||||||
run: |
|
|
||||||
git push origin master
|
|
||||||
git push origin v${{ inputs.version }}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# ── 1. Build, GPG-sign, deploy to Maven releases ──────────────────────────
|
|
||||||
release:
|
|
||||||
name: Build, Sign & Deploy
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
outputs:
|
|
||||||
version: ${{ steps.version.outputs.VERSION }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Extract version from tag
|
|
||||||
id: version
|
|
||||||
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Set up Temurin 21
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: 21
|
|
||||||
cache: maven
|
|
||||||
|
|
||||||
- name: Import GPG key
|
|
||||||
run: |
|
|
||||||
echo "${{ secrets.GPG_PRIVATE_KEY }}" | gpg --batch --import
|
|
||||||
GPG_KEY_ID=$(gpg --list-secret-keys --with-colons | grep '^sec' | cut -d: -f5 | head -1)
|
|
||||||
echo "GPG_KEY_ID=$GPG_KEY_ID" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Build, sign and deploy to releases
|
|
||||||
run: |
|
|
||||||
mvn -B --settings .github/settings.xml \
|
|
||||||
-DperformRelease=true \
|
|
||||||
-Dgpg.keyname=$GPG_KEY_ID \
|
|
||||||
clean deploy
|
|
||||||
env:
|
|
||||||
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
|
||||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Create GitHub Release
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
tag_name: ${{ github.ref_name }}
|
|
||||||
name: ${{ github.ref_name }}
|
|
||||||
generate_release_notes: true
|
|
||||||
draft: false
|
|
||||||
prerelease: false
|
|
||||||
|
|
||||||
# ── 2. Publish JavaDoc (single source of truth: docs.yml) ─────────────────
|
|
||||||
docs:
|
|
||||||
name: Publish JavaDoc
|
|
||||||
needs: release
|
|
||||||
uses: ./.github/workflows/docs.yml
|
|
||||||
with:
|
|
||||||
version: ${{ needs.release.outputs.version }}
|
|
||||||
secrets:
|
|
||||||
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
|
||||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
name: Publish Snapshot
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
tags-ignore:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
snapshot:
|
|
||||||
name: Deploy Snapshot
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Temurin 21
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: 21
|
|
||||||
cache: maven
|
|
||||||
|
|
||||||
- name: Deploy snapshot
|
|
||||||
run: mvn -B --settings .github/settings.xml clean deploy -DskipTests
|
|
||||||
env:
|
|
||||||
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
|
||||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
|
||||||
-57
@@ -1,57 +0,0 @@
|
|||||||
target/
|
|
||||||
*.versionsBackup
|
|
||||||
.mvn/timing.properties
|
|
||||||
*.class
|
|
||||||
!.mvn/wrapper/maven-wrapper.jar
|
|
||||||
!**/src/main/**/target/
|
|
||||||
!**/src/test/**/target/
|
|
||||||
.kotlin
|
|
||||||
|
|
||||||
### IntelliJ IDEA ###
|
|
||||||
.idea/modules.xml
|
|
||||||
.idea/jarRepositories.xml
|
|
||||||
.idea/compiler.xml
|
|
||||||
.idea/libraries/
|
|
||||||
.idea/workspace.xml
|
|
||||||
.idea/inspectionProfiles/
|
|
||||||
*.iws
|
|
||||||
*.iml
|
|
||||||
*.ipr
|
|
||||||
|
|
||||||
### Eclipse ###
|
|
||||||
.apt_generated
|
|
||||||
.classpath
|
|
||||||
.factorypath
|
|
||||||
.project
|
|
||||||
.settings
|
|
||||||
.springBeans
|
|
||||||
.sts4-cache
|
|
||||||
|
|
||||||
### NetBeans ###
|
|
||||||
/nbproject/private/
|
|
||||||
/nbbuild/
|
|
||||||
/dist/
|
|
||||||
/nbdist/
|
|
||||||
/.nb-gradle/
|
|
||||||
build/
|
|
||||||
!**/src/main/**/build/
|
|
||||||
!**/src/test/**/build/
|
|
||||||
|
|
||||||
### VS Code ###
|
|
||||||
.vscode/
|
|
||||||
|
|
||||||
### Mac OS ###
|
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
CLAUDE.md
|
|
||||||
.claude/
|
|
||||||
|
|
||||||
### Local / scratch ###
|
|
||||||
flash/src/main/java/dev/relism/Main.java
|
|
||||||
flash-bench/
|
|
||||||
nuxt-shadcn-dashboard/
|
|
||||||
/dev/
|
|
||||||
/docs/
|
|
||||||
jmh-result.text
|
|
||||||
*.text
|
|
||||||
/flash-extensions/flash-ext-routeviewer/routeviewer-ui/node_modules/
|
|
||||||
Generated
-6
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="AgentMigrationStateService">
|
|
||||||
<option name="migrationStatus" value="COMPLETED" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
Generated
-6
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="AskMigrationStateService">
|
|
||||||
<option name="migrationStatus" value="COMPLETED" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
-6
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="Ask2AgentMigrationStateService">
|
|
||||||
<option name="migrationStatus" value="COMPLETED" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
Generated
-6
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="EditMigrationStateService">
|
|
||||||
<option name="migrationStatus" value="COMPLETED" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
Generated
-43
@@ -1,43 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="Encoding">
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-bench/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-bench/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-api/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-api/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-security-oidc/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-security-oidc/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-vite/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-vite/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
Generated
-6
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="JavaScriptLibraryMappings">
|
|
||||||
<excludedPredefinedLibrary name="Flash/nuxt-shadcn-dashboard/node_modules" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
Generated
-25
@@ -1,25 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="EntryPointsManager">
|
|
||||||
<writeAnnotations>
|
|
||||||
<writeAnnotation name="lombok.Getter" />
|
|
||||||
</writeAnnotations>
|
|
||||||
</component>
|
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
|
||||||
<component name="MavenProjectsManager">
|
|
||||||
<option name="originalFiles">
|
|
||||||
<list>
|
|
||||||
<option value="$PROJECT_DIR$/pom.xml" />
|
|
||||||
<option value="$PROJECT_DIR$/flash-bench/pom.xml" />
|
|
||||||
</list>
|
|
||||||
</option>
|
|
||||||
<option name="ignoredFiles">
|
|
||||||
<set>
|
|
||||||
<option value="$PROJECT_DIR$/flash-bench/pom.xml" />
|
|
||||||
</set>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK">
|
|
||||||
<output url="file://$PROJECT_DIR$/out" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
Generated
-6
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
Generated
-480
@@ -1,480 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="AutoImportSettings">
|
|
||||||
<option name="autoReloadType" value="SELECTIVE" />
|
|
||||||
</component>
|
|
||||||
<component name="ChangeListManager">
|
|
||||||
<list default="true" id="fc757130-fe3e-4ea9-8d69-c26ffb8545a4" name="Changes" comment="feat: introduce WebSocket support with new endpoints and transaction propagation enhancements">
|
|
||||||
<change beforePath="$PROJECT_DIR$/.github/workflows/release.yml" beforeDir="false" afterPath="$PROJECT_DIR$/.github/workflows/release.yml" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
|
||||||
</list>
|
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
|
||||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
|
||||||
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
|
||||||
<option name="LAST_RESOLUTION" value="IGNORE" />
|
|
||||||
</component>
|
|
||||||
<component name="CopilotChats">
|
|
||||||
<option name="panelChat">
|
|
||||||
<chat>
|
|
||||||
<option name="activeSessionId" value="a8d77f48-1eb9-4c5a-961c-1e1452a25ca0" />
|
|
||||||
<option name="sessions">
|
|
||||||
<session>
|
|
||||||
<option name="chatType" value="PANEL" />
|
|
||||||
<option name="id" value="a8d77f48-1eb9-4c5a-961c-1e1452a25ca0" />
|
|
||||||
<option name="modeId" value="Agent" />
|
|
||||||
<option name="modelType" value="builtin_family" />
|
|
||||||
<option name="modelValue" value="auto" />
|
|
||||||
<option name="status" value="Completed" />
|
|
||||||
<option name="targetType" value="LOCAL" />
|
|
||||||
</session>
|
|
||||||
<session>
|
|
||||||
<option name="chatType" value="PANEL" />
|
|
||||||
<option name="id" value="82f48e09-2ca4-4a27-9a04-c65d7a5f0b88" />
|
|
||||||
<option name="modeId" value="Agent" />
|
|
||||||
<option name="modelType" value="builtin_family" />
|
|
||||||
<option name="modelValue" value="auto" />
|
|
||||||
<option name="targetType" value="LOCAL" />
|
|
||||||
</session>
|
|
||||||
</option>
|
|
||||||
<option name="type" value="PANEL" />
|
|
||||||
</chat>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
<component name="CopilotPersistence">
|
|
||||||
<persistenceIdMap>
|
|
||||||
<entry key="_C:/Users/elorc/Documents/Coding/Java/practice/Flash" value="3Axc6mzLyNvh4TgGFvNSCSq83xw" />
|
|
||||||
<entry key="_C:/Users/elorc/Documents/Coding/Java/practice/FlashPractice" value="3AoiAx4zfxMcuO6fDI9oALOPcq1" />
|
|
||||||
<entry key="_C:/Users/relis/Documents/coding/personale/Java/Flash5" value="3CTWwiHEXMdUOOMmV3iz3FrRc6h" />
|
|
||||||
</persistenceIdMap>
|
|
||||||
</component>
|
|
||||||
<component name="EmbeddingIndexingInfo">
|
|
||||||
<option name="cachedIndexableFilesCount" value="448" />
|
|
||||||
<option name="fileBasedEmbeddingIndicesEnabled" value="true" />
|
|
||||||
</component>
|
|
||||||
<component name="FileTemplateManagerImpl">
|
|
||||||
<option name="RECENT_TEMPLATES">
|
|
||||||
<list>
|
|
||||||
<option value="Enum" />
|
|
||||||
<option value="Interface" />
|
|
||||||
<option value="Class" />
|
|
||||||
</list>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
<component name="Git.Settings">
|
|
||||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
|
||||||
<option name="ROOT_SYNC" value="DONT_SYNC" />
|
|
||||||
</component>
|
|
||||||
<component name="GitHubPullRequestSearchHistory">{
|
|
||||||
"lastFilter": {
|
|
||||||
"state": "OPEN",
|
|
||||||
"assignee": "Relism"
|
|
||||||
}
|
|
||||||
}</component>
|
|
||||||
<component name="GithubPullRequestsUISettings">{
|
|
||||||
"selectedUrlAndAccountId": {
|
|
||||||
"url": "https://github.com/Relism/Flash5.git",
|
|
||||||
"accountId": "86d8a39c-af27-4b79-8d5d-dc74375c3348"
|
|
||||||
}
|
|
||||||
}</component>
|
|
||||||
<component name="McpProjectServerCommands">
|
|
||||||
<commands />
|
|
||||||
<urls />
|
|
||||||
</component>
|
|
||||||
<component name="ProblemsViewState">
|
|
||||||
<option name="selectedTabId" value="ProjectErrors" />
|
|
||||||
</component>
|
|
||||||
<component name="ProjectColorInfo">{
|
|
||||||
"associatedIndex": 3
|
|
||||||
}</component>
|
|
||||||
<component name="ProjectId" id="3AoiAx4zfxMcuO6fDI9oALOPcq1" />
|
|
||||||
<component name="ProjectViewState">
|
|
||||||
<option name="hideEmptyMiddlePackages" value="true" />
|
|
||||||
<option name="showLibraryContents" value="true" />
|
|
||||||
</component>
|
|
||||||
<component name="PropertiesComponent">{
|
|
||||||
"keyToString": {
|
|
||||||
"Application.(dev) flash-bench.executor": "Run",
|
|
||||||
"Application.ExternalBenchmark (1).executor": "Run",
|
|
||||||
"Application.ExternalBenchmark.executor": "Run",
|
|
||||||
"Application.Main.executor": "Run",
|
|
||||||
"Application.MainAlt.executor": "Run",
|
|
||||||
"Application.dev.relism.bench.Main.executor": "Run",
|
|
||||||
"JUnit.RequestParserTest.executor": "Run",
|
|
||||||
"JUnit.RequestParserTest.headers_caseInsensitive.executor": "Debug",
|
|
||||||
"Maven.FlashPractice [test].executor": "Run",
|
|
||||||
"Maven.flash [compile].executor": "Run",
|
|
||||||
"Maven.flash [install].executor": "Run",
|
|
||||||
"Maven.flash [test].executor": "Run",
|
|
||||||
"Maven.flash [verify].executor": "Run",
|
|
||||||
"Maven.flash-bench [clean].executor": "Run",
|
|
||||||
"Maven.flash-bench [install].executor": "Run",
|
|
||||||
"Maven.flash-bench [package].executor": "Run",
|
|
||||||
"Maven.flash-bench [validate].executor": "Run",
|
|
||||||
"Maven.flash-ext-limiter [install].executor": "Run",
|
|
||||||
"Maven.flash-ext-limiter [package].executor": "Run",
|
|
||||||
"Maven.flash-ext-view [verify].executor": "Run",
|
|
||||||
"Maven.flash-parent [clean].executor": "Run",
|
|
||||||
"Maven.flash-parent [compile].executor": "Run",
|
|
||||||
"Maven.flash-parent [deploy].executor": "Run",
|
|
||||||
"Maven.flash-parent [install].executor": "Run",
|
|
||||||
"Maven.flash-parent [package].executor": "Run",
|
|
||||||
"Maven.flash-parent [test].executor": "Run",
|
|
||||||
"Maven.flash-parent [validate].executor": "Run",
|
|
||||||
"Maven.flash-parent [verify].executor": "Run",
|
|
||||||
"Maven.flash-web-bundler [test].executor": "Run",
|
|
||||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
|
||||||
"RunOnceActivity.MCP Project settings loaded": "true",
|
|
||||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
|
||||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
|
||||||
"RunOnceActivity.git.unshallow": "true",
|
|
||||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
|
||||||
"SHARE_PROJECT_CONFIGURATION_FILES": "true",
|
|
||||||
"codeWithMe.voiceChat.enabledByDefault": "false",
|
|
||||||
"git-widget-placeholder": "master",
|
|
||||||
"ignore.virus.scanning.warn.message": "true",
|
|
||||||
"kotlin-language-version-configured": "true",
|
|
||||||
"last_opened_file_path": "C:/Users/elorc/Documents/Coding/Java/practice/Flash",
|
|
||||||
"node.js.detected.package.eslint": "true",
|
|
||||||
"node.js.detected.package.tslint": "true",
|
|
||||||
"node.js.selected.package.eslint": "(autodetect)",
|
|
||||||
"node.js.selected.package.tslint": "(autodetect)",
|
|
||||||
"nodejs_package_manager_path": "npm",
|
|
||||||
"npm.build.executor": "Run",
|
|
||||||
"onboarding.tips.debug.path": "C:/Users/elorc/Documents/Coding/Java/practice/Flash/flash-extensions/flash-ext-data/src/main/java/dev/relism/Main.java",
|
|
||||||
"project.structure.last.edited": "Modules",
|
|
||||||
"project.structure.proportion": "0.15",
|
|
||||||
"project.structure.side.proportion": "0.1150748",
|
|
||||||
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings",
|
|
||||||
"ts.external.directory.path": "C:\\Users\\elorc\\Documents\\Coding\\Java\\practice\\Flash\\nuxt-shadcn-dashboard\\node_modules\\typescript\\lib",
|
|
||||||
"vue.rearranger.settings.migration": "true"
|
|
||||||
}
|
|
||||||
}</component>
|
|
||||||
<component name="RecentsManager">
|
|
||||||
<key name="MoveFile.RECENT_KEYS">
|
|
||||||
<recent name="C:\Users\elorc\Documents\Coding\Java\practice\Flash" />
|
|
||||||
</key>
|
|
||||||
<key name="MoveClassesOrPackagesDialog.RECENTS_KEY">
|
|
||||||
<recent name="dev.relism" />
|
|
||||||
</key>
|
|
||||||
</component>
|
|
||||||
<component name="RunManager" selected="Application.MainAlt">
|
|
||||||
<configuration name="ExternalBenchmark" type="Application" factoryName="Application" temporary="true" nameIsGenerated="true">
|
|
||||||
<option name="MAIN_CLASS_NAME" value="dev.relism.bench.ExternalBenchmark" />
|
|
||||||
<module name="flash" />
|
|
||||||
<extension name="coverage">
|
|
||||||
<pattern>
|
|
||||||
<option name="PATTERN" value="dev.relism.bench.*" />
|
|
||||||
<option name="ENABLED" value="true" />
|
|
||||||
</pattern>
|
|
||||||
</extension>
|
|
||||||
<method v="2">
|
|
||||||
<option name="Make" enabled="true" />
|
|
||||||
</method>
|
|
||||||
</configuration>
|
|
||||||
<configuration name="MainAlt" type="Application" factoryName="Application" temporary="true" nameIsGenerated="true">
|
|
||||||
<option name="MAIN_CLASS_NAME" value="dev.relism.bench.MainAlt" />
|
|
||||||
<module name="flash-bench" />
|
|
||||||
<option name="VM_PARAMETERS" value="-Dflash.env=dev" />
|
|
||||||
<extension name="coverage">
|
|
||||||
<pattern>
|
|
||||||
<option name="PATTERN" value="dev.relism.bench.*" />
|
|
||||||
<option name="ENABLED" value="true" />
|
|
||||||
</pattern>
|
|
||||||
</extension>
|
|
||||||
<method v="2">
|
|
||||||
<option name="Make" enabled="true" />
|
|
||||||
</method>
|
|
||||||
</configuration>
|
|
||||||
<configuration name="(dev) flash-bench" type="Application" factoryName="Application">
|
|
||||||
<option name="MAIN_CLASS_NAME" value="dev.relism.bench.Main" />
|
|
||||||
<module name="flash-bench" />
|
|
||||||
<option name="VM_PARAMETERS" value="-Dflash.env=dev" />
|
|
||||||
<extension name="coverage">
|
|
||||||
<pattern>
|
|
||||||
<option name="PATTERN" value="dev.relism.bench.*" />
|
|
||||||
<option name="ENABLED" value="true" />
|
|
||||||
</pattern>
|
|
||||||
</extension>
|
|
||||||
<method v="2">
|
|
||||||
<option name="Make" enabled="true" />
|
|
||||||
</method>
|
|
||||||
</configuration>
|
|
||||||
<list>
|
|
||||||
<item itemvalue="Application.(dev) flash-bench" />
|
|
||||||
<item itemvalue="Application.ExternalBenchmark" />
|
|
||||||
<item itemvalue="Application.MainAlt" />
|
|
||||||
</list>
|
|
||||||
<recent_temporary>
|
|
||||||
<list>
|
|
||||||
<item itemvalue="Application.MainAlt" />
|
|
||||||
</list>
|
|
||||||
</recent_temporary>
|
|
||||||
</component>
|
|
||||||
<component name="SharedIndexes">
|
|
||||||
<attachedChunks>
|
|
||||||
<set>
|
|
||||||
<option value="bundled-jdk-30f59d01ecdd-2fc7cc6b9a17-intellij.indexing.shared.core-IU-253.30387.90" />
|
|
||||||
<option value="bundled-js-predefined-d6986cc7102b-9b0f141eb926-JavaScript-IU-253.30387.90" />
|
|
||||||
</set>
|
|
||||||
</attachedChunks>
|
|
||||||
</component>
|
|
||||||
<component name="TaskManager">
|
|
||||||
<task active="true" id="Default" summary="Default task">
|
|
||||||
<changelist id="fc757130-fe3e-4ea9-8d69-c26ffb8545a4" name="Changes" comment="" />
|
|
||||||
<created>1773265186049</created>
|
|
||||||
<option name="number" value="Default" />
|
|
||||||
<option name="presentableId" value="Default" />
|
|
||||||
<updated>1773265186049</updated>
|
|
||||||
<workItem from="1773265187409" duration="5496000" />
|
|
||||||
<workItem from="1773313172181" duration="9617000" />
|
|
||||||
<workItem from="1773325837582" duration="3918000" />
|
|
||||||
<workItem from="1773400995673" duration="23805000" />
|
|
||||||
<workItem from="1773442006686" duration="2722000" />
|
|
||||||
<workItem from="1773491408276" duration="23095000" />
|
|
||||||
<workItem from="1773528595525" duration="7400000" />
|
|
||||||
<workItem from="1773536222450" duration="680000" />
|
|
||||||
<workItem from="1773536904928" duration="395000" />
|
|
||||||
<workItem from="1773537302622" duration="150000" />
|
|
||||||
<workItem from="1773537489622" duration="5048000" />
|
|
||||||
<workItem from="1773576241900" duration="22639000" />
|
|
||||||
<workItem from="1773613172976" duration="13157000" />
|
|
||||||
<workItem from="1773668722120" duration="389000" />
|
|
||||||
<workItem from="1773680650110" duration="4711000" />
|
|
||||||
<workItem from="1773701882230" duration="6975000" />
|
|
||||||
<workItem from="1773744806275" duration="48612000" />
|
|
||||||
<workItem from="1773831300464" duration="1538000" />
|
|
||||||
<workItem from="1773837116508" duration="4255000" />
|
|
||||||
<workItem from="1773847734658" duration="911000" />
|
|
||||||
<workItem from="1773875652058" duration="5481000" />
|
|
||||||
<workItem from="1773915392332" duration="36877000" />
|
|
||||||
<workItem from="1773996650761" duration="101000" />
|
|
||||||
<workItem from="1774011413085" duration="2519000" />
|
|
||||||
<workItem from="1774033964550" duration="5379000" />
|
|
||||||
<workItem from="1774116033963" duration="1364000" />
|
|
||||||
<workItem from="1774136286683" duration="3816000" />
|
|
||||||
<workItem from="1774187096932" duration="25000" />
|
|
||||||
<workItem from="1774458099414" duration="14351000" />
|
|
||||||
<workItem from="1774523863906" duration="9762000" />
|
|
||||||
<workItem from="1774555172612" duration="9776000" />
|
|
||||||
<workItem from="1774604979874" duration="16474000" />
|
|
||||||
<workItem from="1774628513273" duration="86000" />
|
|
||||||
<workItem from="1774638461244" duration="5718000" />
|
|
||||||
<workItem from="1774691772785" duration="3801000" />
|
|
||||||
<workItem from="1774703412987" duration="25271000" />
|
|
||||||
<workItem from="1774777423667" duration="2127000" />
|
|
||||||
<workItem from="1774790933314" duration="10099000" />
|
|
||||||
<workItem from="1774814271256" duration="6223000" />
|
|
||||||
<workItem from="1774872087063" duration="23188000" />
|
|
||||||
<workItem from="1774944423363" duration="2161000" />
|
|
||||||
<workItem from="1774953226940" duration="4167000" />
|
|
||||||
<workItem from="1775295922142" duration="5680000" />
|
|
||||||
<workItem from="1775312662640" duration="1858000" />
|
|
||||||
<workItem from="1775391482685" duration="1961000" />
|
|
||||||
<workItem from="1775495346638" duration="596000" />
|
|
||||||
<workItem from="1775747075572" duration="1237000" />
|
|
||||||
<workItem from="1776192895352" duration="3823000" />
|
|
||||||
<workItem from="1776198526994" duration="4187000" />
|
|
||||||
<workItem from="1776240695245" duration="14368000" />
|
|
||||||
<workItem from="1776410282788" duration="22340000" />
|
|
||||||
<workItem from="1776515579524" duration="1172000" />
|
|
||||||
<workItem from="1776626614705" duration="4066000" />
|
|
||||||
<workItem from="1776670293634" duration="9127000" />
|
|
||||||
<workItem from="1776716089200" duration="2010000" />
|
|
||||||
<workItem from="1776872699811" duration="4161000" />
|
|
||||||
<workItem from="1776931805422" duration="16122000" />
|
|
||||||
<workItem from="1777051880577" duration="2650000" />
|
|
||||||
<workItem from="1777150747725" duration="837000" />
|
|
||||||
<workItem from="1777198150359" duration="5628000" />
|
|
||||||
<workItem from="1777451402985" duration="709000" />
|
|
||||||
<workItem from="1777534738187" duration="13411000" />
|
|
||||||
<workItem from="1777970837797" duration="5042000" />
|
|
||||||
<workItem from="1778160998498" duration="2931000" />
|
|
||||||
<workItem from="1778319397472" duration="5040000" />
|
|
||||||
<workItem from="1778352978922" duration="2169000" />
|
|
||||||
<workItem from="1778414714349" duration="23000" />
|
|
||||||
<workItem from="1778417425077" duration="3241000" />
|
|
||||||
<workItem from="1778489168036" duration="9828000" />
|
|
||||||
<workItem from="1778576795735" duration="5051000" />
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00001" summary="Initial">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1773536078762</created>
|
|
||||||
<option name="number" value="00001" />
|
|
||||||
<option name="presentableId" value="LOCAL-00001" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1773536078762</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00002" summary="pre-module refactor">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1773536678416</created>
|
|
||||||
<option name="number" value="00002" />
|
|
||||||
<option name="presentableId" value="LOCAL-00002" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1773536678416</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00003" summary="refactored, pre-buffer reuse">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1773581516834</created>
|
|
||||||
<option name="number" value="00003" />
|
|
||||||
<option name="presentableId" value="LOCAL-00003" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1773581516834</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00004" summary="optimized dynamic body size impl, decluttering javadocs/comments">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1773591259108</created>
|
|
||||||
<option name="number" value="00004" />
|
|
||||||
<option name="presentableId" value="LOCAL-00004" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1773591259108</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00005" summary="enhanced HTTP server configuration and response handling; added acceptorThreads, improved header management, and refined error page titles">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1773920675327</created>
|
|
||||||
<option name="number" value="00005" />
|
|
||||||
<option name="presentableId" value="LOCAL-00005" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1773920675327</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00006" summary="multipart parsing, request body access, and chunked input stream support">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1773920735659</created>
|
|
||||||
<option name="number" value="00006" />
|
|
||||||
<option name="presentableId" value="LOCAL-00006" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1773920735659</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00007" summary="enhanced router middleware support; added pre-fused middleware handling and improved handler registration">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1773952556190</created>
|
|
||||||
<option name="number" value="00007" />
|
|
||||||
<option name="presentableId" value="LOCAL-00007" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1773952556190</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00008" summary="pre-major refactoring + ext api.">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1774530802482</created>
|
|
||||||
<option name="number" value="00008" />
|
|
||||||
<option name="presentableId" value="LOCAL-00008" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1774530802482</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00009" summary="preparing for a conceptual refactoring...">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1774703474044</created>
|
|
||||||
<option name="number" value="00009" />
|
|
||||||
<option name="presentableId" value="LOCAL-00009" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1774703474044</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00010" summary="preparing for another refactoring...">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1774819003821</created>
|
|
||||||
<option name="number" value="00010" />
|
|
||||||
<option name="presentableId" value="LOCAL-00010" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1774819003821</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00011" summary="implement OpenAPI contributor integration for rate limiting and response headers">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1776634972916</created>
|
|
||||||
<option name="number" value="00011" />
|
|
||||||
<option name="presentableId" value="LOCAL-00011" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1776634972916</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00012" summary="add core view extension with JTE and Thymeleaf support">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1776724331443</created>
|
|
||||||
<option name="number" value="00012" />
|
|
||||||
<option name="presentableId" value="LOCAL-00012" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1776724331443</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00013" summary="refactor: rename packages and files to use 'flash' prefix for consistency">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1777199691803</created>
|
|
||||||
<option name="number" value="00013" />
|
|
||||||
<option name="presentableId" value="LOCAL-00013" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1777199691804</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00014" summary="fix: enhance global key validation and update template parameters">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1777451475753</created>
|
|
||||||
<option name="number" value="00014" />
|
|
||||||
<option name="presentableId" value="LOCAL-00014" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1777451475753</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00015" summary="feat: introduce WebSocket support with new endpoints and transaction propagation enhancements">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1778508899541</created>
|
|
||||||
<option name="number" value="00015" />
|
|
||||||
<option name="presentableId" value="LOCAL-00015" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1778508899541</updated>
|
|
||||||
</task>
|
|
||||||
<option name="localTasksCounter" value="16" />
|
|
||||||
<servers />
|
|
||||||
</component>
|
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
|
||||||
<option name="version" value="3" />
|
|
||||||
</component>
|
|
||||||
<component name="Vcs.Log.Tabs.Properties">
|
|
||||||
<option name="TAB_STATES">
|
|
||||||
<map>
|
|
||||||
<entry key="MAIN">
|
|
||||||
<value>
|
|
||||||
<State>
|
|
||||||
<option name="FILTERS">
|
|
||||||
<map>
|
|
||||||
<entry key="branch">
|
|
||||||
<value>
|
|
||||||
<list>
|
|
||||||
<option value="claude/distracted-spence" />
|
|
||||||
</list>
|
|
||||||
</value>
|
|
||||||
</entry>
|
|
||||||
</map>
|
|
||||||
</option>
|
|
||||||
</State>
|
|
||||||
</value>
|
|
||||||
</entry>
|
|
||||||
</map>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
<component name="VcsManagerConfiguration">
|
|
||||||
<MESSAGE value="Initial" />
|
|
||||||
<MESSAGE value="pre-module refactor" />
|
|
||||||
<MESSAGE value="refactored, pre-buffer reuse" />
|
|
||||||
<MESSAGE value="optimized dynamic body size impl, decluttering javadocs/comments" />
|
|
||||||
<MESSAGE value="enhanced HTTP server configuration and response handling; added acceptorThreads, improved header management, and refined error page titles" />
|
|
||||||
<MESSAGE value="multipart parsing, request body access, and chunked input stream support" />
|
|
||||||
<MESSAGE value="enhanced router middleware support; added pre-fused middleware handling and improved handler registration" />
|
|
||||||
<MESSAGE value="pre-major refactoring + ext api." />
|
|
||||||
<MESSAGE value="preparing for a conceptual refactoring..." />
|
|
||||||
<MESSAGE value="preparing for another refactoring..." />
|
|
||||||
<MESSAGE value="implement OpenAPI contributor integration for rate limiting and response headers" />
|
|
||||||
<MESSAGE value="add core view extension with JTE and Thymeleaf support" />
|
|
||||||
<MESSAGE value="refactor: rename packages and files to use 'flash' prefix for consistency" />
|
|
||||||
<MESSAGE value="fix: enhance global key validation and update template parameters" />
|
|
||||||
<MESSAGE value="chore(release): prepare 2.1.0-SNAPSHOT" />
|
|
||||||
<MESSAGE value="feat: introduce Spec and Query interfaces with transaction propagation enhancements" />
|
|
||||||
<MESSAGE value="feat: introduce WebSocket support with new endpoints and transaction propagation enhancements" />
|
|
||||||
<option name="LAST_COMMIT_MESSAGE" value="feat: introduce WebSocket support with new endpoints and transaction propagation enhancements" />
|
|
||||||
</component>
|
|
||||||
<component name="XSLT-Support.FileAssociations.UIState">
|
|
||||||
<expand />
|
|
||||||
<select />
|
|
||||||
</component>
|
|
||||||
<component name="github-copilot-workspace">
|
|
||||||
<instructionFileLocations>
|
|
||||||
<option value=".github/instructions" />
|
|
||||||
</instructionFileLocations>
|
|
||||||
<promptFileLocations>
|
|
||||||
<option value=".github/prompts" />
|
|
||||||
</promptFileLocations>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
# Flash — Agent Guidelines
|
|
||||||
|
|
||||||
This document defines the conventions and rules that all agents (AI or human) must follow
|
|
||||||
when working on this repository. Read it entirely before making any change.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Git Workflow
|
|
||||||
|
|
||||||
### Branch Naming
|
|
||||||
|
|
||||||
| Type | Pattern | Example |
|
|
||||||
|------|---------|---------|
|
|
||||||
| New feature | `feature/<scope>/<short-description>` | `feature/ext-oidc/pkce-support` |
|
|
||||||
| Bug fix | `fix/<scope>/<short-description>` | `fix/core/router-npe` |
|
|
||||||
| Hotfix on released version | `hotfix/<version>/<short-description>` | `hotfix/2.0.1/auth-bypass` |
|
|
||||||
| CI/infra changes | `feature/ci/<short-description>` | `feature/ci/add-snapshot-workflow` |
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Always branch from `master`.
|
|
||||||
- Branch names are lowercase, words separated by `-`.
|
|
||||||
- Never push directly to `master`.
|
|
||||||
- Never create `develop`, `release/*`, or any other long-lived branch.
|
|
||||||
|
|
||||||
### Commit Messages — Conventional Commits
|
|
||||||
|
|
||||||
Format: `<type>(<scope>): <short description>`
|
|
||||||
|
|
||||||
| Type | When to use |
|
|
||||||
|------|-------------|
|
|
||||||
| `feat` | New feature |
|
|
||||||
| `fix` | Bug fix |
|
|
||||||
| `refactor` | Code change without feature/fix |
|
|
||||||
| `test` | Adding or updating tests |
|
|
||||||
| `docs` | Documentation only |
|
|
||||||
| `chore` | Build, deps, tooling — no production code |
|
|
||||||
| `ci` | Changes to GitHub Actions workflows |
|
|
||||||
|
|
||||||
Allowed scopes: `core`, `testing`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`,
|
|
||||||
`ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-vite`,
|
|
||||||
`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:
|
|
||||||
```
|
|
||||||
feat(ext-oidc): add PKCE support
|
|
||||||
fix(core): fix NPE in RouteHandler when path is null
|
|
||||||
chore(deps): upgrade jackson to 2.18.0
|
|
||||||
ci: add timeout to snapshot workflow
|
|
||||||
chore(release): 2.1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pull Requests
|
|
||||||
|
|
||||||
- Every branch must be merged via PR, never with a direct push.
|
|
||||||
- PR title must follow Conventional Commits format.
|
|
||||||
- CI (`ci.yml`) must be green before merging.
|
|
||||||
- Squash merge is preferred for `feature/*` and `fix/*` to keep history clean.
|
|
||||||
- Merge commit is preferred for hotfixes (preserves the fix commit intact).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Versioning
|
|
||||||
|
|
||||||
- All modules share a single version defined in the root `pom.xml` (`flash-parent`).
|
|
||||||
- Never change the version in child POMs — always and only in the parent.
|
|
||||||
- Current scheme: `MAJOR.MINOR.PATCH`
|
|
||||||
- MAJOR: breaking API changes
|
|
||||||
- MINOR: new backward-compatible features
|
|
||||||
- PATCH: backward-compatible bug fixes on an already-released version
|
|
||||||
- During development, master always carries a `-SNAPSHOT` version.
|
|
||||||
- **Never manually edit the version** — versions are bumped exclusively by the
|
|
||||||
`prepare-release` GitHub Actions workflow.
|
|
||||||
|
|
||||||
### Release Process (for maintainers only)
|
|
||||||
|
|
||||||
1. Ensure `master` is green (CI passing).
|
|
||||||
2. Go to GitHub Actions → `Prepare Release` → `Run workflow`.
|
|
||||||
3. Input `version` (e.g. `2.1.0`) and `next_version` (e.g. `2.2.0`).
|
|
||||||
4. The workflow handles everything: bump, commit, tag, push.
|
|
||||||
5. The `release` workflow then triggers automatically on the tag.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Maven & Module Structure
|
|
||||||
|
|
||||||
- Root POM: `flash-parent` — defines all dependency versions and plugin config.
|
|
||||||
- `flash` module: the core framework JAR.
|
|
||||||
- `flash-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.
|
|
||||||
- Extensions live under `flash-extensions/flash-ext-*/`.
|
|
||||||
- When adding a new extension:
|
|
||||||
1. Add the module to `flash-extensions/pom.xml` `<modules>`.
|
|
||||||
2. Add the dependency to `flash-extensions/pom.xml` `<dependencyManagement>`.
|
|
||||||
3. Add the dependency to the root `pom.xml` `<dependencyManagement>`.
|
|
||||||
4. Do **not** declare a `<version>` in the new module's POM — it inherits from the parent.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CI/CD Pipelines
|
|
||||||
|
|
||||||
| Workflow | Trigger | What it does |
|
|
||||||
|----------|---------|--------------|
|
|
||||||
| `ci.yml` | Push to any branch, PR to master | Compile + test — required gate |
|
|
||||||
| `snapshot.yml` | Push to `master` | Deploy `-SNAPSHOT` to `maven.relism.dev/snapshots` |
|
|
||||||
| `prepare-release.yml` | Manual `workflow_dispatch` | Bump version, commit, tag, push |
|
|
||||||
| `release.yml` | Push of tag `v*` | GPG sign, deploy to `/releases`, JavaDoc to GitHub Pages, GitHub Release |
|
|
||||||
|
|
||||||
**Agents must never manually trigger `prepare-release` or modify version strings.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What Agents Must NOT Do
|
|
||||||
|
|
||||||
- Push directly to `master` or `gh-pages`.
|
|
||||||
- Manually edit `<version>` tags in any POM.
|
|
||||||
- Add new `<repositories>` or `<distributionManagement>` entries without explicit instruction.
|
|
||||||
- Modify `.github/workflows/*.yml` files without explicit instruction.
|
|
||||||
- Commit generated files (`target/`, `*.class`, `*.versionsBackup`).
|
|
||||||
- Use `git push --force` on any branch.
|
|
||||||
@@ -1,548 +0,0 @@
|
|||||||
# Flash
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
| Module | Description |
|
|
||||||
|---|---|
|
|
||||||
| `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-core` | What every Jackson format shares: the codec, the body handler, the constraints a body is checked against |
|
|
||||||
| `flash-extensions/flash-ext-jackson-json` | JSON bodies and responses |
|
|
||||||
| `flash-extensions/flash-ext-jackson-xml` | XML bodies and responses |
|
|
||||||
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI |
|
|
||||||
| `flash-extensions/flash-ext-security-core` | Security: authentication chain, annotations, sessions, OpenAPI |
|
|
||||||
| `flash-extensions/flash-ext-security-oidc` | OpenID Connect: bearer tokens, code flow + PKCE |
|
|
||||||
| `flash-extensions/flash-ext-security-apikey` | API keys |
|
|
||||||
| `flash-extensions/flash-ext-security-form` | Password sign-in |
|
|
||||||
| `flash-extensions/flash-ext-security-oauth-server` | OAuth 2.1 authorization server for the application's own users and resources |
|
|
||||||
| `flash-extensions/flash-ext-security-test` | Test identities, fake OpenID Provider |
|
|
||||||
| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, secured by flash-ext-security-core |
|
|
||||||
| `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives |
|
|
||||||
| `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension |
|
|
||||||
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
|
|
||||||
| `flash-extensions/flash-ext-vite` | Vite frontend: dev server in DEV, the built SPA from the jar otherwise |
|
|
||||||
| `flash-extensions/flash-ext-vite-maven-plugin` | Builds the Vite frontend into the jar during `mvn package` |
|
|
||||||
| `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
|
|
||||||
|
|
||||||
- Java 21+
|
|
||||||
- Maven 3.8+
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```java
|
|
||||||
FlashApp.create(8080)
|
|
||||||
.get("/ping", (req, res) -> "pong")
|
|
||||||
.start();
|
|
||||||
```
|
|
||||||
|
|
||||||
With full configuration:
|
|
||||||
|
|
||||||
```java
|
|
||||||
FlashApp.create(
|
|
||||||
FlashConfiguration.builder()
|
|
||||||
.port(8080)
|
|
||||||
.host("0.0.0.0")
|
|
||||||
.maxHeaderBufferSize(65536)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.get("/ping", (req, res) -> "pong")
|
|
||||||
.start();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Route registration
|
|
||||||
|
|
||||||
### Lambda routes
|
|
||||||
|
|
||||||
```java
|
|
||||||
FlashApp app = FlashApp.create(8080);
|
|
||||||
|
|
||||||
app.get("/hello", (req, res) -> "world");
|
|
||||||
|
|
||||||
app.post("/echo", (req, res) -> {
|
|
||||||
byte[] body = req.body().bytes();
|
|
||||||
return res.status(200).body(body);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/users/{id}", (req, res) -> {
|
|
||||||
String id = req.param("id");
|
|
||||||
return "user:" + id;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Class-based handlers
|
|
||||||
|
|
||||||
Extend `RequestHandler`, annotate it, then scan its package. Dependencies are cached in
|
|
||||||
`onInit()` after Flash has resolved its complete boot-time service graph:
|
|
||||||
|
|
||||||
```java
|
|
||||||
@GET("/api/users")
|
|
||||||
public class ListUsers extends RequestHandler {
|
|
||||||
private UserService users;
|
|
||||||
|
|
||||||
@Override protected void onInit() { users = require(UserService.class); }
|
|
||||||
@Override public Object handle(Request req, Response res) { return users.list(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
app.scan("dev.example.api");
|
|
||||||
```
|
|
||||||
|
|
||||||
### Middleware
|
|
||||||
|
|
||||||
Apply middleware at registration. Flash composes the final chain at boot:
|
|
||||||
|
|
||||||
```java
|
|
||||||
Middleware authCheck = next -> (req, res) -> {
|
|
||||||
if (req.header("Authorization") == null)
|
|
||||||
return res.status(401).body("Unauthorized");
|
|
||||||
return next.handle(req, res);
|
|
||||||
};
|
|
||||||
|
|
||||||
app.get("/secure", (req, res) -> "secret data", authCheck);
|
|
||||||
```
|
|
||||||
|
|
||||||
Multiple middlewares are composed outermost-first (left-to-right in the call):
|
|
||||||
|
|
||||||
```java
|
|
||||||
app.get("/admin", handler, logging, auth, rateLimit);
|
|
||||||
// execution order: logging → auth → rateLimit → handler
|
|
||||||
```
|
|
||||||
|
|
||||||
### Classpath scan
|
|
||||||
|
|
||||||
Scans a package for classes that extend `RequestHandler` and carry `@Route`. Each is
|
|
||||||
instantiated via its public no-arg constructor:
|
|
||||||
|
|
||||||
```java
|
|
||||||
app.scan("dev.example.handlers");
|
|
||||||
```
|
|
||||||
|
|
||||||
### Namespace mounting
|
|
||||||
|
|
||||||
Mount a scoped sub-router under a prefix. All routes registered inside the scope get the
|
|
||||||
prefix prepended automatically. The scope inherits the parent's extension context (annotation
|
|
||||||
processors, services):
|
|
||||||
|
|
||||||
```java
|
|
||||||
app.mount("/api", scope -> {
|
|
||||||
scope.get("/health", (req, res) -> "ok"); // → GET /api/health
|
|
||||||
scope.scan("dev.example.api");
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Extensions
|
|
||||||
|
|
||||||
Extensions have one declarative `configure` method. They declare services, processors and route
|
|
||||||
callbacks; Flash resolves the complete graph, materialises routes, compiles both routers, then
|
|
||||||
opens listeners. Extension install order never makes a service “not ready”.
|
|
||||||
|
|
||||||
```java
|
|
||||||
FlashApp.create(8080)
|
|
||||||
.install(new JacksonExtension())
|
|
||||||
.install(new OpenApiExtension("/openapi", "My API", "1.0.0"))
|
|
||||||
.install(new OidcExtension(oidcConfig))
|
|
||||||
.scan("dev.example.handlers")
|
|
||||||
.start();
|
|
||||||
```
|
|
||||||
|
|
||||||
See extension-specific READMEs for full details:
|
|
||||||
- [`flash-ext-jackson-core`](flash-extensions/flash-ext-jackson-core/README.md)
|
|
||||||
- [`flash-ext-jackson-json`](flash-extensions/flash-ext-jackson-json/README.md)
|
|
||||||
- [`flash-ext-jackson-xml`](flash-extensions/flash-ext-jackson-xml/README.md)
|
|
||||||
- [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/README.md)
|
|
||||||
- [`flash-ext-security-core`](flash-extensions/flash-ext-security-core/docs/README.md)
|
|
||||||
- [`flash-ext-security-oidc`](flash-extensions/flash-ext-security-oidc/docs/README.md)
|
|
||||||
- [`flash-ext-security-apikey`](flash-extensions/flash-ext-security-apikey/docs/README.md)
|
|
||||||
- [`flash-ext-security-form`](flash-extensions/flash-ext-security-form/docs/README.md)
|
|
||||||
- [`flash-ext-security-test`](flash-extensions/flash-ext-security-test/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-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/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
|
|
||||||
|
|
||||||
```java
|
|
||||||
app.onNotFound((req, res) -> res.status(404).body("Not found: " + req.path()));
|
|
||||||
|
|
||||||
app.onException((ex, req, res) -> {
|
|
||||||
if (ex instanceof IllegalArgumentException)
|
|
||||||
return res.status(400).body(ex.getMessage());
|
|
||||||
return res.status(500).body("Internal error");
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## FlashConfiguration
|
|
||||||
|
|
||||||
| Field | Default | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `port` | — | TCP port to bind |
|
|
||||||
| `host` | `"0.0.0.0"` | Bind address |
|
|
||||||
| `tls` | `null` | TLS for the default listener — see [TLS](#tls) |
|
|
||||||
| `listeners` | `[]` | Multiple bind targets (port + host + optional TLS) on one app — see [TLS](#tls) |
|
|
||||||
| `maxHeaderBufferSize` | `65536` | Max size of the header buffer (bytes) |
|
|
||||||
| `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
|
|
||||||
|
|
||||||
```
|
|
||||||
TransportFactory.create() # binds every listener, wires the connection runner
|
|
||||||
→ AcceptLoop # one per listener × accept thread; hands sockets off
|
|
||||||
→ ConnectionRunner.accept() # per-connection setup: TLS handshake, protocol negotiation
|
|
||||||
→ ProtocolNegotiator # ALPN / h2c-preface — decides the protocol once
|
|
||||||
├─ Http1Connection.run() # request parser, router, handler, h1 response writer
|
|
||||||
└─ Http2Connection.run() # frame demux, HPACK, stream dispatch, flow control
|
|
||||||
→ RequestHandler.handle() # the same protocol-neutral request/response API
|
|
||||||
```
|
|
||||||
|
|
||||||
- **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.
|
|
||||||
- **Keep-alive** — `RequestParser` reuses its header buffer across requests on the same connection.
|
|
||||||
- **Chunked transfer** — both chunked request bodies (decoded via `ChunkedInputStream`) and chunked response bodies are supported.
|
|
||||||
- **TLS is transport-only** — see [TLS](#tls). Listeners bind either a plain `ServerSocket` or an `SSLServerSocket`; nothing downstream of `accept()` branches on which.
|
|
||||||
- **`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
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build all modules (skip tests)
|
|
||||||
mvn clean package -DskipTests
|
|
||||||
|
|
||||||
# Run all tests
|
|
||||||
mvn test
|
|
||||||
|
|
||||||
# Run a single test class
|
|
||||||
mvn test -pl flash -Dtest=RequestParserTest
|
|
||||||
```
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
<?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
@@ -1,41 +0,0 @@
|
|||||||
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
@@ -1,33 +0,0 @@
|
|||||||
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
@@ -1,66 +0,0 @@
|
|||||||
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
@@ -1,160 +0,0 @@
|
|||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<?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
@@ -1,50 +0,0 @@
|
|||||||
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
@@ -1,31 +0,0 @@
|
|||||||
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
@@ -1,64 +0,0 @@
|
|||||||
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
@@ -1,20 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
# 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`
|
|
||||||
- `TxManager.close()` as an `onClose` callback, so stopping the app releases the manager's
|
|
||||||
session factory and connection pool; give the manager a pool you want closed with the app
|
|
||||||
|
|
||||||
This makes the data layer composable with Flash's extension system without global state.
|
|
||||||
|
|
||||||
## Implementation notes
|
|
||||||
|
|
||||||
- The transaction stack is thread-local and is cleared once it becomes empty.
|
|
||||||
- Backend resources are suspended and restored for `REQUIRES_NEW` and `NOT_SUPPORTED`.
|
|
||||||
- `TxSynchronization` is the hook point for commit/rollback/completion callbacks.
|
|
||||||
- Synchronizations live in a thread-local list; every new transaction records how many were already
|
|
||||||
registered when it opened and fires only its own tail, so a `REQUIRES_NEW` does not drag along
|
|
||||||
the suspended transaction's callbacks.
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
|
||||||
<modelVersion>4.0.0</modelVersion>
|
|
||||||
|
|
||||||
<parent>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash-extensions</artifactId>
|
|
||||||
<version>2.1.0-SNAPSHOT</version>
|
|
||||||
</parent>
|
|
||||||
|
|
||||||
<artifactId>flash-ext-data-core</artifactId>
|
|
||||||
|
|
||||||
<dependencies>
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>jakarta.transaction</groupId>
|
|
||||||
<artifactId>jakarta.transaction-api</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>jakarta.annotation</groupId>
|
|
||||||
<artifactId>jakarta.annotation-api</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>jakarta.validation</groupId>
|
|
||||||
<artifactId>jakarta.validation-api</artifactId>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
|
|
||||||
<properties>
|
|
||||||
<maven.compiler.source>21</maven.compiler.source>
|
|
||||||
<maven.compiler.target>21</maven.compiler.target>
|
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
|
||||||
<jacoco.version>0.8.12</jacoco.version>
|
|
||||||
</properties>
|
|
||||||
|
|
||||||
<build>
|
|
||||||
<plugins>
|
|
||||||
<plugin>
|
|
||||||
<groupId>org.jacoco</groupId>
|
|
||||||
<artifactId>jacoco-maven-plugin</artifactId>
|
|
||||||
<version>${jacoco.version}</version>
|
|
||||||
<executions>
|
|
||||||
<execution>
|
|
||||||
<id>jacoco-initialize</id>
|
|
||||||
<goals>
|
|
||||||
<goal>prepare-agent</goal>
|
|
||||||
</goals>
|
|
||||||
</execution>
|
|
||||||
<execution>
|
|
||||||
<id>jacoco-site</id>
|
|
||||||
<phase>verify</phase>
|
|
||||||
<goals>
|
|
||||||
<goal>report</goal>
|
|
||||||
</goals>
|
|
||||||
<configuration>
|
|
||||||
<rules>
|
|
||||||
<rule>
|
|
||||||
<element>BUNDLE</element>
|
|
||||||
<limits>
|
|
||||||
<limit>
|
|
||||||
<counter>LINE</counter>
|
|
||||||
<value>COVEREDRATIO</value>
|
|
||||||
<minimum>0.80</minimum>
|
|
||||||
</limit>
|
|
||||||
</limits>
|
|
||||||
</rule>
|
|
||||||
</rules>
|
|
||||||
</configuration>
|
|
||||||
</execution>
|
|
||||||
</executions>
|
|
||||||
</plugin>
|
|
||||||
<plugin>
|
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
|
||||||
<artifactId>maven-surefire-plugin</artifactId>
|
|
||||||
<version>3.2.5</version>
|
|
||||||
</plugin>
|
|
||||||
</plugins>
|
|
||||||
</build>
|
|
||||||
</project>
|
|
||||||
-64
@@ -1,64 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data;
|
|
||||||
|
|
||||||
import dev.relism.flash.ext.data.core.Tx;
|
|
||||||
import dev.relism.flash.ext.data.core.Data;
|
|
||||||
import dev.relism.flash.ext.data.core.TxDefinition;
|
|
||||||
import dev.relism.flash.ext.data.core.TxManager;
|
|
||||||
import dev.relism.flash.ext.data.core.TransactionPropagation;
|
|
||||||
import dev.relism.flash.extension.FlashRegistrar;
|
|
||||||
import dev.relism.flash.extension.FlashContext;
|
|
||||||
import dev.relism.flash.extension.FlashExtension;
|
|
||||||
import dev.relism.flash.routing.MiddlewareKey;
|
|
||||||
import dev.relism.flash.routing.MiddlewareNode;
|
|
||||||
import dev.relism.flash.routing.Middleware;
|
|
||||||
import jakarta.transaction.Transactional;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
public final class DataExtension implements FlashExtension {
|
|
||||||
private static final MiddlewareKey TRANSACTION = MiddlewareKey.of("flash.data.transaction");
|
|
||||||
private final TxManager txManager;
|
|
||||||
private final Tx tx;
|
|
||||||
private final Data data;
|
|
||||||
|
|
||||||
public DataExtension(TxManager txManager) {
|
|
||||||
this(txManager, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public DataExtension(TxManager txManager, Data data) {
|
|
||||||
this.txManager = Objects.requireNonNull(txManager);
|
|
||||||
this.data = data;
|
|
||||||
this.tx = data != null ? data.tx() : new Tx(txManager);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
|
||||||
ctx.onClose(txManager::close);
|
|
||||||
ctx.provide(Tx.class, tx);
|
|
||||||
ctx.provide(TxManager.class, txManager);
|
|
||||||
if (data != null) ctx.provide(Data.class, data);
|
|
||||||
ctx.addAnnotationProcessor(handlerClass -> {
|
|
||||||
Transactional ann = handlerClass.getAnnotation(Transactional.class);
|
|
||||||
if (ann == null) {
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
TxDefinition definition = TxDefinition.DEFAULTS
|
|
||||||
.withPropagation(mapTxType(ann.value()));
|
|
||||||
Middleware middleware = next -> (req, res) -> {
|
|
||||||
return tx.call(definition, () -> next.handle(req, res));
|
|
||||||
};
|
|
||||||
return List.of(MiddlewareNode.of(TRANSACTION, middleware));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private TransactionPropagation mapTxType(Transactional.TxType txType) {
|
|
||||||
return switch (txType) {
|
|
||||||
case REQUIRED -> TransactionPropagation.REQUIRED;
|
|
||||||
case REQUIRES_NEW -> TransactionPropagation.REQUIRES_NEW;
|
|
||||||
case SUPPORTS -> TransactionPropagation.SUPPORTS;
|
|
||||||
case MANDATORY -> TransactionPropagation.MANDATORY;
|
|
||||||
case NOT_SUPPORTED, NEVER -> TransactionPropagation.NOT_SUPPORTED;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-46
@@ -1,46 +0,0 @@
|
|||||||
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(); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-15
@@ -1,15 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public record Page<T>(
|
|
||||||
List<T> content,
|
|
||||||
int page,
|
|
||||||
int size,
|
|
||||||
long total
|
|
||||||
) {
|
|
||||||
public int totalPages() { return size == 0 ? 0 : (int) Math.ceil((double) total / size); }
|
|
||||||
public boolean hasNext() { return page + 1 < totalPages(); }
|
|
||||||
public boolean hasPrev() { return page > 0; }
|
|
||||||
public boolean isEmpty() { return content.isEmpty(); }
|
|
||||||
}
|
|
||||||
-28
@@ -1,28 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-121
@@ -1,121 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
public abstract class Repository<T, ID> extends RepositorySupport<T, ID> {
|
|
||||||
|
|
||||||
protected Repository(Tx tx) {
|
|
||||||
super(tx);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Optional<T> findById(ID id) {
|
|
||||||
return roQuery(() -> doFindById(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean existsById(ID id) {
|
|
||||||
return roQuery(() -> doExistsById(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
public long count() {
|
|
||||||
return roQuery(this::doCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<T> findAll() {
|
|
||||||
return findAll(Query.all());
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<T> findAll(Spec<T> spec) {
|
|
||||||
return findAll(Query.<T>all().where(spec));
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<T> findAll(Query<T> query) {
|
|
||||||
return roQuery(() -> doFind(query));
|
|
||||||
}
|
|
||||||
|
|
||||||
public Page<T> findPage(Query<T> query) {
|
|
||||||
return roQuery(() -> doFindPage(query));
|
|
||||||
}
|
|
||||||
|
|
||||||
public Optional<T> findOne(Spec<T> spec) {
|
|
||||||
return roQuery(() -> doFindOne(spec));
|
|
||||||
}
|
|
||||||
|
|
||||||
public T save(T entity) {
|
|
||||||
return rwQuery(() -> doSave(entity));
|
|
||||||
}
|
|
||||||
|
|
||||||
public T update(T entity) {
|
|
||||||
return rwQuery(() -> doUpdate(entity));
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<T> saveAll(Iterable<T> entities) {
|
|
||||||
return rwQuery(() -> doSaveAll(entities));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void delete(T entity) {
|
|
||||||
rwQuery(() -> {
|
|
||||||
doDelete(entity);
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public void deleteById(ID id) {
|
|
||||||
rwQuery(() -> {
|
|
||||||
doDeleteById(id);
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public int deleteAll(Spec<T> spec) {
|
|
||||||
return rwQuery(() -> doDeleteAll(spec));
|
|
||||||
}
|
|
||||||
|
|
||||||
public int updateAll(Spec<T> spec, T patch) {
|
|
||||||
return rwQuery(() -> doUpdateAll(spec, patch));
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<T> findAll(int page, int size) {
|
|
||||||
return findAll(Query.<T>all().page(page, size));
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<T> findAll(Sort sort) {
|
|
||||||
return findAll(Query.<T>all().orderBy(sort));
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<T> findAll(int page, int size, Sort sort) {
|
|
||||||
return findAll(Query.<T>all().orderBy(sort).page(page, size));
|
|
||||||
}
|
|
||||||
|
|
||||||
public Page<T> findPage(int page, int size) {
|
|
||||||
return findPage(Query.<T>all().page(page, size));
|
|
||||||
}
|
|
||||||
|
|
||||||
public Page<T> findPage(int page, int size, Sort sort) {
|
|
||||||
return findPage(Query.<T>all().orderBy(sort).page(page, size));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void deleteAll(Iterable<T> entities) {
|
|
||||||
rwQuery(() -> {
|
|
||||||
for (T entity : entities) {
|
|
||||||
doDelete(entity);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected abstract Optional<T> doFindById(ID id);
|
|
||||||
protected abstract List<T> doFind(Query<T> query);
|
|
||||||
protected abstract Optional<T> doFindOne(Spec<T> spec);
|
|
||||||
protected abstract Page<T> doFindPage(Query<T> query);
|
|
||||||
protected abstract boolean doExistsById(ID id);
|
|
||||||
protected abstract long doCount();
|
|
||||||
protected abstract T doSave(T entity);
|
|
||||||
protected abstract List<T> doSaveAll(Iterable<T> entities);
|
|
||||||
protected abstract T doUpdate(T entity);
|
|
||||||
protected abstract void doDelete(T entity);
|
|
||||||
protected abstract void doDeleteById(ID id);
|
|
||||||
protected abstract int doDeleteAll(Spec<T> spec);
|
|
||||||
protected abstract int doUpdateAll(Spec<T> spec, T patch);
|
|
||||||
}
|
|
||||||
-9
@@ -1,9 +0,0 @@
|
|||||||
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
@@ -1,24 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-111
@@ -1,111 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
public final class ResourceRegistry {
|
|
||||||
private static final ThreadLocal<Map<TxResourceKey, Object>> RESOURCES =
|
|
||||||
ThreadLocal.withInitial(HashMap::new);
|
|
||||||
private static final ThreadLocal<List<TxSynchronization>> SYNCHRONIZATIONS =
|
|
||||||
ThreadLocal.withInitial(ArrayList::new);
|
|
||||||
|
|
||||||
private ResourceRegistry() {}
|
|
||||||
|
|
||||||
public static void bind(TxResourceKey key, Object value) {
|
|
||||||
RESOURCES.get().put(key, Objects.requireNonNull(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static boolean isBound(TxResourceKey key) {
|
|
||||||
return RESOURCES.get().containsKey(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void unbind(TxResourceKey key) {
|
|
||||||
RESOURCES.get().remove(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void clear() {
|
|
||||||
RESOURCES.get().clear();
|
|
||||||
SYNCHRONIZATIONS.get().clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void cleanup() {
|
|
||||||
RESOURCES.remove();
|
|
||||||
SYNCHRONIZATIONS.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static <R> R get(TxResourceKey key, Class<R> type) {
|
|
||||||
Object value = RESOURCES.get().get(key);
|
|
||||||
if (value == null) {
|
|
||||||
throw new IllegalStateException("No resource bound for key: " + key);
|
|
||||||
}
|
|
||||||
return type.cast(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static <R> R getOrNull(TxResourceKey key, Class<R> type) {
|
|
||||||
Object value = RESOURCES.get().get(key);
|
|
||||||
return value == null ? null : type.cast(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void addSynchronization(TxSynchronization sync) {
|
|
||||||
SYNCHRONIZATIONS.get().add(Objects.requireNonNull(sync));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How many synchronizations are registered right now — captured by a transaction manager when
|
|
||||||
* it opens a new transaction, and handed back to {@link #fireSynchronizations} on completion
|
|
||||||
* so that transaction only fires its own. See there for why that matters.
|
|
||||||
*/
|
|
||||||
public static int synchronizationCount() {
|
|
||||||
return SYNCHRONIZATIONS.get().size();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs {@link TxSynchronization#beforeCommit} on the synchronizations registered from
|
|
||||||
* {@code fromIndex} onward, while their transaction is still active and its resource still
|
|
||||||
* bound. Unlike {@link #fireSynchronizations} this leaves them registered: they still have
|
|
||||||
* their post-completion callbacks to come. Exceptions propagate on purpose — a beforeCommit
|
|
||||||
* that throws vetoes the commit, see {@link TxSynchronization}.
|
|
||||||
*
|
|
||||||
* <p>Snapshots before iterating, so a callback that registers further synchronizations (a
|
|
||||||
* nested {@code Data#afterCommit}) doesn't mutate the list mid-loop. Those new ones join the
|
|
||||||
* transaction's post-completion callbacks without getting a {@code beforeCommit} of their own,
|
|
||||||
* which is the only coherent answer once the pass is already running.
|
|
||||||
*/
|
|
||||||
public static void fireBeforeCommit(boolean readOnly, int fromIndex) {
|
|
||||||
List<TxSynchronization> pending = SYNCHRONIZATIONS.get();
|
|
||||||
if (fromIndex >= pending.size()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (TxSynchronization sync : List.copyOf(pending.subList(fromIndex, pending.size()))) {
|
|
||||||
sync.beforeCommit(readOnly);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fires (and removes) the synchronizations registered from {@code fromIndex} onward — the ones
|
|
||||||
* belonging to the transaction now completing. Everything before that index was registered by
|
|
||||||
* an enclosing transaction that is merely <em>suspended</em>, not finished: a REQUIRES_NEW
|
|
||||||
* inner transaction sets its own baseline, so committing it no longer drags the outer's
|
|
||||||
* pending callbacks along — which fired them early, and with the inner transaction's outcome,
|
|
||||||
* for an outer transaction that might still roll back.
|
|
||||||
*/
|
|
||||||
public static void fireSynchronizations(TxOutcome outcome, int fromIndex) {
|
|
||||||
List<TxSynchronization> pending = SYNCHRONIZATIONS.get();
|
|
||||||
if (fromIndex >= pending.size()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
List<TxSynchronization> syncs = List.copyOf(pending.subList(fromIndex, pending.size()));
|
|
||||||
pending.subList(fromIndex, pending.size()).clear();
|
|
||||||
for (TxSynchronization sync : syncs) {
|
|
||||||
if (outcome == TxOutcome.COMMITTED) {
|
|
||||||
sync.afterCommit();
|
|
||||||
} else {
|
|
||||||
sync.afterRollback();
|
|
||||||
}
|
|
||||||
sync.afterCompletion(outcome);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-26
@@ -1,26 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public record Sort(List<Column> columns) {
|
|
||||||
|
|
||||||
public record Column(String column, boolean asc) {}
|
|
||||||
|
|
||||||
public static Sort unsorted() { return new Sort(List.of()); }
|
|
||||||
|
|
||||||
public boolean isSorted() { return !columns.isEmpty(); }
|
|
||||||
|
|
||||||
public static Sort by(String column) { return new Sort(List.of(new Column(column, true))); }
|
|
||||||
public static Sort desc(String column) { return new Sort(List.of(new Column(column, false))); }
|
|
||||||
public static Sort by(String col, boolean asc){ return new Sort(List.of(new Column(col, asc))); }
|
|
||||||
|
|
||||||
public Sort then(String column) { return thenBy(column, true); }
|
|
||||||
public Sort thenDesc(String column) { return thenBy(column, false); }
|
|
||||||
|
|
||||||
private Sort thenBy(String column, boolean asc) {
|
|
||||||
List<Column> next = new ArrayList<>(columns);
|
|
||||||
next.add(new Column(column, asc));
|
|
||||||
return new Sort(next);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-26
@@ -1,26 +0,0 @@
|
|||||||
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
@@ -1,37 +0,0 @@
|
|||||||
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
@@ -1,5 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
public interface SpecContext {
|
|
||||||
String bind(Object value);
|
|
||||||
}
|
|
||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
public enum TransactionIsolation {
|
|
||||||
DEFAULT(-1),
|
|
||||||
READ_UNCOMMITTED(1),
|
|
||||||
READ_COMMITTED(2),
|
|
||||||
REPEATABLE_READ(4),
|
|
||||||
SERIALIZABLE(8);
|
|
||||||
|
|
||||||
private final int level;
|
|
||||||
|
|
||||||
TransactionIsolation(int level) { this.level = level; }
|
|
||||||
public int level() { return level; }
|
|
||||||
}
|
|
||||||
-9
@@ -1,9 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
public enum TransactionPropagation {
|
|
||||||
REQUIRED,
|
|
||||||
REQUIRES_NEW,
|
|
||||||
SUPPORTS,
|
|
||||||
NOT_SUPPORTED,
|
|
||||||
MANDATORY
|
|
||||||
}
|
|
||||||
-116
@@ -1,116 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
import java.util.ArrayDeque;
|
|
||||||
import java.util.Deque;
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
public final class Tx {
|
|
||||||
private static final ThreadLocal<Deque<TxStatus>> STATUS_STACK =
|
|
||||||
ThreadLocal.withInitial(ArrayDeque::new);
|
|
||||||
private final TxManager manager;
|
|
||||||
|
|
||||||
public Tx(TxManager txManager) {
|
|
||||||
this.manager = Objects.requireNonNull(txManager);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void run(TxRunnable work) {
|
|
||||||
run(TxDefinition.DEFAULTS, work);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void run(TxDefinition definition, TxRunnable work) {
|
|
||||||
call(definition, () -> {
|
|
||||||
work.run();
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public <T> T call(TxCallable<T> work) {
|
|
||||||
return call(TxDefinition.DEFAULTS, work);
|
|
||||||
}
|
|
||||||
|
|
||||||
public <T> T call(TxDefinition definition, TxCallable<T> work) {
|
|
||||||
TxStatus status = manager.begin(definition);
|
|
||||||
pushStatus(status);
|
|
||||||
try {
|
|
||||||
T result = work.call();
|
|
||||||
if (status.isRollbackOnly()) {
|
|
||||||
manager.rollback(status);
|
|
||||||
} else {
|
|
||||||
manager.commit(status);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
} catch (Exception e) {
|
|
||||||
silentRollback(status);
|
|
||||||
throw (e instanceof TxException txException) ? txException : new TxException(e);
|
|
||||||
} catch (Throwable t) {
|
|
||||||
silentRollback(status);
|
|
||||||
throw sneakyThrow(t);
|
|
||||||
} finally {
|
|
||||||
popStatus();
|
|
||||||
if (STATUS_STACK.get().isEmpty()) {
|
|
||||||
STATUS_STACK.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isActive() {
|
|
||||||
return !STATUS_STACK.get().isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setRollbackOnly() {
|
|
||||||
currentStatus().markRollbackOnly();
|
|
||||||
}
|
|
||||||
|
|
||||||
public <R> R resource(Class<R> type) {
|
|
||||||
return currentStatus().resource(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
public TxDefinition requiresNew() {
|
|
||||||
return TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW);
|
|
||||||
}
|
|
||||||
|
|
||||||
public TxDefinition readOnly() {
|
|
||||||
return TxDefinition.DEFAULTS.asReadOnly();
|
|
||||||
}
|
|
||||||
|
|
||||||
private TxStatus currentStatus() {
|
|
||||||
TxStatus status = STATUS_STACK.get().peek();
|
|
||||||
if (status == null) {
|
|
||||||
throw new IllegalStateException("No active transaction");
|
|
||||||
}
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void pushStatus(TxStatus status) {
|
|
||||||
STATUS_STACK.get().push(status);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void popStatus() {
|
|
||||||
Deque<TxStatus> stack = STATUS_STACK.get();
|
|
||||||
if (!stack.isEmpty()) {
|
|
||||||
stack.pop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void silentRollback(TxStatus status) {
|
|
||||||
try {
|
|
||||||
manager.rollback(status);
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
private static <E extends Throwable> RuntimeException sneakyThrow(Throwable t) throws E {
|
|
||||||
throw (E) t;
|
|
||||||
}
|
|
||||||
|
|
||||||
@FunctionalInterface
|
|
||||||
public interface TxRunnable {
|
|
||||||
void run();
|
|
||||||
}
|
|
||||||
|
|
||||||
@FunctionalInterface
|
|
||||||
public interface TxCallable<T> {
|
|
||||||
T call() throws Exception;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-31
@@ -1,31 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
public record TxDefinition(
|
|
||||||
TransactionPropagation propagation,
|
|
||||||
TransactionIsolation isolation,
|
|
||||||
boolean readOnly,
|
|
||||||
String label
|
|
||||||
) {
|
|
||||||
public static final TxDefinition DEFAULTS = new TxDefinition(
|
|
||||||
TransactionPropagation.REQUIRED,
|
|
||||||
TransactionIsolation.DEFAULT,
|
|
||||||
false,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
public TxDefinition withPropagation(TransactionPropagation p) {
|
|
||||||
return new TxDefinition(p, isolation, readOnly, label);
|
|
||||||
}
|
|
||||||
|
|
||||||
public TxDefinition withIsolation(TransactionIsolation i) {
|
|
||||||
return new TxDefinition(propagation, i, readOnly, label);
|
|
||||||
}
|
|
||||||
|
|
||||||
public TxDefinition withReadOnly(boolean ro) {
|
|
||||||
return new TxDefinition(propagation, isolation, ro, label);
|
|
||||||
}
|
|
||||||
|
|
||||||
public TxDefinition asReadOnly() {
|
|
||||||
return new TxDefinition(propagation, isolation, true, label);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-6
@@ -1,6 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
public class TxException extends RuntimeException {
|
|
||||||
public TxException(String message) { super(message); }
|
|
||||||
public TxException(Throwable cause) { super(cause); }
|
|
||||||
}
|
|
||||||
-11
@@ -1,11 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
public interface TxManager extends AutoCloseable {
|
|
||||||
TxStatus begin(TxDefinition definition);
|
|
||||||
void commit(TxStatus status);
|
|
||||||
void rollback(TxStatus status);
|
|
||||||
|
|
||||||
/** Releases what this manager was built on, its connection pool included. {@link dev.relism.flash.ext.data.DataExtension} calls it when the app stops. */
|
|
||||||
@Override
|
|
||||||
void close();
|
|
||||||
}
|
|
||||||
-6
@@ -1,6 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
public enum TxOutcome {
|
|
||||||
COMMITTED,
|
|
||||||
ROLLED_BACK
|
|
||||||
}
|
|
||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
public final class TxResourceKey {
|
|
||||||
private final String name;
|
|
||||||
|
|
||||||
private TxResourceKey(String name) {
|
|
||||||
this.name = Objects.requireNonNull(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static TxResourceKey of(String name) {
|
|
||||||
return new TxResourceKey(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean equals(Object o) {
|
|
||||||
if (this == o) return true;
|
|
||||||
if (!(o instanceof TxResourceKey that)) return false;
|
|
||||||
return name.equals(that.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int hashCode() {
|
|
||||||
return name.hashCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String toString() {
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-9
@@ -1,9 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
public interface TxStatus {
|
|
||||||
boolean isNewTransaction();
|
|
||||||
boolean isReadOnly();
|
|
||||||
boolean isRollbackOnly();
|
|
||||||
void markRollbackOnly();
|
|
||||||
<R> R resource(Class<R> type);
|
|
||||||
}
|
|
||||||
-58
@@ -1,58 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lifecycle hooks for one transaction, registered through {@code Data#afterCommit} (or
|
|
||||||
* {@link ResourceRegistry#addSynchronization} directly) and fired by the {@link TxManager} that
|
|
||||||
* owns the transaction they were registered in.
|
|
||||||
*
|
|
||||||
* <p>Every callback belongs to exactly one transaction — the innermost one active at registration
|
|
||||||
* time — and fires exactly once, when <em>that</em> transaction completes. A joined
|
|
||||||
* ({@code REQUIRED}) inner transaction is not a transaction of its own, so callbacks registered
|
|
||||||
* inside one wait for the outermost commit; a {@code REQUIRES_NEW} transaction is, so completing
|
|
||||||
* it fires only its own callbacks and leaves the suspended outer transaction's alone.
|
|
||||||
*
|
|
||||||
* <h3>Where each hook sits relative to the commit</h3>
|
|
||||||
* <ul>
|
|
||||||
* <li>{@link #beforeCommit(boolean)} — immediately <b>before</b> the real commit, with the
|
|
||||||
* transaction still active and its session/connection still bound. This is the only hook
|
|
||||||
* that can still write through that same resource and have the write land in the same atomic
|
|
||||||
* unit: flush a buffer, stamp an audit row, materialize a derived value. Skipped entirely
|
|
||||||
* when the transaction is already rollback-only, since there is no commit to precede.</li>
|
|
||||||
* <li>{@link #afterCommit()} / {@link #afterRollback()}, then {@link #afterCompletion(TxOutcome)}
|
|
||||||
* — <b>after</b> the transaction has completed and its resource has been unbound. Nothing
|
|
||||||
* done here is part of the transaction: a callback that opens its own transaction gets a
|
|
||||||
* fresh one instead of joining the one that just finished, which is what makes this the
|
|
||||||
* right place to refresh a cache, enqueue a message, or notify anything outside the
|
|
||||||
* database.</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <h3>Failure</h3>
|
|
||||||
* Throwing from {@link #beforeCommit(boolean)} <b>vetoes the commit</b>: the transaction is rolled
|
|
||||||
* back, {@link #afterRollback()}/{@link #afterCompletion(TxOutcome)} fire with
|
|
||||||
* {@link TxOutcome#ROLLED_BACK}, and the exception propagates to the caller. That is the point of
|
|
||||||
* this hook running before the commit rather than after — it can still refuse.
|
|
||||||
*
|
|
||||||
* <p>The post-completion hooks have no such power: the transaction is over by the time they run,
|
|
||||||
* so an exception from one propagates to the caller but changes nothing already committed, and
|
|
||||||
* stops the callbacks queued behind it.
|
|
||||||
*/
|
|
||||||
public interface TxSynchronization {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs inside the transaction, immediately before it commits — see the interface javadoc.
|
|
||||||
* Throwing from here rolls the transaction back instead of committing it.
|
|
||||||
*
|
|
||||||
* @param readOnly whether the transaction was opened read-only, so a callback that would
|
|
||||||
* otherwise write can skip work it is not allowed to do
|
|
||||||
*/
|
|
||||||
default void beforeCommit(boolean readOnly) {}
|
|
||||||
|
|
||||||
/** Runs after a successful commit, with the transaction's resource already unbound. */
|
|
||||||
default void afterCommit() {}
|
|
||||||
|
|
||||||
/** Runs after a rollback, with the transaction's resource already unbound. */
|
|
||||||
default void afterRollback() {}
|
|
||||||
|
|
||||||
/** Runs after {@link #afterCommit()}/{@link #afterRollback()}, whichever applied. */
|
|
||||||
default void afterCompletion(TxOutcome outcome) {}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
|
||||||
<modelVersion>4.0.0</modelVersion>
|
|
||||||
|
|
||||||
<parent>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash-extensions</artifactId>
|
|
||||||
<version>2.1.0-SNAPSHOT</version>
|
|
||||||
</parent>
|
|
||||||
|
|
||||||
<artifactId>flash-ext-data-hibernate</artifactId>
|
|
||||||
|
|
||||||
<properties>
|
|
||||||
<jacoco.version>0.8.12</jacoco.version>
|
|
||||||
</properties>
|
|
||||||
|
|
||||||
<dependencies>
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash-ext-data-core</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.hibernate.orm</groupId>
|
|
||||||
<artifactId>hibernate-core</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.projectlombok</groupId>
|
|
||||||
<artifactId>lombok</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.junit.jupiter</groupId>
|
|
||||||
<artifactId>junit-jupiter</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.h2database</groupId>
|
|
||||||
<artifactId>h2</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
|
|
||||||
<build>
|
|
||||||
<plugins>
|
|
||||||
<plugin>
|
|
||||||
<groupId>org.jacoco</groupId>
|
|
||||||
<artifactId>jacoco-maven-plugin</artifactId>
|
|
||||||
<version>${jacoco.version}</version>
|
|
||||||
<configuration>
|
|
||||||
<rules>
|
|
||||||
<rule>
|
|
||||||
<element>BUNDLE</element>
|
|
||||||
<limits>
|
|
||||||
<limit>
|
|
||||||
<counter>LINE</counter>
|
|
||||||
<value>COVEREDRATIO</value>
|
|
||||||
<minimum>0.80</minimum>
|
|
||||||
</limit>
|
|
||||||
</limits>
|
|
||||||
</rule>
|
|
||||||
</rules>
|
|
||||||
</configuration>
|
|
||||||
</plugin>
|
|
||||||
</plugins>
|
|
||||||
</build>
|
|
||||||
</project>
|
|
||||||
-25
@@ -1,25 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-167
@@ -1,167 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.hibernate;
|
|
||||||
|
|
||||||
import dev.relism.flash.ext.data.core.*;
|
|
||||||
import jakarta.persistence.TypedQuery;
|
|
||||||
import org.hibernate.Session;
|
|
||||||
import org.hibernate.query.MutationQuery;
|
|
||||||
|
|
||||||
import java.io.Serializable;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.function.Consumer;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
public abstract class HibernateRepository<T, ID extends Serializable> extends Repository<T, ID> {
|
|
||||||
|
|
||||||
private final Class<T> type;
|
|
||||||
|
|
||||||
protected HibernateRepository(Tx tx, Class<T> type) {
|
|
||||||
super(tx);
|
|
||||||
this.type = type;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected Session session() {
|
|
||||||
return tx().resource(Session.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected Optional<T> doFindById(ID id) {
|
|
||||||
return Optional.ofNullable(session().get(type, id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected List<T> doFind(Query<T> query) {
|
|
||||||
HibernateSpecContext ctx = new HibernateSpecContext();
|
|
||||||
String where = query.spec() != null ? " where " + query.spec().toFragment(ctx) : "";
|
|
||||||
String order = query.sort() != null && query.sort().isSorted() ? " order by " + orderClause(query.sort()) : "";
|
|
||||||
|
|
||||||
TypedQuery<T> q = session().createQuery("from " + type.getSimpleName() + where + order, type);
|
|
||||||
ctx.applyParameters(q);
|
|
||||||
|
|
||||||
if (query.isPaged()) {
|
|
||||||
q.setFirstResult(query.page() * query.size());
|
|
||||||
q.setMaxResults(query.size());
|
|
||||||
}
|
|
||||||
return q.getResultList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected Optional<T> doFindOne(Spec<T> spec) {
|
|
||||||
return doFind(Query.<T>all().where(spec).page(0, 1)).stream().findFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected Page<T> doFindPage(Query<T> query) {
|
|
||||||
if (!query.isPaged()) {
|
|
||||||
throw new IllegalArgumentException("Paged query requires page and size");
|
|
||||||
}
|
|
||||||
long total = countWhere(query.spec());
|
|
||||||
List<T> content = doFind(query);
|
|
||||||
return new Page<>(content, query.page(), query.size(), total);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected boolean doExistsById(ID id) {
|
|
||||||
return doFindById(id).isPresent();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected long doCount() {
|
|
||||||
return countWhere(Spec.all());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected T doSave(T entity) {
|
|
||||||
session().persist(entity);
|
|
||||||
return entity;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected List<T> doSaveAll(Iterable<T> entities) {
|
|
||||||
List<T> saved = new ArrayList<>();
|
|
||||||
Session s = session();
|
|
||||||
int i = 0;
|
|
||||||
for (T entity : entities) {
|
|
||||||
s.persist(entity);
|
|
||||||
saved.add(entity);
|
|
||||||
if (++i % 50 == 0) {
|
|
||||||
s.flush();
|
|
||||||
s.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return saved;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected T doUpdate(T entity) {
|
|
||||||
return session().merge(entity);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void doDelete(T entity) {
|
|
||||||
Session s = session();
|
|
||||||
s.remove(s.contains(entity) ? entity : s.merge(entity));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void doDeleteById(ID id) {
|
|
||||||
doFindById(id).ifPresent(this::doDelete);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected int doDeleteAll(Spec<T> spec) {
|
|
||||||
HibernateSpecContext ctx = new HibernateSpecContext();
|
|
||||||
String where = " where " + spec.toFragment(ctx);
|
|
||||||
MutationQuery q = session().createMutationQuery("delete from " + type.getSimpleName() + where);
|
|
||||||
ctx.applyParameters(q);
|
|
||||||
return q.executeUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected int doUpdateAll(Spec<T> spec, T patch) {
|
|
||||||
throw new UnsupportedOperationException("Override doUpdateAll() for bulk UPDATE support");
|
|
||||||
}
|
|
||||||
|
|
||||||
protected List<T> hql(String hql, Consumer<TypedQuery<T>> params) {
|
|
||||||
return roQuery(() -> {
|
|
||||||
TypedQuery<T> q = session().createQuery(hql, type);
|
|
||||||
params.accept(q);
|
|
||||||
return q.getResultList();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected <R> List<R> hql(String hql, Class<R> resultType, Consumer<TypedQuery<R>> params) {
|
|
||||||
return roQuery(() -> {
|
|
||||||
TypedQuery<R> q = session().createQuery(hql, resultType);
|
|
||||||
params.accept(q);
|
|
||||||
return q.getResultList();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected int hqlMutate(String hql, Consumer<MutationQuery> params) {
|
|
||||||
return rwQuery(() -> {
|
|
||||||
MutationQuery q = session().createMutationQuery(hql);
|
|
||||||
params.accept(q);
|
|
||||||
return q.executeUpdate();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected Class<T> entityType() {
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
|
|
||||||
private long countWhere(Spec<T> spec) {
|
|
||||||
HibernateSpecContext ctx = new HibernateSpecContext();
|
|
||||||
String where = spec != null ? " where " + spec.toFragment(ctx) : "";
|
|
||||||
TypedQuery<Long> q = session().createQuery("select count(*) from " + type.getSimpleName() + where, Long.class);
|
|
||||||
ctx.applyParameters(q);
|
|
||||||
return q.getResultStream().findFirst().orElse(0L);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String orderClause(Sort sort) {
|
|
||||||
return sort.columns().stream()
|
|
||||||
.map(c -> c.column() + (c.asc() ? " ASC" : " DESC"))
|
|
||||||
.collect(Collectors.joining(", "));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-28
@@ -1,28 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-241
@@ -1,241 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.hibernate;
|
|
||||||
|
|
||||||
import dev.relism.flash.ext.data.core.*;
|
|
||||||
import org.hibernate.Session;
|
|
||||||
import org.hibernate.SessionFactory;
|
|
||||||
import org.hibernate.engine.jdbc.connections.spi.ConnectionProvider;
|
|
||||||
import org.hibernate.engine.spi.SessionFactoryImplementor;
|
|
||||||
|
|
||||||
import javax.sql.DataSource;
|
|
||||||
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
public class HibernateTxManager implements TxManager {
|
|
||||||
private static final TxResourceKey HIBERNATE_STATUS_KEY = TxResourceKey.of("hibernate.tx.status");
|
|
||||||
private static final TxResourceKey HIBERNATE_SUSPENDED_KEY = TxResourceKey.of("hibernate.tx.suspended");
|
|
||||||
|
|
||||||
private final SessionFactory sf;
|
|
||||||
|
|
||||||
public HibernateTxManager(SessionFactory sessionFactory) {
|
|
||||||
this.sf = Objects.requireNonNull(sessionFactory);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Closes the session factory, then the data source it was given ({@code jakarta.persistence.nonJtaDataSource}
|
|
||||||
* or {@code hibernate.connection.datasource}): Hibernate stops a pool it built itself, never one handed to it.
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public void close() {
|
|
||||||
ConnectionProvider connections = sf.unwrap(SessionFactoryImplementor.class).getServiceRegistry().getService(ConnectionProvider.class);
|
|
||||||
DataSource ds = connections != null && connections.isUnwrappableAs(DataSource.class) ? connections.unwrap(DataSource.class) : null;
|
|
||||||
sf.close();
|
|
||||||
if (ds instanceof AutoCloseable closeable) {
|
|
||||||
try {
|
|
||||||
closeable.close();
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new IllegalStateException("Failed to close the data source", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public TxStatus begin(TxDefinition definition) {
|
|
||||||
return switch (definition.propagation()) {
|
|
||||||
case REQUIRED -> ResourceRegistry.isBound(HIBERNATE_STATUS_KEY)
|
|
||||||
? joinExisting(definition)
|
|
||||||
: beginNew(definition);
|
|
||||||
case REQUIRES_NEW -> beginNew(definition);
|
|
||||||
case SUPPORTS -> ResourceRegistry.isBound(HIBERNATE_STATUS_KEY)
|
|
||||||
? joinExisting(definition)
|
|
||||||
: noOp(definition);
|
|
||||||
case MANDATORY -> {
|
|
||||||
if (!ResourceRegistry.isBound(HIBERNATE_STATUS_KEY))
|
|
||||||
throw new IllegalStateException("MANDATORY: no active transaction");
|
|
||||||
yield joinExisting(definition);
|
|
||||||
}
|
|
||||||
case NOT_SUPPORTED -> {
|
|
||||||
HibernateTxStatus suspended = suspendIfNeeded();
|
|
||||||
yield noOp(definition, suspended);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private TxStatus beginNew(TxDefinition definition) {
|
|
||||||
return beginNew(definition, suspendIfNeeded());
|
|
||||||
}
|
|
||||||
|
|
||||||
private TxStatus beginNew(TxDefinition definition, HibernateTxStatus suspended) {
|
|
||||||
// Anything already registered belongs to an enclosing transaction this one is nested
|
|
||||||
// inside (or suspended over) — see ResourceRegistry#fireSynchronizations.
|
|
||||||
int synchronizationBaseline = ResourceRegistry.synchronizationCount();
|
|
||||||
Session s = sf.openSession();
|
|
||||||
boolean bound = false;
|
|
||||||
try {
|
|
||||||
s.beginTransaction();
|
|
||||||
if (definition.readOnly()) s.setDefaultReadOnly(true);
|
|
||||||
if (definition.isolation() != TransactionIsolation.DEFAULT) {
|
|
||||||
s.doWork(connection -> connection.setTransactionIsolation(definition.isolation().level()));
|
|
||||||
}
|
|
||||||
HibernateTxStatus status = new HibernateTxStatus(
|
|
||||||
s,
|
|
||||||
true,
|
|
||||||
definition.readOnly(),
|
|
||||||
suspended,
|
|
||||||
new HibernateTxStatus.RollbackMarker(),
|
|
||||||
synchronizationBaseline
|
|
||||||
);
|
|
||||||
ResourceRegistry.bind(HIBERNATE_STATUS_KEY, status);
|
|
||||||
bound = true;
|
|
||||||
return status;
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
silentClose(s);
|
|
||||||
throw e;
|
|
||||||
} catch (Exception e) {
|
|
||||||
silentClose(s);
|
|
||||||
throw new TxException(e);
|
|
||||||
} finally {
|
|
||||||
if (!bound && suspended != null) {
|
|
||||||
ResourceRegistry.unbind(HIBERNATE_SUSPENDED_KEY);
|
|
||||||
ResourceRegistry.bind(HIBERNATE_STATUS_KEY, suspended);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private TxStatus joinExisting(TxDefinition definition) {
|
|
||||||
HibernateTxStatus existing = ResourceRegistry.get(HIBERNATE_STATUS_KEY, HibernateTxStatus.class);
|
|
||||||
if (definition.readOnly() && !existing.isReadOnly()) {
|
|
||||||
throw new TxException("Cannot join read-write tx as read-only");
|
|
||||||
}
|
|
||||||
// Baseline 0 is never read: a joined status isn't a new transaction, so commit()/rollback()
|
|
||||||
// hand it straight back to the transaction it joined without firing anything.
|
|
||||||
return new HibernateTxStatus(
|
|
||||||
existing.session(),
|
|
||||||
false,
|
|
||||||
definition.readOnly(),
|
|
||||||
null,
|
|
||||||
existing.rollbackMarker(),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private TxStatus noOp(TxDefinition definition) {
|
|
||||||
return noOp(definition, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private TxStatus noOp(TxDefinition definition, HibernateTxStatus suspended) {
|
|
||||||
return new HibernateTxStatus(null, false, definition.readOnly(), suspended, new HibernateTxStatus.RollbackMarker(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private HibernateTxStatus suspendIfNeeded() {
|
|
||||||
HibernateTxStatus suspended = ResourceRegistry.getOrNull(HIBERNATE_STATUS_KEY, HibernateTxStatus.class);
|
|
||||||
if (suspended != null) {
|
|
||||||
ResourceRegistry.unbind(HIBERNATE_STATUS_KEY);
|
|
||||||
ResourceRegistry.bind(HIBERNATE_SUSPENDED_KEY, suspended);
|
|
||||||
}
|
|
||||||
return suspended;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void commit(TxStatus status) {
|
|
||||||
HibernateTxStatus s = (HibernateTxStatus) status;
|
|
||||||
if (!s.isNewTransaction()) {
|
|
||||||
resumeIfNeeded(s);
|
|
||||||
cleanupIfIdle();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
TxOutcome outcome = null;
|
|
||||||
try {
|
|
||||||
if (s.isRollbackOnly() && s.session().getTransaction().isActive()) {
|
|
||||||
s.session().getTransaction().rollback();
|
|
||||||
outcome = TxOutcome.ROLLED_BACK;
|
|
||||||
} else {
|
|
||||||
// Still inside the transaction, session still bound: a beforeCommit callback can
|
|
||||||
// write through it and land in this same commit. Throwing from there vetoes the
|
|
||||||
// commit — see TxSynchronization.
|
|
||||||
ResourceRegistry.fireBeforeCommit(s.isReadOnly(), s.synchronizationBaseline());
|
|
||||||
s.session().getTransaction().commit();
|
|
||||||
outcome = TxOutcome.COMMITTED;
|
|
||||||
}
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
// A vetoing beforeCommit, or a commit that failed outright: either way nothing was
|
|
||||||
// committed, so roll back and let the remaining callbacks hear ROLLED_BACK rather than
|
|
||||||
// nothing at all. A rollback failure here is swallowed deliberately — it would mask
|
|
||||||
// the exception that actually explains what went wrong, which is the one propagating.
|
|
||||||
if (s.session().getTransaction().isActive()) {
|
|
||||||
try {
|
|
||||||
s.session().getTransaction().rollback();
|
|
||||||
} catch (RuntimeException suppressed) {
|
|
||||||
e.addSuppressed(suppressed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
outcome = TxOutcome.ROLLED_BACK;
|
|
||||||
throw e;
|
|
||||||
} finally {
|
|
||||||
// Order is load-bearing, and getting it wrong is silent: cleanupIfIdle() calls
|
|
||||||
// ResourceRegistry.cleanup(), which removes the very ThreadLocal list of
|
|
||||||
// synchronizations still waiting to be fired — firing afterwards saw a freshly
|
|
||||||
// initialized empty list and dropped every callback on the floor. cleanupAndResume()
|
|
||||||
// still has to come first, so a synchronization that opens its own transaction
|
|
||||||
// (Registry#reload() in Pathway does) starts a fresh one instead of joining the
|
|
||||||
// session that just committed. rollback() below already had this order right.
|
|
||||||
cleanupAndResume(s);
|
|
||||||
if (outcome != null) ResourceRegistry.fireSynchronizations(outcome, s.synchronizationBaseline());
|
|
||||||
cleanupIfIdle();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void rollback(TxStatus status) {
|
|
||||||
HibernateTxStatus s = (HibernateTxStatus) status;
|
|
||||||
if (!s.isNewTransaction()) {
|
|
||||||
s.markRollbackOnly();
|
|
||||||
resumeIfNeeded(s);
|
|
||||||
cleanupIfIdle();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (s.session().getTransaction().isActive()) {
|
|
||||||
s.session().getTransaction().rollback();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
// Same order as commit(): unbind the session first so a callback opening its own
|
|
||||||
// transaction gets a fresh one, fire before cleanupIfIdle() can drop the list.
|
|
||||||
cleanupAndResume(s);
|
|
||||||
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK, s.synchronizationBaseline());
|
|
||||||
cleanupIfIdle();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void cleanupAndResume(HibernateTxStatus status) {
|
|
||||||
ResourceRegistry.unbind(HIBERNATE_STATUS_KEY);
|
|
||||||
silentClose(status.session());
|
|
||||||
resumeIfNeeded(status);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void cleanupIfIdle() {
|
|
||||||
if (!ResourceRegistry.isBound(HIBERNATE_STATUS_KEY) && !ResourceRegistry.isBound(HIBERNATE_SUSPENDED_KEY)) {
|
|
||||||
ResourceRegistry.cleanup();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void resumeIfNeeded(HibernateTxStatus status) {
|
|
||||||
HibernateTxStatus suspended = status.suspended();
|
|
||||||
if (suspended == null) {
|
|
||||||
suspended = ResourceRegistry.getOrNull(HIBERNATE_SUSPENDED_KEY, HibernateTxStatus.class);
|
|
||||||
}
|
|
||||||
if (suspended != null) {
|
|
||||||
ResourceRegistry.unbind(HIBERNATE_SUSPENDED_KEY);
|
|
||||||
ResourceRegistry.bind(HIBERNATE_STATUS_KEY, suspended);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void silentClose(Session session) {
|
|
||||||
if (session == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
session.close();
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-53
@@ -1,53 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.hibernate;
|
|
||||||
|
|
||||||
import dev.relism.flash.ext.data.core.TxStatus;
|
|
||||||
import org.hibernate.Session;
|
|
||||||
|
|
||||||
class HibernateTxStatus implements TxStatus {
|
|
||||||
static final class RollbackMarker {
|
|
||||||
boolean rollbackOnly;
|
|
||||||
}
|
|
||||||
|
|
||||||
private final Session session;
|
|
||||||
private final boolean newTransaction;
|
|
||||||
private final boolean readOnly;
|
|
||||||
private final HibernateTxStatus suspended;
|
|
||||||
private final RollbackMarker rollbackMarker;
|
|
||||||
private final int synchronizationBaseline;
|
|
||||||
|
|
||||||
HibernateTxStatus(
|
|
||||||
Session session,
|
|
||||||
boolean newTransaction,
|
|
||||||
boolean readOnly,
|
|
||||||
HibernateTxStatus suspended,
|
|
||||||
RollbackMarker rollbackMarker,
|
|
||||||
int synchronizationBaseline
|
|
||||||
) {
|
|
||||||
this.session = session;
|
|
||||||
this.newTransaction = newTransaction;
|
|
||||||
this.readOnly = readOnly;
|
|
||||||
this.suspended = suspended;
|
|
||||||
this.rollbackMarker = rollbackMarker;
|
|
||||||
this.synchronizationBaseline = synchronizationBaseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public boolean isNewTransaction() { return newTransaction; }
|
|
||||||
@Override public boolean isReadOnly() { return readOnly; }
|
|
||||||
@Override public boolean isRollbackOnly() { return rollbackMarker.rollbackOnly; }
|
|
||||||
@Override public void markRollbackOnly() { rollbackMarker.rollbackOnly = true; }
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public <R> R resource(Class<R> type) {
|
|
||||||
if (session == null) {
|
|
||||||
throw new IllegalStateException("No session bound to this transaction status");
|
|
||||||
}
|
|
||||||
return type.cast(session);
|
|
||||||
}
|
|
||||||
|
|
||||||
Session session() { return session; }
|
|
||||||
HibernateTxStatus suspended() { return suspended; }
|
|
||||||
RollbackMarker rollbackMarker() { return rollbackMarker; }
|
|
||||||
|
|
||||||
/** Index into {@code ResourceRegistry}'s synchronization list where this transaction's own callbacks start. */
|
|
||||||
int synchronizationBaseline() { return synchronizationBaseline; }
|
|
||||||
}
|
|
||||||
-48
@@ -1,48 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.hibernate;
|
|
||||||
|
|
||||||
import org.hibernate.SessionFactory;
|
|
||||||
import org.hibernate.boot.MetadataSources;
|
|
||||||
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
|
|
||||||
import org.hibernate.cfg.AvailableSettings;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
class HibernateTxManagerCloseTest {
|
|
||||||
|
|
||||||
/** Stands in for a pool: closeable, and it records being closed. */
|
|
||||||
static final class Pool implements DataSource, AutoCloseable {
|
|
||||||
boolean closed;
|
|
||||||
@Override public Connection getConnection() throws SQLException { return DriverManager.getConnection("jdbc:h2:mem:tx-close;DB_CLOSE_DELAY=-1"); }
|
|
||||||
@Override public Connection getConnection(String user, String password) throws SQLException { return getConnection(); }
|
|
||||||
@Override public void close() { closed = true; }
|
|
||||||
@Override public <T> T unwrap(Class<T> type) { throw new UnsupportedOperationException(); }
|
|
||||||
@Override public boolean isWrapperFor(Class<?> type) { return false; }
|
|
||||||
@Override public PrintWriter getLogWriter() { return null; }
|
|
||||||
@Override public void setLogWriter(PrintWriter out) {}
|
|
||||||
@Override public void setLoginTimeout(int seconds) {}
|
|
||||||
@Override public int getLoginTimeout() { return 0; }
|
|
||||||
@Override public Logger getParentLogger() { throw new UnsupportedOperationException(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void closingTheManagerClosesTheDataSourceHibernateWasGiven() {
|
|
||||||
Pool pool = new Pool();
|
|
||||||
SessionFactory sf = new MetadataSources(new StandardServiceRegistryBuilder()
|
|
||||||
.applySetting(AvailableSettings.JAKARTA_NON_JTA_DATASOURCE, pool)
|
|
||||||
.applySetting(AvailableSettings.DIALECT, "org.hibernate.dialect.H2Dialect")
|
|
||||||
.build()).buildMetadata().buildSessionFactory();
|
|
||||||
|
|
||||||
new HibernateTxManager(sf).close();
|
|
||||||
|
|
||||||
assertTrue(sf.isClosed());
|
|
||||||
assertTrue(pool.closed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-258
@@ -1,258 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-108
@@ -1,108 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.hibernate;
|
|
||||||
|
|
||||||
import dev.relism.flash.ext.data.core.*;
|
|
||||||
import org.hibernate.Session;
|
|
||||||
import org.hibernate.SessionFactory;
|
|
||||||
import org.junit.jupiter.api.AfterAll;
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
|
||||||
import org.junit.jupiter.api.BeforeAll;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
|
|
||||||
class HibernateTxManagerTest {
|
|
||||||
static SessionFactory sf;
|
|
||||||
static HibernateTxManager manager;
|
|
||||||
|
|
||||||
@BeforeAll
|
|
||||||
static void setup() {
|
|
||||||
sf = TestHelper.buildSessionFactory();
|
|
||||||
manager = new HibernateTxManager(sf);
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterAll
|
|
||||||
static void teardown() {
|
|
||||||
if (sf != null) {
|
|
||||||
sf.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
void cleanup() {
|
|
||||||
ResourceRegistry.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void required_starts_new_when_absent() {
|
|
||||||
TxStatus s = manager.begin(TxDefinition.DEFAULTS);
|
|
||||||
assertNotNull(s.resource(Session.class));
|
|
||||||
assertTrue(s.isNewTransaction());
|
|
||||||
assertDoesNotThrow(() -> manager.commit(s));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void required_joins_existing_when_present() {
|
|
||||||
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
|
||||||
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
|
|
||||||
assertSame(outer.resource(Session.class), inner.resource(Session.class));
|
|
||||||
manager.rollback(outer);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void requires_new_uses_separate_session() {
|
|
||||||
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
|
||||||
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW));
|
|
||||||
assertNotSame(outer.resource(Session.class), inner.resource(Session.class));
|
|
||||||
manager.commit(inner);
|
|
||||||
manager.rollback(outer);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void rollback_on_joined_marks_outer_rollback_only() {
|
|
||||||
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
|
||||||
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
|
|
||||||
manager.rollback(inner);
|
|
||||||
assertTrue(outer.isRollbackOnly());
|
|
||||||
manager.rollback(outer);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** SUPPORTS without an active transaction yields a sessionless status: not a transaction, no session to hand out. */
|
|
||||||
@Test
|
|
||||||
void supports_without_active_transaction_is_a_sessionless_no_op() {
|
|
||||||
TxStatus s = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.SUPPORTS));
|
|
||||||
|
|
||||||
assertFalse(s.isNewTransaction());
|
|
||||||
assertThrows(IllegalStateException.class, () -> s.resource(Session.class));
|
|
||||||
assertDoesNotThrow(() -> manager.commit(s));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void not_supported_suspends_the_active_transaction_and_restores_it_on_commit() {
|
|
||||||
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
|
||||||
Session outerSession = outer.resource(Session.class);
|
|
||||||
|
|
||||||
TxStatus suspended = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.NOT_SUPPORTED));
|
|
||||||
assertFalse(suspended.isNewTransaction());
|
|
||||||
assertThrows(IllegalStateException.class, () -> suspended.resource(Session.class));
|
|
||||||
manager.commit(suspended);
|
|
||||||
|
|
||||||
TxStatus rejoined = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
|
|
||||||
assertSame(outerSession, rejoined.resource(Session.class), "the suspended transaction must be back");
|
|
||||||
manager.rollback(outer);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void mandatory_without_active_transaction_is_rejected() {
|
|
||||||
assertThrows(IllegalStateException.class,
|
|
||||||
() -> manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.MANDATORY)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A read-only join onto a read-write transaction is a contract violation, not a silent downgrade. */
|
|
||||||
@Test
|
|
||||||
void read_only_cannot_join_a_read_write_transaction() {
|
|
||||||
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
|
||||||
assertThrows(TxException.class,
|
|
||||||
() -> manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED).asReadOnly()));
|
|
||||||
manager.rollback(outer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-24
@@ -1,24 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.hibernate;
|
|
||||||
|
|
||||||
import org.hibernate.SessionFactory;
|
|
||||||
import org.hibernate.boot.MetadataSources;
|
|
||||||
import org.hibernate.boot.registry.StandardServiceRegistry;
|
|
||||||
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
|
|
||||||
|
|
||||||
public class TestHelper {
|
|
||||||
public static SessionFactory buildSessionFactory() {
|
|
||||||
final StandardServiceRegistry registry = new StandardServiceRegistryBuilder()
|
|
||||||
.applySetting("hibernate.connection.url", "jdbc:h2:mem:tx-hibernate;DB_CLOSE_DELAY=-1")
|
|
||||||
.applySetting("hibernate.connection.driver_class", "org.h2.Driver")
|
|
||||||
.applySetting("hibernate.dialect", "org.hibernate.dialect.H2Dialect")
|
|
||||||
.applySetting("hibernate.hbm2ddl.auto", "none")
|
|
||||||
.applySetting("hibernate.show_sql", "false")
|
|
||||||
.build();
|
|
||||||
try {
|
|
||||||
return new MetadataSources(registry).buildMetadata().buildSessionFactory();
|
|
||||||
} catch (Exception e) {
|
|
||||||
StandardServiceRegistryBuilder.destroy(registry);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
# flash-ext-data-jdbc
|
|
||||||
|
|
||||||
JDBC backend for `flash-ext-data-core`.
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
This module implements `TxManager` on top of a `DataSource` and provides a raw-SQL repository base
|
|
||||||
class.
|
|
||||||
|
|
||||||
## How to use it
|
|
||||||
|
|
||||||
### 1. Create the manager
|
|
||||||
|
|
||||||
```java
|
|
||||||
DataSource dataSource = ...;
|
|
||||||
JdbcTxManager txManager = new JdbcTxManager(dataSource);
|
|
||||||
DataExtension extension = new DataExtension(txManager);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Install the extension in Flash
|
|
||||||
|
|
||||||
As with Hibernate, `DataExtension` registers `Tx` in the `FlashContext` and enables
|
|
||||||
`@Transactional` on class-based handlers.
|
|
||||||
|
|
||||||
### 3. Define a repository
|
|
||||||
|
|
||||||
```java
|
|
||||||
public final class UserRepository extends JdbcRepository<User, Long> {
|
|
||||||
public UserRepository(Tx tx) {
|
|
||||||
super(tx, "users", "id");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected User mapRow(ResultSet rs) throws SQLException {
|
|
||||||
return new User(rs.getLong("id"), rs.getString("name"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Here too you can expose reusable `Spec`s and compose queries from the service layer:
|
|
||||||
|
|
||||||
```java
|
|
||||||
public final class UserRepository extends JdbcRepository<User, Long> {
|
|
||||||
public static final SpecBuilder.FieldSpec<User, String> EMAIL = SpecBuilder.field("email");
|
|
||||||
|
|
||||||
public UserRepository(Tx tx) {
|
|
||||||
super(tx, "users", "id");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Saving and updating need an explicit binding:
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Override
|
|
||||||
protected String insertSql() {
|
|
||||||
return "insert into users(name) values(?)";
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void bindInsert(PreparedStatement ps, User entity) throws SQLException {
|
|
||||||
ps.setString(1, entity.name());
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## How it works underneath
|
|
||||||
|
|
||||||
- The current transaction exposes a `Connection`.
|
|
||||||
- `Tx.resource(Connection.class)` retrieves the connection bound to the thread.
|
|
||||||
- `REQUIRES_NEW` suspends the active connection and opens a new one.
|
|
||||||
- `NOT_SUPPORTED` suspends the context and continues without a transaction.
|
|
||||||
|
|
||||||
## Repository base class
|
|
||||||
|
|
||||||
`JdbcRepository` provides:
|
|
||||||
|
|
||||||
- `select` queries through `queryOne`, `queryMany`
|
|
||||||
- mutations through `mutate`
|
|
||||||
- persistence through `doSave`, `doUpdate`
|
|
||||||
- paging through `doFindPage`
|
|
||||||
- bulk `deleteAll(Spec<T>)`
|
|
||||||
- raw helpers `queryOne(...)`, `queryMany(...)`, `mutate(...)`
|
|
||||||
|
|
||||||
Concrete repositories only have to translate between `ResultSet` and the domain.
|
|
||||||
|
|
||||||
## Transactional semantics
|
|
||||||
|
|
||||||
- `REQUIRED`: join, or open a new transaction.
|
|
||||||
- `REQUIRES_NEW`: suspend the current context.
|
|
||||||
- `SUPPORTS`: join if a transaction exists, otherwise no-op.
|
|
||||||
- `NOT_SUPPORTED`: suspend and continue without a transaction.
|
|
||||||
- `MANDATORY`: fail if there is no transaction.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- The `Connection` is closed when a new transaction ends.
|
|
||||||
- Synchronizations registered in a transaction fire when *that* transaction completes:
|
|
||||||
`beforeCommit` while it is still active and the `Connection` still bound, the post-completion
|
|
||||||
hooks once it is unbound. See `flash-ext-data-core/docs/README.md` for the full contract.
|
|
||||||
- If a repository uses `doDelete(T)`, the default behaviour is unsupported: use `deleteById` or
|
|
||||||
override it.
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
|
||||||
<modelVersion>4.0.0</modelVersion>
|
|
||||||
|
|
||||||
<parent>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash-extensions</artifactId>
|
|
||||||
<version>2.1.0-SNAPSHOT</version>
|
|
||||||
</parent>
|
|
||||||
|
|
||||||
<artifactId>flash-ext-data-jdbc</artifactId>
|
|
||||||
|
|
||||||
<properties>
|
|
||||||
<jacoco.version>0.8.12</jacoco.version>
|
|
||||||
</properties>
|
|
||||||
|
|
||||||
<dependencies>
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash-ext-data-core</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.zaxxer</groupId>
|
|
||||||
<artifactId>HikariCP</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.projectlombok</groupId>
|
|
||||||
<artifactId>lombok</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.junit.jupiter</groupId>
|
|
||||||
<artifactId>junit-jupiter</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.h2database</groupId>
|
|
||||||
<artifactId>h2</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
|
|
||||||
<build>
|
|
||||||
<plugins>
|
|
||||||
<plugin>
|
|
||||||
<groupId>org.jacoco</groupId>
|
|
||||||
<artifactId>jacoco-maven-plugin</artifactId>
|
|
||||||
<version>${jacoco.version}</version>
|
|
||||||
<configuration>
|
|
||||||
<rules>
|
|
||||||
<rule>
|
|
||||||
<element>BUNDLE</element>
|
|
||||||
<limits>
|
|
||||||
<limit>
|
|
||||||
<counter>LINE</counter>
|
|
||||||
<value>COVEREDRATIO</value>
|
|
||||||
<minimum>0.80</minimum>
|
|
||||||
</limit>
|
|
||||||
</limits>
|
|
||||||
</rule>
|
|
||||||
</rules>
|
|
||||||
</configuration>
|
|
||||||
</plugin>
|
|
||||||
</plugins>
|
|
||||||
</build>
|
|
||||||
</project>
|
|
||||||
-201
@@ -1,201 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.jdbc;
|
|
||||||
|
|
||||||
import dev.relism.flash.ext.data.core.*;
|
|
||||||
|
|
||||||
import java.sql.*;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
public abstract class JdbcRepository<T, ID> extends Repository<T, ID> {
|
|
||||||
|
|
||||||
private final String table;
|
|
||||||
private final String idColumn;
|
|
||||||
|
|
||||||
protected JdbcRepository(Tx tx, String table, String idColumn) {
|
|
||||||
super(tx);
|
|
||||||
this.table = table;
|
|
||||||
this.idColumn = idColumn;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected Connection connection() {
|
|
||||||
return tx().resource(Connection.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected abstract T mapRow(ResultSet rs) throws SQLException;
|
|
||||||
protected abstract void bindInsert(PreparedStatement ps, T entity) throws SQLException;
|
|
||||||
protected abstract void bindUpdate(PreparedStatement ps, T entity) throws SQLException;
|
|
||||||
protected abstract String insertSql();
|
|
||||||
protected abstract String updateSql();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected Optional<T> doFindById(ID id) {
|
|
||||||
return queryOne("select * from " + table + " where " + idColumn + " = ?", ps -> ps.setObject(1, id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected List<T> doFind(Query<T> query) {
|
|
||||||
JdbcSpecContext ctx = new JdbcSpecContext();
|
|
||||||
String where = query.spec() != null ? " where " + query.spec().toFragment(ctx) : "";
|
|
||||||
String order = query.sort() != null && query.sort().isSorted() ? " order by " + orderClause(query.sort()) : "";
|
|
||||||
String paging = query.isPaged() ? " limit ? offset ?" : "";
|
|
||||||
|
|
||||||
return queryMany("select * from " + table + where + order + paging, ps -> {
|
|
||||||
if (query.isPaged()) {
|
|
||||||
ctx.applyParameters(ps);
|
|
||||||
int base = ctx.size();
|
|
||||||
ps.setInt(base + 1, query.size());
|
|
||||||
ps.setInt(base + 2, query.page() * query.size());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ctx.applyParameters(ps);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected Optional<T> doFindOne(Spec<T> spec) {
|
|
||||||
return doFind(Query.<T>all().where(spec).page(0, 1)).stream().findFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected Page<T> doFindPage(Query<T> query) {
|
|
||||||
if (!query.isPaged()) {
|
|
||||||
throw new IllegalArgumentException("Paged query requires page and size");
|
|
||||||
}
|
|
||||||
long total = countWhere(query.spec());
|
|
||||||
List<T> content = doFind(query);
|
|
||||||
return new Page<>(content, query.page(), query.size(), total);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected boolean doExistsById(ID id) {
|
|
||||||
return queryOne("select 1 from " + table + " where " + idColumn + " = ?", ps -> ps.setObject(1, id), rs -> rs.getInt(1)).isPresent();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected long doCount() {
|
|
||||||
return queryOne("select count(*) from " + table, ps -> {}, rs -> rs.getLong(1)).orElse(0L);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected T doSave(T entity) {
|
|
||||||
try (PreparedStatement ps = connection().prepareStatement(insertSql(), Statement.RETURN_GENERATED_KEYS)) {
|
|
||||||
bindInsert(ps, entity);
|
|
||||||
ps.executeUpdate();
|
|
||||||
applyGeneratedKey(ps, entity);
|
|
||||||
return entity;
|
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new TxException(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected List<T> doSaveAll(Iterable<T> entities) {
|
|
||||||
List<T> saved = new ArrayList<>();
|
|
||||||
for (T entity : entities) {
|
|
||||||
saved.add(doSave(entity));
|
|
||||||
}
|
|
||||||
return saved;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected T doUpdate(T entity) {
|
|
||||||
try (PreparedStatement ps = connection().prepareStatement(updateSql())) {
|
|
||||||
bindUpdate(ps, entity);
|
|
||||||
ps.executeUpdate();
|
|
||||||
return entity;
|
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new TxException(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void doDelete(T entity) {
|
|
||||||
throw new UnsupportedOperationException("Override doDelete() or use deleteById()");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void doDeleteById(ID id) {
|
|
||||||
mutate("delete from " + table + " where " + idColumn + " = ?", ps -> ps.setObject(1, id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected int doDeleteAll(Spec<T> spec) {
|
|
||||||
JdbcSpecContext ctx = new JdbcSpecContext();
|
|
||||||
String where = " where " + spec.toFragment(ctx);
|
|
||||||
return mutate("delete from " + table + where, ctx::applyParameters);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected int doUpdateAll(Spec<T> spec, T patch) {
|
|
||||||
throw new UnsupportedOperationException("Override doUpdateAll() for bulk UPDATE support");
|
|
||||||
}
|
|
||||||
|
|
||||||
protected Optional<T> queryOne(String sql, SqlBinder params) {
|
|
||||||
List<T> r = queryMany(sql, params);
|
|
||||||
return r.isEmpty() ? Optional.empty() : Optional.of(r.get(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected <R> Optional<R> queryOne(String sql, SqlBinder params, SqlMapper<R> mapper) {
|
|
||||||
try (PreparedStatement ps = connection().prepareStatement(sql)) {
|
|
||||||
params.bind(ps);
|
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
|
||||||
return rs.next() ? Optional.of(mapper.map(rs)) : Optional.empty();
|
|
||||||
}
|
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new TxException(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected List<T> queryMany(String sql, SqlBinder params) {
|
|
||||||
try (PreparedStatement ps = connection().prepareStatement(sql)) {
|
|
||||||
params.bind(ps);
|
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
|
||||||
List<T> results = new ArrayList<>();
|
|
||||||
while (rs.next()) results.add(mapRow(rs));
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new TxException(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected int mutate(String sql, SqlBinder params) {
|
|
||||||
try (PreparedStatement ps = connection().prepareStatement(sql)) {
|
|
||||||
params.bind(ps);
|
|
||||||
return ps.executeUpdate();
|
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new TxException(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected void applyGeneratedKey(PreparedStatement ps, T entity) throws SQLException {
|
|
||||||
// override when entity has a generated PK
|
|
||||||
}
|
|
||||||
|
|
||||||
protected Class<T> entityType() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private long countWhere(Spec<T> spec) {
|
|
||||||
JdbcSpecContext ctx = new JdbcSpecContext();
|
|
||||||
String where = spec != null ? " where " + spec.toFragment(ctx) : "";
|
|
||||||
return queryOne("select count(*) from " + table + where, ctx::applyParameters, rs -> rs.getLong(1)).orElse(0L);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String orderClause(Sort sort) {
|
|
||||||
return sort.columns().stream()
|
|
||||||
.map(c -> c.column() + (c.asc() ? " ASC" : " DESC"))
|
|
||||||
.collect(java.util.stream.Collectors.joining(", "));
|
|
||||||
}
|
|
||||||
|
|
||||||
@FunctionalInterface
|
|
||||||
public interface SqlBinder {
|
|
||||||
void bind(PreparedStatement ps) throws SQLException;
|
|
||||||
}
|
|
||||||
|
|
||||||
@FunctionalInterface
|
|
||||||
public interface SqlMapper<R> {
|
|
||||||
R map(ResultSet rs) throws SQLException;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-28
@@ -1,28 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-235
@@ -1,235 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.jdbc;
|
|
||||||
|
|
||||||
import dev.relism.flash.ext.data.core.*;
|
|
||||||
|
|
||||||
import javax.sql.DataSource;
|
|
||||||
import java.sql.Connection;
|
|
||||||
import java.sql.SQLException;
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
public class JdbcTxManager implements TxManager {
|
|
||||||
private static final TxResourceKey JDBC_STATUS_KEY = TxResourceKey.of("jdbc.tx.status");
|
|
||||||
private static final TxResourceKey JDBC_SUSPENDED_KEY = TxResourceKey.of("jdbc.tx.suspended");
|
|
||||||
|
|
||||||
private final DataSource ds;
|
|
||||||
|
|
||||||
public JdbcTxManager(DataSource ds) {
|
|
||||||
this.ds = Objects.requireNonNull(ds);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Closes the data source when it is closeable, as a pool is; a plain {@code DataSource} holds nothing to release. */
|
|
||||||
@Override
|
|
||||||
public void close() {
|
|
||||||
if (ds instanceof AutoCloseable closeable) {
|
|
||||||
try {
|
|
||||||
closeable.close();
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new IllegalStateException("Failed to close the data source", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public TxStatus begin(TxDefinition definition) {
|
|
||||||
return switch (definition.propagation()) {
|
|
||||||
case REQUIRED -> ResourceRegistry.isBound(JDBC_STATUS_KEY)
|
|
||||||
? joinExisting(definition)
|
|
||||||
: beginNew(definition);
|
|
||||||
case REQUIRES_NEW -> beginNew(definition);
|
|
||||||
case SUPPORTS -> ResourceRegistry.isBound(JDBC_STATUS_KEY)
|
|
||||||
? joinExisting(definition)
|
|
||||||
: noOp(definition);
|
|
||||||
case MANDATORY -> {
|
|
||||||
if (!ResourceRegistry.isBound(JDBC_STATUS_KEY))
|
|
||||||
throw new IllegalStateException("MANDATORY: no active transaction");
|
|
||||||
yield joinExisting(definition);
|
|
||||||
}
|
|
||||||
case NOT_SUPPORTED -> {
|
|
||||||
JdbcTxStatus suspended = suspendIfNeeded();
|
|
||||||
yield noOp(definition, suspended);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private TxStatus beginNew(TxDefinition definition) {
|
|
||||||
Connection conn = null;
|
|
||||||
// Anything already registered belongs to an enclosing transaction this one is nested
|
|
||||||
// inside (or suspended over) — see ResourceRegistry#fireSynchronizations.
|
|
||||||
int synchronizationBaseline = ResourceRegistry.synchronizationCount();
|
|
||||||
JdbcTxStatus suspended = suspendIfNeeded();
|
|
||||||
boolean bound = false;
|
|
||||||
try {
|
|
||||||
conn = ds.getConnection();
|
|
||||||
conn.setAutoCommit(false);
|
|
||||||
if (definition.readOnly()) conn.setReadOnly(true);
|
|
||||||
if (definition.isolation() != TransactionIsolation.DEFAULT) {
|
|
||||||
conn.setTransactionIsolation(definition.isolation().level());
|
|
||||||
}
|
|
||||||
JdbcTxStatus status = new JdbcTxStatus(
|
|
||||||
conn,
|
|
||||||
true,
|
|
||||||
definition.readOnly(),
|
|
||||||
suspended,
|
|
||||||
new JdbcTxStatus.RollbackMarker(),
|
|
||||||
synchronizationBaseline
|
|
||||||
);
|
|
||||||
ResourceRegistry.bind(JDBC_STATUS_KEY, status);
|
|
||||||
bound = true;
|
|
||||||
return status;
|
|
||||||
} catch (SQLException e) {
|
|
||||||
silentClose(conn);
|
|
||||||
throw new TxException(e);
|
|
||||||
} finally {
|
|
||||||
if (!bound && suspended != null) {
|
|
||||||
ResourceRegistry.unbind(JDBC_SUSPENDED_KEY);
|
|
||||||
ResourceRegistry.bind(JDBC_STATUS_KEY, suspended);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private TxStatus joinExisting(TxDefinition definition) {
|
|
||||||
JdbcTxStatus existing = ResourceRegistry.get(JDBC_STATUS_KEY, JdbcTxStatus.class);
|
|
||||||
if (definition.readOnly() && !existing.isReadOnly()) {
|
|
||||||
throw new TxException("Cannot join read-write tx as read-only");
|
|
||||||
}
|
|
||||||
// Baseline 0 is never read: a joined status isn't a new transaction, so commit()/rollback()
|
|
||||||
// hand it straight back to the transaction it joined without firing anything.
|
|
||||||
return new JdbcTxStatus(
|
|
||||||
existing.connection(),
|
|
||||||
false,
|
|
||||||
definition.readOnly(),
|
|
||||||
null,
|
|
||||||
existing.rollbackMarker(),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private TxStatus noOp(TxDefinition definition) {
|
|
||||||
return noOp(definition, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private TxStatus noOp(TxDefinition definition, JdbcTxStatus suspended) {
|
|
||||||
return new JdbcTxStatus(null, false, definition.readOnly(), suspended, new JdbcTxStatus.RollbackMarker(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private JdbcTxStatus suspendIfNeeded() {
|
|
||||||
JdbcTxStatus suspended = ResourceRegistry.getOrNull(JDBC_STATUS_KEY, JdbcTxStatus.class);
|
|
||||||
if (suspended != null) {
|
|
||||||
ResourceRegistry.unbind(JDBC_STATUS_KEY);
|
|
||||||
ResourceRegistry.bind(JDBC_SUSPENDED_KEY, suspended);
|
|
||||||
}
|
|
||||||
return suspended;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void commit(TxStatus status) {
|
|
||||||
JdbcTxStatus s = (JdbcTxStatus) status;
|
|
||||||
if (!s.isNewTransaction()) {
|
|
||||||
resumeIfNeeded(s);
|
|
||||||
cleanupIfIdle();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
TxOutcome outcome = null;
|
|
||||||
try {
|
|
||||||
if (s.isRollbackOnly()) {
|
|
||||||
s.connection().rollback();
|
|
||||||
outcome = TxOutcome.ROLLED_BACK;
|
|
||||||
} else {
|
|
||||||
// Still inside the transaction, connection still bound: a beforeCommit callback
|
|
||||||
// can write through it and land in this same commit. Throwing from there vetoes
|
|
||||||
// the commit — see TxSynchronization.
|
|
||||||
ResourceRegistry.fireBeforeCommit(s.isReadOnly(), s.synchronizationBaseline());
|
|
||||||
s.connection().commit();
|
|
||||||
outcome = TxOutcome.COMMITTED;
|
|
||||||
}
|
|
||||||
} catch (SQLException e) {
|
|
||||||
TxException wrapped = new TxException(e);
|
|
||||||
outcome = rollbackAfterFailedCommit(s, wrapped);
|
|
||||||
throw wrapped;
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
outcome = rollbackAfterFailedCommit(s, e);
|
|
||||||
throw e;
|
|
||||||
} finally {
|
|
||||||
// Unbind the connection before firing, so a callback that opens its own transaction
|
|
||||||
// gets a fresh one instead of joining the connection that just committed — and fire
|
|
||||||
// before cleanupIfIdle(), whose ResourceRegistry.cleanup() drops the pending list.
|
|
||||||
cleanupAndResume(s);
|
|
||||||
if (outcome != null) ResourceRegistry.fireSynchronizations(outcome, s.synchronizationBaseline());
|
|
||||||
cleanupIfIdle();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Nothing was committed — a vetoing {@code beforeCommit}, or a commit that failed outright —
|
|
||||||
* so undo whatever the transaction had done and report {@code ROLLED_BACK} to the callbacks
|
|
||||||
* still queued behind it. A failure to roll back is attached to the exception already on its
|
|
||||||
* way out rather than replacing it: that one explains what actually went wrong.
|
|
||||||
*/
|
|
||||||
private static TxOutcome rollbackAfterFailedCommit(JdbcTxStatus s, Throwable propagating) {
|
|
||||||
try {
|
|
||||||
s.connection().rollback();
|
|
||||||
} catch (SQLException suppressed) {
|
|
||||||
propagating.addSuppressed(suppressed);
|
|
||||||
}
|
|
||||||
return TxOutcome.ROLLED_BACK;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void rollback(TxStatus status) {
|
|
||||||
JdbcTxStatus s = (JdbcTxStatus) status;
|
|
||||||
if (!s.isNewTransaction()) {
|
|
||||||
s.markRollbackOnly();
|
|
||||||
resumeIfNeeded(s);
|
|
||||||
cleanupIfIdle();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
s.connection().rollback();
|
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new TxException(e);
|
|
||||||
} finally {
|
|
||||||
// Same order as commit() above, for the same two reasons.
|
|
||||||
cleanupAndResume(s);
|
|
||||||
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK, s.synchronizationBaseline());
|
|
||||||
cleanupIfIdle();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void cleanupAndResume(JdbcTxStatus status) {
|
|
||||||
ResourceRegistry.unbind(JDBC_STATUS_KEY);
|
|
||||||
try {
|
|
||||||
if (status.connection() != null) {
|
|
||||||
status.connection().close();
|
|
||||||
}
|
|
||||||
} catch (SQLException ignored) {
|
|
||||||
}
|
|
||||||
resumeIfNeeded(status);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void cleanupIfIdle() {
|
|
||||||
if (!ResourceRegistry.isBound(JDBC_STATUS_KEY) && !ResourceRegistry.isBound(JDBC_SUSPENDED_KEY)) {
|
|
||||||
ResourceRegistry.cleanup();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void resumeIfNeeded(JdbcTxStatus status) {
|
|
||||||
JdbcTxStatus suspended = status.suspended();
|
|
||||||
if (suspended == null) {
|
|
||||||
suspended = ResourceRegistry.getOrNull(JDBC_SUSPENDED_KEY, JdbcTxStatus.class);
|
|
||||||
}
|
|
||||||
if (suspended != null) {
|
|
||||||
ResourceRegistry.unbind(JDBC_SUSPENDED_KEY);
|
|
||||||
ResourceRegistry.bind(JDBC_STATUS_KEY, suspended);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void silentClose(Connection connection) {
|
|
||||||
if (connection == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
connection.close();
|
|
||||||
} catch (SQLException ignored) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-59
@@ -1,59 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.jdbc;
|
|
||||||
|
|
||||||
import dev.relism.flash.ext.data.core.TxStatus;
|
|
||||||
|
|
||||||
import java.sql.Connection;
|
|
||||||
|
|
||||||
class JdbcTxStatus implements TxStatus {
|
|
||||||
static final class RollbackMarker {
|
|
||||||
boolean rollbackOnly;
|
|
||||||
}
|
|
||||||
|
|
||||||
private final Connection connection;
|
|
||||||
private final boolean newTransaction;
|
|
||||||
private final boolean readOnly;
|
|
||||||
private final JdbcTxStatus suspended;
|
|
||||||
private final RollbackMarker rollbackMarker;
|
|
||||||
private final int synchronizationBaseline;
|
|
||||||
|
|
||||||
// No requireNonNull on the connection: a SUPPORTS-without-a-transaction or a NOT_SUPPORTED
|
|
||||||
// status is deliberately connectionless (see JdbcTxManager#noOp), and rejecting null here
|
|
||||||
// turned both of those propagations into an NPE at begin() — the Hibernate manager has
|
|
||||||
// always allowed it. resource() reports the real mistake, asking a connectionless status for
|
|
||||||
// its connection, where it can name it.
|
|
||||||
JdbcTxStatus(
|
|
||||||
Connection connection,
|
|
||||||
boolean newTransaction,
|
|
||||||
boolean readOnly,
|
|
||||||
JdbcTxStatus suspended,
|
|
||||||
RollbackMarker rollbackMarker,
|
|
||||||
int synchronizationBaseline
|
|
||||||
) {
|
|
||||||
this.connection = connection;
|
|
||||||
this.newTransaction = newTransaction;
|
|
||||||
this.readOnly = readOnly;
|
|
||||||
this.suspended = suspended;
|
|
||||||
this.rollbackMarker = rollbackMarker;
|
|
||||||
this.synchronizationBaseline = synchronizationBaseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public boolean isNewTransaction() { return newTransaction; }
|
|
||||||
@Override public boolean isReadOnly() { return readOnly; }
|
|
||||||
@Override public boolean isRollbackOnly() { return rollbackMarker.rollbackOnly; }
|
|
||||||
@Override public void markRollbackOnly() { rollbackMarker.rollbackOnly = true; }
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public <R> R resource(Class<R> type) {
|
|
||||||
if (connection == null) {
|
|
||||||
throw new IllegalStateException("No connection bound to this transaction status");
|
|
||||||
}
|
|
||||||
return type.cast(connection);
|
|
||||||
}
|
|
||||||
|
|
||||||
Connection connection() { return connection; }
|
|
||||||
JdbcTxStatus suspended() { return suspended; }
|
|
||||||
RollbackMarker rollbackMarker() { return rollbackMarker; }
|
|
||||||
|
|
||||||
/** Index into {@code ResourceRegistry}'s synchronization list where this transaction's own callbacks start. */
|
|
||||||
int synchronizationBaseline() { return synchronizationBaseline; }
|
|
||||||
}
|
|
||||||
-20
@@ -1,20 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.jdbc;
|
|
||||||
|
|
||||||
import com.zaxxer.hikari.HikariDataSource;
|
|
||||||
import dev.relism.flash.ext.data.DataExtension;
|
|
||||||
import dev.relism.flash.extension.FlashApp;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
class JdbcTxManagerCloseTest {
|
|
||||||
|
|
||||||
/** A pool outlives its app unless someone closes it; the data extension does, once requests have drained. */
|
|
||||||
@Test
|
|
||||||
void stoppingTheAppClosesThePool() {
|
|
||||||
HikariDataSource pool = new HikariDataSource();
|
|
||||||
pool.setJdbcUrl(TestDataSource.URL);
|
|
||||||
FlashApp.create(0).install(new DataExtension(new JdbcTxManager(pool))).start().stop().join();
|
|
||||||
assertTrue(pool.isClosed());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-216
@@ -1,216 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-87
@@ -1,87 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.jdbc;
|
|
||||||
|
|
||||||
import dev.relism.flash.ext.data.core.*;
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.sql.Connection;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
|
|
||||||
class JdbcTxManagerTest {
|
|
||||||
private final JdbcTxManager manager = new JdbcTxManager(new TestDataSource());
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
void cleanup() {
|
|
||||||
ResourceRegistry.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void required_starts_new_when_absent() {
|
|
||||||
TxStatus s = manager.begin(TxDefinition.DEFAULTS);
|
|
||||||
assertTrue(s.isNewTransaction());
|
|
||||||
assertDoesNotThrow(() -> manager.commit(s));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void required_joins_existing_when_present() {
|
|
||||||
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
|
||||||
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
|
|
||||||
assertSame(outer.resource(Connection.class), inner.resource(Connection.class));
|
|
||||||
assertFalse(inner.isNewTransaction());
|
|
||||||
manager.rollback(outer);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void requires_new_creates_distinct_connection() {
|
|
||||||
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
|
||||||
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW));
|
|
||||||
assertNotSame(outer.resource(Connection.class), inner.resource(Connection.class));
|
|
||||||
manager.commit(inner);
|
|
||||||
manager.rollback(outer);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void rollback_on_joined_marks_outer_rollback_only() {
|
|
||||||
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
|
||||||
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
|
|
||||||
manager.rollback(inner);
|
|
||||||
assertTrue(outer.isRollbackOnly());
|
|
||||||
manager.rollback(outer);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SUPPORTS without an active transaction yields a connectionless status — it must not be a
|
|
||||||
* transaction, and asking it for a connection must say so rather than NPE. Both propagations
|
|
||||||
* that produce one used to throw {@link NullPointerException} straight out of {@code begin()}.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void supports_without_active_transaction_is_a_connectionless_no_op() {
|
|
||||||
TxStatus s = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.SUPPORTS));
|
|
||||||
|
|
||||||
assertFalse(s.isNewTransaction());
|
|
||||||
assertThrows(IllegalStateException.class, () -> s.resource(Connection.class));
|
|
||||||
assertDoesNotThrow(() -> manager.commit(s));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void not_supported_suspends_the_active_transaction_and_restores_it_on_commit() {
|
|
||||||
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
|
||||||
Connection outerConnection = outer.resource(Connection.class);
|
|
||||||
|
|
||||||
TxStatus suspended = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.NOT_SUPPORTED));
|
|
||||||
assertFalse(suspended.isNewTransaction());
|
|
||||||
assertThrows(IllegalStateException.class, () -> suspended.resource(Connection.class));
|
|
||||||
manager.commit(suspended);
|
|
||||||
|
|
||||||
TxStatus rejoined = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
|
|
||||||
assertSame(outerConnection, rejoined.resource(Connection.class), "the suspended transaction must be back");
|
|
||||||
manager.rollback(outer);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void mandatory_without_active_transaction_is_rejected() {
|
|
||||||
assertThrows(IllegalStateException.class,
|
|
||||||
() -> manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.MANDATORY)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-37
@@ -1,37 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.jdbc;
|
|
||||||
|
|
||||||
import javax.sql.DataSource;
|
|
||||||
import java.io.PrintWriter;
|
|
||||||
import java.sql.Connection;
|
|
||||||
import java.sql.DriverManager;
|
|
||||||
import java.sql.SQLException;
|
|
||||||
import java.util.logging.Logger;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A bare in-memory H2 {@link DataSource} — one fresh connection per {@code getConnection()}, which
|
|
||||||
* is all {@code JdbcTxManager} needs to exercise real commit/rollback and suspension. Every method
|
|
||||||
* outside the two {@code getConnection} overloads throws: nothing under test calls them, and a
|
|
||||||
* loud failure beats a silent stub if that ever changes.
|
|
||||||
*/
|
|
||||||
final class TestDataSource implements DataSource {
|
|
||||||
|
|
||||||
static final String URL = "jdbc:h2:mem:tx-jdbc;DB_CLOSE_DELAY=-1";
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Connection getConnection() throws SQLException {
|
|
||||||
return DriverManager.getConnection(URL);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Connection getConnection(String username, String password) throws SQLException {
|
|
||||||
return DriverManager.getConnection(URL, username, password);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public <T> T unwrap(Class<T> iface) { throw new UnsupportedOperationException(); }
|
|
||||||
@Override public boolean isWrapperFor(Class<?> iface) { return false; }
|
|
||||||
@Override public PrintWriter getLogWriter() { throw new UnsupportedOperationException(); }
|
|
||||||
@Override public void setLogWriter(PrintWriter out) { throw new UnsupportedOperationException(); }
|
|
||||||
@Override public void setLoginTimeout(int seconds) { throw new UnsupportedOperationException(); }
|
|
||||||
@Override public int getLoginTimeout() { return 0; }
|
|
||||||
@Override public Logger getParentLogger() { throw new UnsupportedOperationException(); }
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# flash-ext-jackson-core
|
|
||||||
|
|
||||||
What every Jackson data format shares. Applications do not install this module directly: they
|
|
||||||
install a format — [`flash-ext-jackson-json`](../flash-ext-jackson-json),
|
|
||||||
[`flash-ext-jackson-xml`](../flash-ext-jackson-xml) — and get all of this with it.
|
|
||||||
|
|
||||||
## Why there is a core at all
|
|
||||||
|
|
||||||
Every Jackson data format is the same databind model behind a different factory: `XmlMapper`,
|
|
||||||
`YAMLMapper` and `CBORMapper` are all `ObjectMapper`s. So the annotations on a type, the
|
|
||||||
constraints its fields declare and the schema it publishes are the same whatever writes it. Only
|
|
||||||
the mapper and the content type differ, and that is all a format module has to say.
|
|
||||||
|
|
||||||
## Codec
|
|
||||||
|
|
||||||
One mapper, in the shape a handler needs it.
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| `body(Request, Class<T>)` | Parses the body and verifies its constraints |
|
|
||||||
| `write(Response, Object)` | Serializes and sets the format's content type |
|
|
||||||
| `writeView(Response, Object, Class<?>)` | The same, through a Jackson `@JsonView` |
|
|
||||||
| `mapper()` | The `ObjectMapper` itself, for everything else |
|
|
||||||
|
|
||||||
`body` reads straight off the request's stream, which Flash reuses per connection: the body is
|
|
||||||
never buffered into an array to be handed over. A malformed body is a 400, a body that breaks a
|
|
||||||
constraint is a 422, and neither reaches the handler.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
|
|
||||||
A body is checked against the `jakarta.validation` annotations its own type declares — nothing to
|
|
||||||
install, nothing to call:
|
|
||||||
|
|
||||||
```java
|
|
||||||
public record NewUser(@NotBlank @Size(max = 80) String name, @Email String email, @Min(18) int age) {}
|
|
||||||
```
|
|
||||||
|
|
||||||
Supported: `@NotNull`, `@NotBlank`, `@NotEmpty`, `@Size`, `@Min`, `@Max`, `@Email`, `@Pattern`.
|
|
||||||
Jakarta semantics: only `@NotNull` rejects null, every other constraint passes it.
|
|
||||||
|
|
||||||
The constraints of a type are compiled the first time it is seen and kept in a `ClassValue`,
|
|
||||||
beside the class itself — no map, no lock. A check reads the field through an exact-signature
|
|
||||||
`MethodHandle`: no boxing, no argument array, no iterator, and nothing allocated at all unless
|
|
||||||
something fails. A type that declares no constraints compiles to a validator that does nothing.
|
|
||||||
|
|
||||||
Verify a value built by hand with `Validator.check(value)`.
|
|
||||||
|
|
||||||
`flash-ext-openapi` reads the same annotations to publish `minLength`, `maximum`, `pattern` and
|
|
||||||
the required fields, so a rule is written once and both enforced and documented.
|
|
||||||
|
|
||||||
## Writing a format module
|
|
||||||
|
|
||||||
```java
|
|
||||||
public final class Yaml extends Codec {
|
|
||||||
public Yaml(YAMLMapper mapper) { super(mapper, ContentType.TEXT_YAML); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Consumes(ContentType.TEXT_YAML)
|
|
||||||
public abstract class YamlHandler<B> extends JacksonHandler<B> {
|
|
||||||
@Inject private Yaml yaml;
|
|
||||||
@Override protected Codec codec() { return yaml; }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Plus an extension that provides the codec and, for outbound bodies,
|
|
||||||
`Marshalling.of(mapper, contentType)`. That is the whole of it.
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
|
||||||
<modelVersion>4.0.0</modelVersion>
|
|
||||||
|
|
||||||
<parent>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash-extensions</artifactId>
|
|
||||||
<version>2.1.0-SNAPSHOT</version>
|
|
||||||
</parent>
|
|
||||||
|
|
||||||
<artifactId>flash-ext-jackson-core</artifactId>
|
|
||||||
<name>flash-ext-jackson-core</name>
|
|
||||||
<description>What every Jackson data format shares: the codec, the body handler and the constraints a body is checked against.</description>
|
|
||||||
|
|
||||||
<dependencies>
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.fasterxml.jackson.core</groupId>
|
|
||||||
<artifactId>jackson-databind</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
|
||||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<!-- The constraint annotations a body is checked against; no implementation, no transitives. -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>jakarta.validation</groupId>
|
|
||||||
<artifactId>jakarta.validation-api</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.projectlombok</groupId>
|
|
||||||
<artifactId>lombok</artifactId>
|
|
||||||
<scope>provided</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.junit.jupiter</groupId>
|
|
||||||
<artifactId>junit-jupiter</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
</project>
|
|
||||||
-76
@@ -1,76 +0,0 @@
|
|||||||
package dev.relism.flash.ext.jackson;
|
|
||||||
|
|
||||||
import java.lang.invoke.MethodHandle;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One constraint, compiled. Flattened into an opcode plus its operands rather than a class per
|
|
||||||
* constraint type: the check loop becomes a {@code tableswitch} over a monomorphic array instead
|
|
||||||
* of a megamorphic virtual call, and a passing check touches no allocation at all.
|
|
||||||
*
|
|
||||||
* <p>Field access goes through a {@link MethodHandle} adapted at compile time to an exact
|
|
||||||
* signature — {@code (Object)Object} for reference fields, {@code (Object)long} for primitive
|
|
||||||
* integrals — so {@code invokeExact} neither boxes nor allocates an argument array the way
|
|
||||||
* {@code Field.get} and {@code Method.invoke} do.
|
|
||||||
*/
|
|
||||||
final class Check {
|
|
||||||
|
|
||||||
static final int NOT_NULL = 0;
|
|
||||||
static final int NOT_BLANK = 1;
|
|
||||||
static final int NOT_EMPTY = 2;
|
|
||||||
static final int SIZE = 3;
|
|
||||||
static final int RANGE_PRIMITIVE = 4;
|
|
||||||
static final int RANGE_BOXED = 5;
|
|
||||||
static final int EMAIL = 6;
|
|
||||||
static final int PATTERN = 7;
|
|
||||||
|
|
||||||
final int op;
|
|
||||||
final String field;
|
|
||||||
/** Pre-rendered at compile time, so even the failure path formats nothing. */
|
|
||||||
final String message;
|
|
||||||
|
|
||||||
/** {@code (Object)Object} — set for every op except {@link #RANGE_PRIMITIVE}. */
|
|
||||||
final MethodHandle ref;
|
|
||||||
/** {@code (Object)long} — set only for {@link #RANGE_PRIMITIVE}. */
|
|
||||||
final MethodHandle num;
|
|
||||||
|
|
||||||
final int min;
|
|
||||||
final int max;
|
|
||||||
final long lo;
|
|
||||||
final long hi;
|
|
||||||
final Pattern pattern;
|
|
||||||
|
|
||||||
private Check(int op, String field, String message, MethodHandle ref, MethodHandle num,
|
|
||||||
int min, int max, long lo, long hi, Pattern pattern) {
|
|
||||||
this.op = op;
|
|
||||||
this.field = field;
|
|
||||||
this.message = message;
|
|
||||||
this.ref = ref;
|
|
||||||
this.num = num;
|
|
||||||
this.min = min;
|
|
||||||
this.max = max;
|
|
||||||
this.lo = lo;
|
|
||||||
this.hi = hi;
|
|
||||||
this.pattern = pattern;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Check reference(int op, String field, String message, MethodHandle ref) {
|
|
||||||
return new Check(op, field, message, ref, null, 0, 0, 0, 0, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Check size(String field, String message, MethodHandle ref, int min, int max) {
|
|
||||||
return new Check(SIZE, field, message, ref, null, min, max, 0, 0, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Check rangePrimitive(String field, String message, MethodHandle num, long lo, long hi) {
|
|
||||||
return new Check(RANGE_PRIMITIVE, field, message, null, num, 0, 0, lo, hi, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Check rangeBoxed(String field, String message, MethodHandle ref, long lo, long hi) {
|
|
||||||
return new Check(RANGE_BOXED, field, message, ref, null, 0, 0, lo, hi, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Check pattern(String field, String message, MethodHandle ref, Pattern pattern) {
|
|
||||||
return new Check(PATTERN, field, message, ref, null, 0, 0, 0, 0, pattern);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-72
@@ -1,72 +0,0 @@
|
|||||||
package dev.relism.flash.ext.jackson;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import dev.relism.flash.exceptions.HttpException;
|
|
||||||
import dev.relism.flash.http.ContentType;
|
|
||||||
import dev.relism.flash.models.Request;
|
|
||||||
import dev.relism.flash.models.Response;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One Jackson mapper, in the shape a handler needs it.
|
|
||||||
*
|
|
||||||
* <p>Every Jackson data format is the same databind model behind a different factory, so this is
|
|
||||||
* the whole of what a format module has to say: which mapper, and which content type it writes.
|
|
||||||
* What the annotations mean, what a body is checked against and how a type is described are the
|
|
||||||
* same for all of them.
|
|
||||||
*
|
|
||||||
* <p>Retrieve it once at boot — {@code @Inject private Json json;} — and call it on the hot path.
|
|
||||||
* The underlying {@link ObjectMapper} is thread-safe once configured.
|
|
||||||
*/
|
|
||||||
public abstract class Codec {
|
|
||||||
|
|
||||||
private final ObjectMapper mapper;
|
|
||||||
private final ContentType contentType;
|
|
||||||
|
|
||||||
protected Codec(ObjectMapper mapper, ContentType contentType) {
|
|
||||||
this.mapper = mapper;
|
|
||||||
this.contentType = contentType;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads the request body as {@code type} and verifies its constraints.
|
|
||||||
*
|
|
||||||
* <p>Read straight off the request's stream, which Flash reuses per connection: nothing
|
|
||||||
* buffers the body to hand it over. A type that declares no constraints is not checked at all.
|
|
||||||
*
|
|
||||||
* @throws HttpException 400 if the body cannot be parsed as {@code type}
|
|
||||||
* @throws ValidationException 422 if it parses but violates a constraint
|
|
||||||
*/
|
|
||||||
public <T> T body(Request request, Class<T> type) throws Exception {
|
|
||||||
T value;
|
|
||||||
try {
|
|
||||||
value = mapper.readValue(request.body().stream(), type);
|
|
||||||
} catch (JsonProcessingException malformed) {
|
|
||||||
throw HttpException.badRequest("Invalid request body: " + malformed.getOriginalMessage());
|
|
||||||
}
|
|
||||||
Validator.of(type).verify(value);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Serializes {@code value} and sets this codec's content type on the response. */
|
|
||||||
public String write(Response response, Object value) throws Exception {
|
|
||||||
response.type(contentType);
|
|
||||||
return mapper.writeValueAsString(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Like {@link #write}, restricted to the fields visible under a Jackson {@code @JsonView}. */
|
|
||||||
public String writeView(Response response, Object value, Class<?> view) throws Exception {
|
|
||||||
response.type(contentType);
|
|
||||||
return mapper.writerWithView(view).writeValueAsString(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What this codec writes, for a handler that sets the response type itself. */
|
|
||||||
public ContentType contentType() {
|
|
||||||
return contentType;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The mapper itself, for everything these methods do not cover. */
|
|
||||||
public ObjectMapper mapper() {
|
|
||||||
return mapper;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-33
@@ -1,33 +0,0 @@
|
|||||||
package dev.relism.flash.ext.jackson;
|
|
||||||
|
|
||||||
import dev.relism.flash.models.BodyHandler;
|
|
||||||
import dev.relism.flash.models.Request;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A handler whose body one Jackson format parses and whose constraints are checked before it
|
|
||||||
* arrives.
|
|
||||||
*
|
|
||||||
* <p>Format modules extend this and name their codec — {@code JsonHandler}, {@code XmlHandler}.
|
|
||||||
* An application extends those, never this one.
|
|
||||||
*
|
|
||||||
* @param <B> the body type, which is also what the published OpenAPI document describes
|
|
||||||
*/
|
|
||||||
public abstract class JacksonHandler<B> extends BodyHandler<B> {
|
|
||||||
|
|
||||||
private final Class<B> type = bodyType();
|
|
||||||
|
|
||||||
protected JacksonHandler() {
|
|
||||||
if (type == null) {
|
|
||||||
throw new IllegalStateException(getClass().getSimpleName()
|
|
||||||
+ " extends a body handler without naming its body type — write it as Handler<YourBody>");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The format this handler speaks. Injected by the subclass, resolved once at boot. */
|
|
||||||
protected abstract Codec codec();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected final B body(Request request) throws Exception {
|
|
||||||
return codec().body(request, type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
package dev.relism.flash.ext.jackson;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import dev.relism.flash.http.ContentType;
|
|
||||||
import dev.relism.flash.models.Response;
|
|
||||||
import dev.relism.flash.routing.Middleware;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Turns whatever a handler returns into a serialized body.
|
|
||||||
*
|
|
||||||
* <p>Pass-through for what is already a response: {@code null}, a {@link Response}, a
|
|
||||||
* {@code byte[]} or a {@link CharSequence}. Everything else is serialized straight to bytes.
|
|
||||||
*/
|
|
||||||
public final class Marshalling {
|
|
||||||
|
|
||||||
private Marshalling() {}
|
|
||||||
|
|
||||||
public static Middleware of(ObjectMapper mapper, ContentType contentType) {
|
|
||||||
return next -> (req, res) -> {
|
|
||||||
Object out = next.handle(req, res);
|
|
||||||
if (out == null || out instanceof Response || out instanceof byte[] || out instanceof CharSequence) return out;
|
|
||||||
|
|
||||||
res.type(contentType);
|
|
||||||
try {
|
|
||||||
return mapper.writeValueAsBytes(out);
|
|
||||||
} catch (JsonProcessingException failure) {
|
|
||||||
throw new IllegalStateException("Could not serialize " + out.getClass().getName(), failure);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-39
@@ -1,39 +0,0 @@
|
|||||||
package dev.relism.flash.ext.jackson;
|
|
||||||
|
|
||||||
import dev.relism.flash.exceptions.HttpException;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Raised when a value fails its constraints. Extends {@link HttpException} with status 422, so
|
|
||||||
* Flash's default exception handler renders it without this extension registering anything.
|
|
||||||
*
|
|
||||||
* <p>Allocated only on failure — a passing validation constructs nothing.
|
|
||||||
*/
|
|
||||||
public final class ValidationException extends HttpException {
|
|
||||||
|
|
||||||
private final transient List<Violation> violations;
|
|
||||||
|
|
||||||
public ValidationException(List<Violation> violations) {
|
|
||||||
super(422, describe(violations));
|
|
||||||
this.violations = List.copyOf(violations);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The individual failures, in field declaration order. */
|
|
||||||
public List<Violation> violations() {
|
|
||||||
return violations;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String describe(List<Violation> violations) {
|
|
||||||
StringBuilder out = new StringBuilder(32 * violations.size());
|
|
||||||
for (int i = 0; i < violations.size(); i++) {
|
|
||||||
if (i > 0) out.append("; ");
|
|
||||||
Violation v = violations.get(i);
|
|
||||||
out.append(v.field()).append(' ').append(v.message());
|
|
||||||
}
|
|
||||||
return out.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One failed constraint. */
|
|
||||||
public record Violation(String field, String message) {}
|
|
||||||
}
|
|
||||||
-242
@@ -1,242 +0,0 @@
|
|||||||
package dev.relism.flash.ext.jackson;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.Email;
|
|
||||||
import jakarta.validation.constraints.Max;
|
|
||||||
import jakarta.validation.constraints.Min;
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
import jakarta.validation.constraints.NotEmpty;
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
|
||||||
import jakarta.validation.constraints.Pattern;
|
|
||||||
import jakarta.validation.constraints.Size;
|
|
||||||
|
|
||||||
import java.lang.invoke.MethodHandle;
|
|
||||||
import java.lang.invoke.MethodHandles;
|
|
||||||
import java.lang.invoke.MethodType;
|
|
||||||
import java.lang.reflect.Field;
|
|
||||||
import java.lang.reflect.Modifier;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collection;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The compiled constraints of one type. Built once per class and reused for every request.
|
|
||||||
*
|
|
||||||
* <p>{@link #verify} allocates nothing when a value passes: the loop walks an array (no iterator),
|
|
||||||
* reads fields through exact-signature {@link MethodHandle}s (no boxing, no argument array), and
|
|
||||||
* compares against operands resolved at compile time. The violation list and the exception are
|
|
||||||
* constructed only once something actually fails.
|
|
||||||
*/
|
|
||||||
public final class Validator {
|
|
||||||
|
|
||||||
private static final Check[] NONE = new Check[0];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One compiled validator per type, kept beside the class itself: no map lookup, no lock, and
|
|
||||||
* the entry is collected with the class rather than pinning it.
|
|
||||||
*/
|
|
||||||
private static final ClassValue<Validator> COMPILED = new ClassValue<>() {
|
|
||||||
@Override protected Validator computeValue(Class<?> type) {
|
|
||||||
return compile(type);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private final Check[] checks;
|
|
||||||
|
|
||||||
private Validator(Check[] checks) {
|
|
||||||
this.checks = checks;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The constraints of {@code type}, compiled the first time it is seen and reused after. */
|
|
||||||
public static Validator of(Class<?> type) {
|
|
||||||
return COMPILED.get(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies a value against its own type's constraints.
|
|
||||||
*
|
|
||||||
* @return {@code value}, so it can be used inline
|
|
||||||
* @throws ValidationException 422, listing every failure
|
|
||||||
*/
|
|
||||||
public static <T> T check(T value) {
|
|
||||||
of(value.getClass()).verify(value);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when the type declares no constraints at all — {@link #verify} is then a no-op. */
|
|
||||||
public boolean isEmpty() {
|
|
||||||
return checks.length == 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies every constraint on {@code target}.
|
|
||||||
*
|
|
||||||
* @throws ValidationException with all failures, never just the first
|
|
||||||
*/
|
|
||||||
public void verify(Object target) {
|
|
||||||
List<ValidationException.Violation> failures = null;
|
|
||||||
for (Check check : checks) {
|
|
||||||
if (passes(check, target)) continue;
|
|
||||||
if (failures == null) failures = new ArrayList<>(4);
|
|
||||||
failures.add(new ValidationException.Violation(check.field, check.message));
|
|
||||||
}
|
|
||||||
if (failures != null) throw new ValidationException(failures);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean passes(Check check, Object target) {
|
|
||||||
try {
|
|
||||||
if (check.op == Check.RANGE_PRIMITIVE) {
|
|
||||||
long value = (long) check.num.invokeExact(target);
|
|
||||||
return value >= check.lo && value <= check.hi;
|
|
||||||
}
|
|
||||||
Object value = (Object) check.ref.invokeExact(target);
|
|
||||||
// Jakarta semantics: only @NotNull rejects null; every other constraint passes it.
|
|
||||||
return switch (check.op) {
|
|
||||||
case Check.NOT_NULL -> value != null;
|
|
||||||
case Check.NOT_BLANK -> value instanceof String text && !text.isBlank();
|
|
||||||
case Check.NOT_EMPTY -> value != null && sizeOf(value) > 0;
|
|
||||||
case Check.SIZE -> value == null || withinSize(check, value);
|
|
||||||
case Check.RANGE_BOXED -> value == null || withinRange(check, (Number) value);
|
|
||||||
case Check.EMAIL -> value == null || (value instanceof String text && isEmail(text));
|
|
||||||
case Check.PATTERN -> value == null
|
|
||||||
|| (value instanceof String text && check.pattern.matcher(text).matches());
|
|
||||||
default -> true;
|
|
||||||
};
|
|
||||||
} catch (Throwable failure) {
|
|
||||||
throw new IllegalStateException("Could not read " + check.field + " for validation", failure);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean withinSize(Check check, Object value) {
|
|
||||||
int size = sizeOf(value);
|
|
||||||
return size >= check.min && size <= check.max;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean withinRange(Check check, Number value) {
|
|
||||||
long asLong = value.longValue();
|
|
||||||
return asLong >= check.lo && asLong <= check.hi;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** No copies: every branch reads a length the object already knows. */
|
|
||||||
private static int sizeOf(Object value) {
|
|
||||||
if (value instanceof CharSequence text) return text.length();
|
|
||||||
if (value instanceof Collection<?> items) return items.size();
|
|
||||||
if (value instanceof Map<?, ?> entries) return entries.size();
|
|
||||||
if (value instanceof Object[] array) return array.length;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Structural check rather than a regex: {@code Pattern.matcher} allocates a matcher, an int
|
|
||||||
* array and a group array on every call, which is exactly the per-request cost this module
|
|
||||||
* exists to avoid. {@code indexOf} allocates nothing.
|
|
||||||
*
|
|
||||||
* <p>Accepts what a mail server would plausibly route and rejects the shapes people actually
|
|
||||||
* typo. Deliverability is the confirmation mail's job, not a validator's.
|
|
||||||
*/
|
|
||||||
private static boolean isEmail(String value) {
|
|
||||||
int at = value.indexOf('@');
|
|
||||||
if (at <= 0 || at == value.length() - 1) return false;
|
|
||||||
if (value.indexOf('@', at + 1) >= 0) return false;
|
|
||||||
int dot = value.indexOf('.', at + 2);
|
|
||||||
return dot > 0 && dot < value.length() - 1 && value.indexOf(' ') < 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Compilation ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compiles {@code type}'s constraints once.
|
|
||||||
*
|
|
||||||
* <p>Reads declared fields rather than record accessors: a constraint on a record component
|
|
||||||
* propagates to the backing field, so records and plain classes need one code path, not two.
|
|
||||||
*/
|
|
||||||
static Validator compile(Class<?> type) {
|
|
||||||
MethodHandles.Lookup lookup;
|
|
||||||
try {
|
|
||||||
lookup = MethodHandles.privateLookupIn(type, MethodHandles.lookup());
|
|
||||||
} catch (IllegalAccessException denied) {
|
|
||||||
throw new IllegalStateException(
|
|
||||||
"Cannot read " + type.getName() + " for validation — open its module or package", denied);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Check> checks = new ArrayList<>();
|
|
||||||
for (Field field : type.getDeclaredFields()) {
|
|
||||||
if (Modifier.isStatic(field.getModifiers())) continue;
|
|
||||||
MethodHandle getter;
|
|
||||||
try {
|
|
||||||
getter = lookup.unreflectGetter(field);
|
|
||||||
} catch (IllegalAccessException denied) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
compileField(field, getter, checks);
|
|
||||||
}
|
|
||||||
return new Validator(checks.isEmpty() ? NONE : checks.toArray(new Check[0]));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void compileField(Field field, MethodHandle getter, List<Check> checks) {
|
|
||||||
String name = field.getName();
|
|
||||||
Class<?> type = field.getType();
|
|
||||||
MethodHandle ref = type.isPrimitive() ? null : asReference(getter);
|
|
||||||
|
|
||||||
if (field.isAnnotationPresent(NotNull.class) && ref != null)
|
|
||||||
checks.add(Check.reference(Check.NOT_NULL, name, "must not be null", ref));
|
|
||||||
|
|
||||||
if (field.isAnnotationPresent(NotBlank.class) && ref != null)
|
|
||||||
checks.add(Check.reference(Check.NOT_BLANK, name, "must not be blank", ref));
|
|
||||||
|
|
||||||
if (field.isAnnotationPresent(NotEmpty.class) && ref != null)
|
|
||||||
checks.add(Check.reference(Check.NOT_EMPTY, name, "must not be empty", ref));
|
|
||||||
|
|
||||||
Size size = field.getAnnotation(Size.class);
|
|
||||||
if (size != null && ref != null)
|
|
||||||
checks.add(Check.size(name, sizeMessage(size), ref, size.min(), size.max()));
|
|
||||||
|
|
||||||
Min min = field.getAnnotation(Min.class);
|
|
||||||
Max max = field.getAnnotation(Max.class);
|
|
||||||
if (min != null || max != null) {
|
|
||||||
long lo = min != null ? min.value() : Long.MIN_VALUE;
|
|
||||||
long hi = max != null ? max.value() : Long.MAX_VALUE;
|
|
||||||
String message = rangeMessage(min, max);
|
|
||||||
if (isIntegralPrimitive(type)) {
|
|
||||||
checks.add(Check.rangePrimitive(name, message, asLong(getter), lo, hi));
|
|
||||||
} else if (Number.class.isAssignableFrom(type) && ref != null) {
|
|
||||||
checks.add(Check.rangeBoxed(name, message, ref, lo, hi));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.isAnnotationPresent(Email.class) && ref != null)
|
|
||||||
checks.add(Check.reference(Check.EMAIL, name, "must be a well-formed email address", ref));
|
|
||||||
|
|
||||||
Pattern pattern = field.getAnnotation(Pattern.class);
|
|
||||||
if (pattern != null && ref != null) {
|
|
||||||
// ponytail: the one allocating check — Pattern.matcher() per call. The regex itself is
|
|
||||||
// compiled once here; swap for a structural check if a hot route ever needs it.
|
|
||||||
checks.add(Check.pattern(name, "must match " + pattern.regexp(), ref,
|
|
||||||
java.util.regex.Pattern.compile(pattern.regexp())));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isIntegralPrimitive(Class<?> type) {
|
|
||||||
return type == int.class || type == long.class || type == short.class || type == byte.class;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static MethodHandle asReference(MethodHandle getter) {
|
|
||||||
return getter.asType(MethodType.methodType(Object.class, Object.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static MethodHandle asLong(MethodHandle getter) {
|
|
||||||
return getter.asType(MethodType.methodType(long.class, Object.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String sizeMessage(Size size) {
|
|
||||||
if (size.min() == 0) return "size must be at most " + size.max();
|
|
||||||
if (size.max() == Integer.MAX_VALUE) return "size must be at least " + size.min();
|
|
||||||
return "size must be between " + size.min() + " and " + size.max();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String rangeMessage(Min min, Max max) {
|
|
||||||
if (min == null) return "must be at most " + max.value();
|
|
||||||
if (max == null) return "must be at least " + min.value();
|
|
||||||
return "must be between " + min.value() + " and " + max.value();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-117
@@ -1,117 +0,0 @@
|
|||||||
package dev.relism.flash.ext.jackson;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.Email;
|
|
||||||
import jakarta.validation.constraints.Max;
|
|
||||||
import jakarta.validation.constraints.Min;
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
import jakarta.validation.constraints.NotEmpty;
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
|
||||||
import jakarta.validation.constraints.Pattern;
|
|
||||||
import jakarta.validation.constraints.Size;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
|
|
||||||
class ValidatorTest {
|
|
||||||
|
|
||||||
record CreateUser(
|
|
||||||
@NotBlank @Size(max = 8) String name,
|
|
||||||
@Email String email,
|
|
||||||
@Min(18) @Max(120) int age,
|
|
||||||
@NotNull String role) {}
|
|
||||||
|
|
||||||
record Boxed(@Min(1) Integer count) {}
|
|
||||||
|
|
||||||
record Sized(@NotEmpty List<String> tags, @Size(min = 2, max = 4) String code) {}
|
|
||||||
|
|
||||||
record Patterned(@Pattern(regexp = "[a-z]+") String slug) {}
|
|
||||||
|
|
||||||
record Plain(String anything) {}
|
|
||||||
|
|
||||||
private static ValidationException failureOf(Object value) {
|
|
||||||
return assertThrows(ValidationException.class, () -> Validator.compile(value.getClass()).verify(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void aValidValuePasses() {
|
|
||||||
assertDoesNotThrow(() ->
|
|
||||||
Validator.compile(CreateUser.class).verify(new CreateUser("alice", "a@b.com", 30, "admin")));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void reportsEveryViolationNotJustTheFirst() {
|
|
||||||
ValidationException failure = failureOf(new CreateUser(" ", "nope", 5, null));
|
|
||||||
|
|
||||||
assertEquals(List.of("name", "email", "age", "role"),
|
|
||||||
failure.violations().stream().map(ValidationException.Violation::field).toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void violationsCarryFieldAndMessage() {
|
|
||||||
ValidationException failure = failureOf(new CreateUser("alice", "a@b.com", 5, "admin"));
|
|
||||||
|
|
||||||
assertEquals(1, failure.violations().size());
|
|
||||||
assertEquals("age", failure.violations().get(0).field());
|
|
||||||
assertEquals("must be between 18 and 120", failure.violations().get(0).message());
|
|
||||||
assertEquals(422, failure.status());
|
|
||||||
assertEquals("age must be between 18 and 120", failure.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void sizeCountsCharactersWithoutCopying() {
|
|
||||||
assertEquals("name", failureOf(new CreateUser("far-too-long", "a@b.com", 30, "x"))
|
|
||||||
.violations().get(0).field());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void onlyNotNullRejectsNull() {
|
|
||||||
// @Email, @Size and @Min all accept null per Jakarta semantics; @NotNull is the one that does not.
|
|
||||||
ValidationException failure = failureOf(new CreateUser("alice", null, 30, null));
|
|
||||||
|
|
||||||
assertEquals(List.of("role"),
|
|
||||||
failure.violations().stream().map(ValidationException.Violation::field).toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void boxedNumbersUseTheReferencePathAndTolerateNull() {
|
|
||||||
assertDoesNotThrow(() -> Validator.compile(Boxed.class).verify(new Boxed(null)));
|
|
||||||
assertEquals("count", failureOf(new Boxed(0)).violations().get(0).field());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void sizeAppliesToCollectionsAndStrings() {
|
|
||||||
assertDoesNotThrow(() -> Validator.compile(Sized.class).verify(new Sized(List.of("a"), "abc")));
|
|
||||||
|
|
||||||
ValidationException failure = failureOf(new Sized(List.of(), "x"));
|
|
||||||
assertEquals(List.of("tags", "code"),
|
|
||||||
failure.violations().stream().map(ValidationException.Violation::field).toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void patternIsAnchoredLikeJakarta() {
|
|
||||||
assertDoesNotThrow(() -> Validator.compile(Patterned.class).verify(new Patterned("abc")));
|
|
||||||
assertEquals("slug", failureOf(new Patterned("Abc1")).violations().get(0).field());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void emailAcceptsPlausibleAddressesAndRejectsTypos() {
|
|
||||||
assertDoesNotThrow(() ->
|
|
||||||
Validator.compile(CreateUser.class).verify(new CreateUser("a", "first.last@sub.example.co", 20, "x")));
|
|
||||||
|
|
||||||
for (String bad : List.of("no-at", "@leading.com", "trailing@", "two@@at.com", "no dots@x", "a@b")) {
|
|
||||||
assertThrows(ValidationException.class,
|
|
||||||
() -> Validator.compile(CreateUser.class).verify(new CreateUser("a", bad, 20, "x")),
|
|
||||||
bad);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void aTypeWithNoConstraintsCompilesToANoOp() {
|
|
||||||
Validator validator = Validator.compile(Plain.class);
|
|
||||||
|
|
||||||
assertTrue(validator.isEmpty());
|
|
||||||
assertDoesNotThrow(() -> validator.verify(new Plain(null)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# flash-ext-jackson-json
|
|
||||||
|
|
||||||
JSON bodies and JSON responses.
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
```java
|
|
||||||
JsonExtension json = new JsonExtension();
|
|
||||||
|
|
||||||
FlashApp.create(8080)
|
|
||||||
.install(json)
|
|
||||||
.use(json.auto())
|
|
||||||
.scan("com.acme.handlers")
|
|
||||||
.startAndBlock();
|
|
||||||
```
|
|
||||||
|
|
||||||
The default mapper discovers the modules on the classpath (Java Time among them) and writes dates
|
|
||||||
as ISO strings; `new JsonExtension(mapper)` takes one of your own. `auto()` serializes whatever a
|
|
||||||
handler returns, leaving alone what is already a response: `null`, a `Response`, a `byte[]` or a
|
|
||||||
`CharSequence`.
|
|
||||||
|
|
||||||
## A handler with a body
|
|
||||||
|
|
||||||
The body type is the handler's type argument, and that is the whole declaration — it is also what
|
|
||||||
the OpenAPI document describes and what the constraints are read from:
|
|
||||||
|
|
||||||
```java
|
|
||||||
@POST("/users")
|
|
||||||
public final class CreateUser extends JsonHandler<NewUser> {
|
|
||||||
@Inject private UserService users;
|
|
||||||
|
|
||||||
@Override protected Object handle(Request req, Response res, NewUser body) {
|
|
||||||
return users.create(body);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
A malformed body never reaches it (400), nor does one that breaks a constraint (422).
|
|
||||||
|
|
||||||
## A handler that reads it itself
|
|
||||||
|
|
||||||
```java
|
|
||||||
public final class Import extends RequestHandler {
|
|
||||||
@Inject private Json json;
|
|
||||||
|
|
||||||
@Override public Object handle(Request req, Response res) throws Exception {
|
|
||||||
return archive.store(json.body(req, Manifest.class));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`Json` is the [`Codec`](../flash-ext-jackson-core) for `application/json`: `body`, `write`,
|
|
||||||
`writeView`, `mapper`.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- One mapper per application (or per scope), shared by every handler; `ObjectMapper` is
|
|
||||||
thread-safe once configured.
|
|
||||||
- Install `flash-ext-jackson-xml` beside this one when an application speaks both: a route picks
|
|
||||||
its format by the handler it extends, not by negotiation.
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
|
||||||
<modelVersion>4.0.0</modelVersion>
|
|
||||||
|
|
||||||
<parent>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash-extensions</artifactId>
|
|
||||||
<version>2.1.0-SNAPSHOT</version>
|
|
||||||
</parent>
|
|
||||||
|
|
||||||
<artifactId>flash-ext-jackson-json</artifactId>
|
|
||||||
<name>flash-ext-jackson-json</name>
|
|
||||||
<description>JSON bodies and responses: the Json codec, JsonHandler and the marshalling middleware.</description>
|
|
||||||
|
|
||||||
<dependencies>
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash-ext-jackson-core</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.junit.jupiter</groupId>
|
|
||||||
<artifactId>junit-jupiter</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash-testing</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
<!-- The constraints a body declares are described by the published document too. -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash-ext-openapi</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
</project>
|
|
||||||
-27
@@ -1,27 +0,0 @@
|
|||||||
package dev.relism.flash.ext.jackson.json;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import dev.relism.flash.ext.jackson.Codec;
|
|
||||||
import dev.relism.flash.http.ContentType;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON in and out, checked against the body type's own constraints.
|
|
||||||
*
|
|
||||||
* <pre>{@code
|
|
||||||
* public final class CreateItem extends RequestHandler {
|
|
||||||
* @Inject private Json json;
|
|
||||||
*
|
|
||||||
* @Override public Object handle(Request req, Response res) throws Exception {
|
|
||||||
* return items.create(json.body(req, NewItem.class));
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
* }</pre>
|
|
||||||
*
|
|
||||||
* <p>A handler whose whole body is one type has nothing to write at all: see {@link JsonHandler}.
|
|
||||||
*/
|
|
||||||
public final class Json extends Codec {
|
|
||||||
|
|
||||||
public Json(ObjectMapper mapper) {
|
|
||||||
super(mapper, ContentType.JSON);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-50
@@ -1,50 +0,0 @@
|
|||||||
package dev.relism.flash.ext.jackson.json;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
|
||||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
|
||||||
import dev.relism.flash.ext.jackson.Marshalling;
|
|
||||||
import dev.relism.flash.extension.FlashContext;
|
|
||||||
import dev.relism.flash.extension.FlashExtension;
|
|
||||||
import dev.relism.flash.extension.FlashRegistrar;
|
|
||||||
import dev.relism.flash.http.ContentType;
|
|
||||||
import dev.relism.flash.routing.Middleware;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON for an application: the {@link Json} codec, the mapper behind it, and the middleware that
|
|
||||||
* serializes whatever a handler returns.
|
|
||||||
*
|
|
||||||
* <pre>{@code
|
|
||||||
* JsonExtension json = new JsonExtension();
|
|
||||||
* app.install(json).use(json.auto());
|
|
||||||
* }</pre>
|
|
||||||
*
|
|
||||||
* <p>The default mapper discovers the modules on the classpath (Java Time among them) and writes
|
|
||||||
* dates as ISO strings. Hand it a mapper of your own to decide otherwise.
|
|
||||||
*/
|
|
||||||
public class JsonExtension implements FlashExtension {
|
|
||||||
|
|
||||||
private final ObjectMapper mapper;
|
|
||||||
|
|
||||||
public JsonExtension() {
|
|
||||||
this(JsonMapper.builder()
|
|
||||||
.findAndAddModules()
|
|
||||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
|
||||||
.build());
|
|
||||||
}
|
|
||||||
|
|
||||||
public JsonExtension(ObjectMapper mapper) {
|
|
||||||
this.mapper = mapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Serializes what a handler returns, unless it already returned a response, bytes or text. */
|
|
||||||
public Middleware auto() {
|
|
||||||
return Marshalling.of(mapper, ContentType.JSON);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
|
||||||
ctx.provide(Json.class, new Json(mapper));
|
|
||||||
ctx.provide(ObjectMapper.class, mapper);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-35
@@ -1,35 +0,0 @@
|
|||||||
package dev.relism.flash.ext.jackson.json;
|
|
||||||
|
|
||||||
import dev.relism.flash.ext.jackson.Codec;
|
|
||||||
import dev.relism.flash.ext.jackson.JacksonHandler;
|
|
||||||
import dev.relism.flash.extension.Inject;
|
|
||||||
import dev.relism.flash.http.ContentType;
|
|
||||||
import dev.relism.flash.routing.Consumes;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A handler that takes a JSON body of one type.
|
|
||||||
*
|
|
||||||
* <pre>{@code
|
|
||||||
* @POST("/users")
|
|
||||||
* public final class CreateUser extends JsonHandler<NewUser> {
|
|
||||||
* @Inject private UserService users;
|
|
||||||
*
|
|
||||||
* @Override protected Object handle(Request req, Response res, NewUser body) {
|
|
||||||
* return users.create(body);
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
* }</pre>
|
|
||||||
*
|
|
||||||
* <p>The body is read off the request stream, verified against the constraints its type declares,
|
|
||||||
* and handed over. A malformed body is a 400, a body that breaks a constraint is a 422, and
|
|
||||||
* neither ever reaches the handler. The published OpenAPI document describes the same type.
|
|
||||||
*/
|
|
||||||
@Consumes(ContentType.JSON)
|
|
||||||
public abstract class JsonHandler<B> extends JacksonHandler<B> {
|
|
||||||
|
|
||||||
@Inject private Json json;
|
|
||||||
|
|
||||||
@Override protected final Codec codec() {
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-67
@@ -1,67 +0,0 @@
|
|||||||
package dev.relism.flash.ext.jackson.json;
|
|
||||||
|
|
||||||
import dev.relism.flash.ext.jackson.json.JsonExtension;
|
|
||||||
import dev.relism.flash.ext.openapi.APIResponse;
|
|
||||||
import dev.relism.flash.ext.openapi.ApiOperation;
|
|
||||||
import dev.relism.flash.ext.openapi.Content;
|
|
||||||
import dev.relism.flash.ext.openapi.OpenApiExtension;
|
|
||||||
import dev.relism.flash.http.ContentType;
|
|
||||||
import dev.relism.flash.models.Request;
|
|
||||||
import dev.relism.flash.models.RequestHandler;
|
|
||||||
import dev.relism.flash.models.Response;
|
|
||||||
import dev.relism.flash.routing.GET;
|
|
||||||
import dev.relism.flash.testing.FlashTest;
|
|
||||||
import jakarta.validation.constraints.Email;
|
|
||||||
import jakarta.validation.constraints.Max;
|
|
||||||
import jakarta.validation.constraints.Min;
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
import jakarta.validation.constraints.Size;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constraints are declared once and read twice: the validator enforces them, the published schema
|
|
||||||
* describes them. Nothing registers this bridge — flash-ext-openapi picks the annotations up on
|
|
||||||
* its own when they are on the classpath.
|
|
||||||
*/
|
|
||||||
class ConstraintsInTheDocumentTest {
|
|
||||||
|
|
||||||
record Account(
|
|
||||||
@NotBlank @Size(max = 40) String name,
|
|
||||||
@Email String email,
|
|
||||||
@Min(18) @Max(120) int age) {}
|
|
||||||
|
|
||||||
@GET("/accounts")
|
|
||||||
@ApiOperation(summary = "List accounts")
|
|
||||||
@APIResponse(responseCode = "200", content = @Content(contentType = ContentType.JSON, schema = Account.class))
|
|
||||||
public static class ListAccounts extends RequestHandler {
|
|
||||||
@Override public Object handle(Request request, Response response) {
|
|
||||||
return new Account("alice", "a@b.com", 30);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@RegisterExtension
|
|
||||||
static FlashTest app = FlashTest.of(configured -> {
|
|
||||||
configured.install(new JsonExtension());
|
|
||||||
configured.install(new OpenApiExtension("/openapi", "Accounts", "1.0.0"));
|
|
||||||
configured.scan("dev.relism.flash.ext.jackson.json");
|
|
||||||
});
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void constraintsAppearInTheGeneratedSchema() {
|
|
||||||
app.get("/openapi.json")
|
|
||||||
.expectStatus(200)
|
|
||||||
.expectBodyContains("\"maxLength\":40")
|
|
||||||
.expectBodyContains("\"format\":\"email\"")
|
|
||||||
.expectBodyContains("\"minimum\":18")
|
|
||||||
.expectBodyContains("\"maximum\":120");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void notBlankMarksThePropertyRequiredAndNonEmpty() {
|
|
||||||
app.get("/openapi.json")
|
|
||||||
.expectStatus(200)
|
|
||||||
.expectBodyContains("\"minLength\":1")
|
|
||||||
.expectBodyContains("\"required\":[\"name\"]");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-61
@@ -1,61 +0,0 @@
|
|||||||
package dev.relism.flash.ext.jackson.json;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import dev.relism.flash.extension.FlashContext;
|
|
||||||
import dev.relism.flash.models.SimpleHandler;
|
|
||||||
import dev.relism.flash.http.ContentType;
|
|
||||||
import dev.relism.flash.models.Request;
|
|
||||||
import dev.relism.flash.models.RequestHandler;
|
|
||||||
import dev.relism.flash.models.Response;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
class JsonExtensionTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void configure_registers_json_mapper_and_middleware() {
|
|
||||||
FlashContext ctx = new FlashContext();
|
|
||||||
ObjectMapper mapper = new ObjectMapper();
|
|
||||||
JsonExtension ext = new JsonExtension(mapper);
|
|
||||||
|
|
||||||
ext.configure(null, ctx);
|
|
||||||
ctx.complete();
|
|
||||||
|
|
||||||
assertNotNull(ctx.require(Json.class));
|
|
||||||
assertSame(mapper, ctx.require(ObjectMapper.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void auto_marshals_what_a_handler_returns() throws Exception {
|
|
||||||
ObjectMapper mapper = new ObjectMapper();
|
|
||||||
JsonExtension ext = new JsonExtension(mapper);
|
|
||||||
RequestHandler next = new RequestHandler() {
|
|
||||||
@Override
|
|
||||||
public Object handle(Request request, Response response) {
|
|
||||||
return new Payload("ok");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
RequestHandler wrapped = new RequestHandler() {
|
|
||||||
private final SimpleHandler.FunctionalHandler delegate = ext.auto().wrap(next);
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Object handle(Request request, Response response) throws Exception {
|
|
||||||
return delegate.handle(request, response);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
|
||||||
Object out = wrapped.handle(null, res);
|
|
||||||
|
|
||||||
assertTrue(out instanceof byte[]);
|
|
||||||
assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
|
|
||||||
}
|
|
||||||
|
|
||||||
private record Payload(String status) {}
|
|
||||||
}
|
|
||||||
-78
@@ -1,78 +0,0 @@
|
|||||||
package dev.relism.flash.ext.jackson.json;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import dev.relism.flash.extension.FlashContext;
|
|
||||||
import dev.relism.flash.http.ContentType;
|
|
||||||
import dev.relism.flash.http.HttpMethod;
|
|
||||||
import dev.relism.flash.models.Http1HeaderMap;
|
|
||||||
import dev.relism.flash.models.Request;
|
|
||||||
import dev.relism.flash.models.RequestLine;
|
|
||||||
import dev.relism.flash.models.Response;
|
|
||||||
import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
class JsonHandlerTest {
|
|
||||||
|
|
||||||
public record NewUser(String name) {}
|
|
||||||
|
|
||||||
static final class Create extends JsonHandler<NewUser> {
|
|
||||||
@Override protected Object handle(Request request, Response response, NewUser body) {
|
|
||||||
return body.name();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressWarnings("rawtypes")
|
|
||||||
static final class Untyped extends JsonHandler {
|
|
||||||
@Override protected Object handle(Request request, Response response, Object body) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void theBodyArrivesParsedAsTheTypeTheHandlerDeclares() throws Exception {
|
|
||||||
Create handler = new Create();
|
|
||||||
handler.bind(context());
|
|
||||||
|
|
||||||
Object answer = handler.handle(request("{\"name\":\"alice\"}"), new Response(200, ContentType.JSON));
|
|
||||||
|
|
||||||
assertEquals("alice", answer);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void aMalformedBodyIsTheUsualBadRequest() throws Exception {
|
|
||||||
Create handler = new Create();
|
|
||||||
handler.bind(context());
|
|
||||||
|
|
||||||
dev.relism.flash.exceptions.HttpException refused = assertThrows(dev.relism.flash.exceptions.HttpException.class,
|
|
||||||
() -> handler.handle(request("not json"), new Response(200, ContentType.JSON)));
|
|
||||||
|
|
||||||
assertEquals(400, refused.status());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void aHandlerThatNeverNamedItsBodyTypeIsRefusedAtBoot() {
|
|
||||||
IllegalStateException refused = assertThrows(IllegalStateException.class, Untyped::new);
|
|
||||||
|
|
||||||
assertTrue(refused.getMessage().contains("Handler<YourBody>"), refused.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static FlashContext context() {
|
|
||||||
FlashContext ctx = new FlashContext();
|
|
||||||
ctx.provide(Json.class, new Json(new ObjectMapper()));
|
|
||||||
ctx.complete();
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Request request(String body) {
|
|
||||||
return new Request(new RequestLine(HttpMethod.POST,
|
|
||||||
new FastPathViews.StringByteView("/users"), null,
|
|
||||||
new FastPathViews.StringByteView("HTTP/1.1"), new Http1HeaderMap()),
|
|
||||||
body.getBytes(StandardCharsets.UTF_8));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-111
@@ -1,111 +0,0 @@
|
|||||||
package dev.relism.flash.ext.jackson.json;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonView;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import dev.relism.flash.exceptions.HttpException;
|
|
||||||
import dev.relism.flash.http.ContentType;
|
|
||||||
import dev.relism.flash.http.HttpMethod;
|
|
||||||
import dev.relism.flash.models.Http1HeaderMap;
|
|
||||||
import dev.relism.flash.models.Request;
|
|
||||||
import dev.relism.flash.models.RequestLine;
|
|
||||||
import dev.relism.flash.models.Response;
|
|
||||||
import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
class JsonTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void body_parses_valid_json_and_maps_bad_payload_to_http_400() throws Exception {
|
|
||||||
Json json = new Json(new ObjectMapper());
|
|
||||||
|
|
||||||
Request ok = request("{\"id\":\"u1\",\"name\":\"alice\"}");
|
|
||||||
UserDto dto = json.body(ok, UserDto.class);
|
|
||||||
assertEquals("u1", dto.id);
|
|
||||||
assertEquals("alice", dto.name);
|
|
||||||
|
|
||||||
Request bad = request("not-json");
|
|
||||||
HttpException ex = assertThrows(HttpException.class, () -> json.body(bad, UserDto.class));
|
|
||||||
assertEquals(400, ex.status());
|
|
||||||
assertTrue(ex.getMessage().startsWith("Invalid request body:"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void a_truncated_body_is_a_bad_request_too() throws Exception {
|
|
||||||
Json json = new Json(new ObjectMapper());
|
|
||||||
|
|
||||||
HttpException ex = assertThrows(HttpException.class, () -> json.body(request("["), UserDto.class));
|
|
||||||
|
|
||||||
assertEquals(400, ex.status());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void write_and_writeView_set_content_type_and_render_expected_payload() throws Exception {
|
|
||||||
Json json = new Json(new ObjectMapper());
|
|
||||||
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
|
||||||
|
|
||||||
String payload = json.write(res, new UserDto("u3", "carol"));
|
|
||||||
assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
|
|
||||||
assertEquals("{\"id\":\"u3\",\"name\":\"carol\"}", payload);
|
|
||||||
|
|
||||||
Response viewRes = new Response(200, ContentType.TEXT_PLAIN);
|
|
||||||
String viewed = json.writeView(viewRes, new ViewDto("u4", "hidden"), PublicView.class);
|
|
||||||
assertEquals("application/json", new String(viewRes.getContentType(), StandardCharsets.UTF_8));
|
|
||||||
assertEquals("{\"id\":\"u4\"}", viewed);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void mapper_returns_underlying_object_mapper_instance() {
|
|
||||||
ObjectMapper mapper = new ObjectMapper();
|
|
||||||
Json json = new Json(mapper);
|
|
||||||
assertSame(mapper, json.mapper());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Request request(String body) {
|
|
||||||
return request(body.getBytes(StandardCharsets.UTF_8));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Request request(byte[] body) {
|
|
||||||
RequestLine line = new RequestLine(
|
|
||||||
HttpMethod.POST,
|
|
||||||
new FastPathViews.StringByteView("/json"),
|
|
||||||
null,
|
|
||||||
new FastPathViews.StringByteView("HTTP/1.1"),
|
|
||||||
new Http1HeaderMap()
|
|
||||||
);
|
|
||||||
return new Request(line, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class UserDto {
|
|
||||||
public String id;
|
|
||||||
public String name;
|
|
||||||
|
|
||||||
public UserDto() {}
|
|
||||||
|
|
||||||
private UserDto(String id, String name) {
|
|
||||||
this.id = id;
|
|
||||||
this.name = name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private interface PublicView {}
|
|
||||||
private interface InternalView {}
|
|
||||||
|
|
||||||
private static final class ViewDto {
|
|
||||||
@JsonView(PublicView.class)
|
|
||||||
public String id;
|
|
||||||
@JsonView(InternalView.class)
|
|
||||||
public String secret;
|
|
||||||
|
|
||||||
private ViewDto(String id, String secret) {
|
|
||||||
this.id = id;
|
|
||||||
this.secret = secret;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-92
@@ -1,92 +0,0 @@
|
|||||||
package dev.relism.flash.ext.jackson.json;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import dev.relism.flash.ext.jackson.Marshalling;
|
|
||||||
import dev.relism.flash.routing.Middleware;
|
|
||||||
import dev.relism.flash.models.SimpleHandler;
|
|
||||||
import dev.relism.flash.http.ContentType;
|
|
||||||
import dev.relism.flash.models.Request;
|
|
||||||
import dev.relism.flash.models.RequestHandler;
|
|
||||||
import dev.relism.flash.models.Response;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
class MarshallingTest {
|
|
||||||
|
|
||||||
private static final Request REQ = null;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void marshalling_marshalsPojo_toJsonBytes_and_setsJsonContentType() throws Exception {
|
|
||||||
Middleware mw = Marshalling.of(new ObjectMapper(), ContentType.JSON);
|
|
||||||
RequestHandler wrapped = wrap(mw, new UserDto("u1", "alice"));
|
|
||||||
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
|
||||||
|
|
||||||
Object out = wrapped.handle(REQ, res);
|
|
||||||
|
|
||||||
assertInstanceOf(byte[].class, out);
|
|
||||||
assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
|
|
||||||
assertEquals("{\"id\":\"u1\",\"name\":\"alice\"}", new String((byte[]) out, StandardCharsets.UTF_8));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void marshalling_passThrough_for_response_string_charSequence_bytes_and_null() throws Exception {
|
|
||||||
Middleware mw = Marshalling.of(new ObjectMapper(), ContentType.JSON);
|
|
||||||
|
|
||||||
Response payloadResponse = new Response(201, ContentType.TEXT_PLAIN).body("ok");
|
|
||||||
RequestHandler wrappedResponse = wrap(mw, payloadResponse);
|
|
||||||
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
|
||||||
assertSame(payloadResponse, wrappedResponse.handle(REQ, res));
|
|
||||||
|
|
||||||
String s = "hello";
|
|
||||||
assertSame(s, wrap(mw, s).handle(REQ, res));
|
|
||||||
|
|
||||||
CharSequence cs = new StringBuilder("hello-cs");
|
|
||||||
assertSame(cs, wrap(mw, cs).handle(REQ, res));
|
|
||||||
|
|
||||||
byte[] bytes = new byte[]{1, 2, 3};
|
|
||||||
assertSame(bytes, wrap(mw, bytes).handle(REQ, res));
|
|
||||||
|
|
||||||
assertSame(null, wrap(mw, null).handle(REQ, res));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void marshalling_wraps_serialization_errors_as_illegal_state() {
|
|
||||||
Middleware mw = Marshalling.of(new ObjectMapper(), ContentType.JSON);
|
|
||||||
RequestHandler wrapped = wrap(mw, new CyclicDto());
|
|
||||||
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
|
||||||
|
|
||||||
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> wrapped.handle(REQ, res));
|
|
||||||
assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
|
|
||||||
assertTrue(ex.getMessage().startsWith("Could not serialize"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static RequestHandler wrap(Middleware mw, Object fixedReturn) {
|
|
||||||
RequestHandler next = new RequestHandler() {
|
|
||||||
@Override
|
|
||||||
public Object handle(Request request, Response response) {
|
|
||||||
return fixedReturn;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return new RequestHandler() {
|
|
||||||
private final SimpleHandler.FunctionalHandler delegate = mw.wrap(next);
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Object handle(Request request, Response response) throws Exception {
|
|
||||||
return delegate.handle(request, response);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private record UserDto(String id, String name) {}
|
|
||||||
|
|
||||||
private static final class CyclicDto {
|
|
||||||
CyclicDto self = this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user