Compare commits
14
Commits
v2.0.0
..
391ae6778e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
391ae6778e | ||
|
|
1b48d14b4f | ||
|
|
7cd8b3869c | ||
|
|
9f6808e90c | ||
|
|
bf2d54ca1a | ||
|
|
a037456634 | ||
|
|
524bdeb28b | ||
|
|
c619c17949 | ||
|
|
c368843ad4 | ||
|
|
35293a0a57 | ||
|
|
c514897ffc | ||
|
|
6314bcff5f | ||
|
|
ccc5550598 | ||
|
|
a4a16bdb00 |
@@ -1,7 +1,7 @@
|
||||
<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/maven-1.0.0.xsd">
|
||||
http://maven.apache.org/xsd/settings-1.0.0.xsd">
|
||||
<servers>
|
||||
<server>
|
||||
<id>Personal</id>
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
name: Publish Docs
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Docs version to publish (e.g. 2.1.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Docs version to publish (e.g. 2.1.0)'
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
MAVEN_USERNAME:
|
||||
required: true
|
||||
MAVEN_PASSWORD:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
docs:
|
||||
name: Build JavaDoc & Update gh-pages
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Temurin 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
cache: maven
|
||||
|
||||
- name: Build project (compile + resolve deps, skip tests)
|
||||
run: mvn -B --settings .github/settings.xml clean verify -DskipTests
|
||||
env:
|
||||
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
|
||||
# javadoc:aggregate runs on the reactor root.
|
||||
# With flash + flash-extensions both declared as modules in root pom.xml,
|
||||
# this produces a single aggregated Javadoc covering all modules.
|
||||
# Default output path: target/site/apidocs/ (no custom reportOutputDirectory set).
|
||||
- name: Generate aggregated JavaDoc
|
||||
run: |
|
||||
mvn -B \
|
||||
--settings .github/settings.xml \
|
||||
-DskipTests \
|
||||
org.apache.maven.plugins:maven-javadoc-plugin:3.6.3:aggregate
|
||||
env:
|
||||
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
|
||||
- name: Verify JavaDoc output exists
|
||||
run: |
|
||||
APIDOCS=""
|
||||
|
||||
if [ -f "target/site/apidocs/index.html" ]; then
|
||||
APIDOCS="target/site/apidocs"
|
||||
elif [ -f "target/reports/apidocs/index.html" ]; then
|
||||
APIDOCS="target/reports/apidocs"
|
||||
else
|
||||
echo "ERROR: JavaDoc output not found."
|
||||
echo
|
||||
echo "Contents of target/:"
|
||||
find target -maxdepth 5 2>/dev/null || echo "(empty)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "APIDOCS_DIR=$APIDOCS" >> $GITHUB_ENV
|
||||
|
||||
COUNT=$(find "$APIDOCS" -name '*.html' | wc -l)
|
||||
echo "JavaDoc OK — $COUNT HTML files at $APIDOCS"
|
||||
|
||||
- name: Checkout gh-pages
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: gh-pages
|
||||
path: gh-pages-out
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Copy JavaDoc to versioned folder and latest
|
||||
run: |
|
||||
VERSION=${{ inputs.version }}
|
||||
|
||||
mkdir -p gh-pages-out/javadoc/$VERSION
|
||||
cp -r "$APIDOCS_DIR"/. gh-pages-out/javadoc/$VERSION/
|
||||
|
||||
rm -rf gh-pages-out/latest
|
||||
mkdir -p gh-pages-out/latest
|
||||
cp -r "$APIDOCS_DIR"/. gh-pages-out/latest/
|
||||
|
||||
- name: Regenerate index.html
|
||||
run: |
|
||||
cd gh-pages-out
|
||||
python3 - <<'EOF'
|
||||
import os, re
|
||||
|
||||
def version_key(v):
|
||||
parts = re.findall(r'\d+', v)
|
||||
return [int(p) for p in parts] if parts else [0]
|
||||
|
||||
versions = sorted(
|
||||
[d for d in os.listdir("javadoc") if os.path.isdir(f"javadoc/{d}")],
|
||||
key=version_key,
|
||||
reverse=True
|
||||
)
|
||||
|
||||
latest = versions[0] if versions else None
|
||||
|
||||
rows = "\n".join(
|
||||
f'''
|
||||
<div class="release">
|
||||
<div class="release-info">
|
||||
<span class="version">{v}</span>
|
||||
{"<span class='badge'>latest</span>" if v == latest else ""}
|
||||
</div>
|
||||
|
||||
<a href="javadoc/{v}/index.html">Open</a>
|
||||
</div>
|
||||
'''
|
||||
for v in versions
|
||||
)
|
||||
|
||||
html = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Flash — JavaDoc</title>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--surface: #fafafa;
|
||||
--border: #e5e7eb;
|
||||
|
||||
--text: #111827;
|
||||
--muted: #6b7280;
|
||||
|
||||
--accent: #111827;
|
||||
--accent-hover: #000000;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
|
||||
min-height: 100vh;
|
||||
padding: 64px 24px;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.header p {
|
||||
margin-top: 10px;
|
||||
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.latest {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
margin-top: 20px;
|
||||
padding-bottom: 2px;
|
||||
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
|
||||
border-bottom: 1px solid transparent;
|
||||
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.latest:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.list {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.release {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
gap: 16px;
|
||||
|
||||
padding: 18px 0;
|
||||
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.release-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.version {
|
||||
font-size: 0.96rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
|
||||
color: var(--muted);
|
||||
|
||||
border: 1px solid var(--border);
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.release a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.release a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 32px 0;
|
||||
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
body {
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.7rem;
|
||||
}
|
||||
|
||||
.release {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<header class="header">
|
||||
<h1>Flash JavaDoc</h1>
|
||||
|
||||
<p>
|
||||
API documentation for all published Flash releases.
|
||||
</p>
|
||||
|
||||
""" + (
|
||||
f'''
|
||||
<a class="latest" href="latest/index.html">
|
||||
Latest release — {latest}
|
||||
</a>
|
||||
'''
|
||||
if latest else ""
|
||||
) + """
|
||||
</header>
|
||||
|
||||
""" + (
|
||||
f'''
|
||||
<div class="list">
|
||||
{rows}
|
||||
</div>
|
||||
'''
|
||||
if rows else
|
||||
'''
|
||||
<div class="empty">
|
||||
No versions published yet.
|
||||
</div>
|
||||
'''
|
||||
) + """
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
with open("index.html", "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
|
||||
print(f"index.html generated — {len(versions)} version(s): {versions}")
|
||||
EOF
|
||||
|
||||
- name: Push gh-pages
|
||||
run: |
|
||||
cd gh-pages-out
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add -A
|
||||
git diff --cached --quiet || git commit -m "docs(javadoc): publish ${{ inputs.version }}"
|
||||
git push origin gh-pages
|
||||
@@ -6,13 +6,15 @@ on:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
# ── 1. Build, GPG-sign, deploy to Maven releases ──────────────────────────
|
||||
release:
|
||||
name: Build, Sign, Deploy & Publish
|
||||
name: Build, Sign & Deploy
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.VERSION }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -47,90 +49,22 @@ jobs:
|
||||
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
|
||||
- name: Generate aggregated JavaDoc
|
||||
run: |
|
||||
mvn -B --settings .github/settings.xml \
|
||||
-pl flash,flash-extensions -am \
|
||||
javadoc:aggregate -DskipTests
|
||||
env:
|
||||
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
|
||||
- name: Checkout gh-pages
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: gh-pages
|
||||
path: gh-pages-out
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Copy JavaDoc to versioned folder
|
||||
run: |
|
||||
VERSION=${{ steps.version.outputs.VERSION }}
|
||||
mkdir -p gh-pages-out/javadoc/$VERSION
|
||||
cp -r target/reports/apidocs/. gh-pages-out/javadoc/$VERSION/
|
||||
rm -rf gh-pages-out/latest
|
||||
mkdir -p gh-pages-out/latest
|
||||
cp -r target/reports/apidocs/. gh-pages-out/latest/
|
||||
|
||||
- name: Regenerate index.html
|
||||
run: |
|
||||
cd gh-pages-out
|
||||
python3 - <<'EOF'
|
||||
import os, re
|
||||
|
||||
versions = sorted(
|
||||
[d for d in os.listdir("javadoc") if os.path.isdir(f"javadoc/{d}")],
|
||||
key=lambda v: [int(x) for x in re.sub(r'[^0-9.]', '', v).split('.') if x],
|
||||
reverse=True
|
||||
)
|
||||
|
||||
rows = "\n".join(
|
||||
f' <li><a href="javadoc/{v}/index.html">{v}</a></li>'
|
||||
for v in versions
|
||||
)
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Flash JavaDoc</title>
|
||||
<style>
|
||||
body {{ font-family: sans-serif; max-width: 600px; margin: 4rem auto; }}
|
||||
h1 {{ font-size: 1.6rem; }}
|
||||
ul {{ line-height: 2; }}
|
||||
a {{ color: #0070f3; text-decoration: none; }}
|
||||
a:hover {{ text-decoration: underline; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Flash — JavaDoc</h1>
|
||||
<p><a href="latest/index.html">→ Latest</a></p>
|
||||
<h2>All versions</h2>
|
||||
<ul>
|
||||
{rows}
|
||||
</ul>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
with open("index.html", "w") as f:
|
||||
f.write(html)
|
||||
print(f"index.html generated with {len(versions)} versions: {versions}")
|
||||
EOF
|
||||
|
||||
- name: Push gh-pages
|
||||
run: |
|
||||
cd gh-pages-out
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add -A
|
||||
git diff --cached --quiet || git commit -m "docs(javadoc): release ${{ steps.version.outputs.VERSION }}"
|
||||
git push origin gh-pages
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ steps.version.outputs.VERSION }}
|
||||
name: v${{ steps.version.outputs.VERSION }}
|
||||
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 }}
|
||||
Generated
+72
-283
@@ -4,292 +4,42 @@
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="fc757130-fe3e-4ea9-8d69-c26ffb8545a4" name="Changes" comment="add core view extension with JTE and Thymeleaf support">
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/pom.xml" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/DataExtension.java" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/Page.java" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/Repository.java" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/ResourceRegistry.java" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/Sort.java" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TransactionIsolation.java" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TransactionPropagation.java" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/Tx.java" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxDefinition.java" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxException.java" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxManager.java" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxOutcome.java" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxResourceKey.java" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxStatus.java" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxSynchronization.java" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/pom.xml" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateRepository.java" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateTxManager.java" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateTxStatus.java" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/test/java/dev/relism/flash/ext/data/hibernate/HibernateTxManagerTest.java" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/test/java/dev/relism/flash/ext/data/hibernate/TestHelper.java" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/pom.xml" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/main/java/dev/relism/flash/ext/data/jdbc/JdbcRepository.java" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/main/java/dev/relism/flash/ext/data/jdbc/JdbcTxManager.java" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/main/java/dev/relism/flash/ext/data/jdbc/JdbcTxStatus.java" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/test/java/dev/relism/flash/ext/data/jdbc/JdbcTxManagerTest.java" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteTarget.java" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendType.java" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/Flash.java" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/routers/radix/RadixPathRouterImpl.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/.idea/encodings.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/encodings.xml" afterDir="false" />
|
||||
<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" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/JacksonExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/JacksonExtension.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/JacksonMiddleware.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/JacksonMiddleware.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/Json.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/Json.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/ext/jackson/JacksonExtensionTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JacksonExtensionTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/ext/jackson/JacksonMiddlewareTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JacksonMiddlewareTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/ext/jackson/JsonTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JsonTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/Bucket.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/Bucket.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/BucketStore.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/BucketStore.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/Guard.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/Guard.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/KeyResolver.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/KeyResolver.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/Limit.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/Limit.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimitConfig.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimitConfig.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimitStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimitStrategy.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimiterConfig.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterConfig.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimiterExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterExtension.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/RateLimitStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/RateLimitStrategy.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/strategy/FixedWindowStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/strategy/FixedWindowStrategy.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/strategy/SlidingWindowStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/strategy/SlidingWindowStrategy.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/strategy/TokenBucketStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/strategy/TokenBucketStrategy.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/test/java/dev/relism/ext/limiter/LimiterOpenApiInteropTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/test/java/dev/relism/flash/ext/limiter/LimiterOpenApiInteropTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/Authenticated.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/Authenticated.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/ClaimsHolder.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ClaimsHolder.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/ClientAuthMethod.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ClientAuthMethod.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/DiscoveryClient.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/DiscoveryClient.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/InMemoryOidcSessionStore.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/InMemoryOidcSessionStore.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/JwtUtils.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/JwtUtils.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/JwtValidator.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/JwtValidator.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcAuthPolicy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcAuthPolicy.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcConfig.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcConfig.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcMiddleware.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcMiddleware.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcProviderMetadata.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcProviderMetadata.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcSession.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcSession.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcSessionStore.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcSessionStore.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcStateStore.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcStateStore.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcTokenResponse.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcTokenResponse.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcUser.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcUser.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcValidationException.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcValidationException.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/PkceUtils.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/PkceUtils.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/RolesAllowed.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/RolesAllowed.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/ScopesAllowed.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ScopesAllowed.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/TokenClient.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/TokenClient.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcAuthPolicyTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcAuthPolicyTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcMiddlewareAuthzTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcMiddlewareAuthzTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcOpenApiInteropTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcUserScopesTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcUserScopesTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/APIResponse.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/APIResponse.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/APIResponses.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/APIResponses.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiOperation.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ApiOperation.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ArraySchema.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ArraySchema.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/Content.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Content.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiBuilder.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiContributor.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiContributor.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiContributorRegistry.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiContributorRegistry.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiExtension.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiOperationContribution.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiOperationContribution.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiResponseContribution.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiResponseContribution.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/Parameter.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Parameter.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ParameterIn.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ParameterIn.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/Parameters.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Parameters.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/Schema.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Schema.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/SchemaProperty.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/SchemaProperty.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/SchemaType.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/SchemaType.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/ext/openapi/OpenApiBuilderTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/ext/openapi/OpenApiExtensionTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiExtensionTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/GraphSerializer.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/GraphSerializer.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/RouteViewerDataHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/RouteViewerDataHandler.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/RouteViewerExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/RouteViewerExtension.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/RouteViewerHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/RouteViewerHandler.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/RouteViewerStaticHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/RouteViewerStaticHandler.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/model/RouteGraph.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/model/RouteGraph.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/model/RouteRecord.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/model/RouteRecord.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/model/RouterNode.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/model/RouterNode.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/BaseViewExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/BaseViewExtension.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/BaseViewHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/BaseViewHandler.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/GlobalValue.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/GlobalValue.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/RenderedView.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/RenderedView.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/ViewModel.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/ViewModel.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/ext/view/core/ViewRuntimeBridge.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/ViewRuntimeBridge.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/test/java/dev/relism/ext/view/core/ViewModelTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/test/java/dev/relism/flash/ext/view/core/ViewModelTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/README.md" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/docs/handlers.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/docs/handlers.md" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.class" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.class" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteExtension.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteHandler.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteRuntime.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteRuntime.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteSettings.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteSettings.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteTarget.java" beforeDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteTargetResolver.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteTargetResolver.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/Template.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/Template.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteExtensionTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteExtensionTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteRuntimeTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteRuntimeTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteSettingsTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteSettingsTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteTargetResolverTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteTargetResolverTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/model/HomePage.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/model/HomePage.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/resources/templates/pages/home.jte" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/test/resources/templates/pages/home.jte" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/README.md" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/docs/fragments.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/docs/fragments.md" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/docs/handlers.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/docs/handlers.md" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/Fragment.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/Fragment.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/Template.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/Template.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafExtension.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafHandler.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafRuntime.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafRuntime.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafSettings.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafSettings.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafTarget.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafTarget.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/ext/view/thymeleaf/ThymeleafTargetResolver.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafTargetResolver.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/ext/view/thymeleaf/ThymeleafExtensionTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafExtensionTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/ext/view/thymeleaf/ThymeleafRuntimeTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafRuntimeTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/ext/view/thymeleaf/ThymeleafSettingsTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafSettingsTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/ext/view/thymeleaf/ThymeleafTargetResolverTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/test/java/dev/relism/flash/ext/view/thymeleaf/ThymeleafTargetResolverTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/README.md" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/asset-sources.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/asset-sources.md" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/configuration.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/configuration.md" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/dev-lifecycle.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/dev-lifecycle.md" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/frontend-selection.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/frontend-selection.md" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/modes.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/modes.md" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/package-managers.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/package-managers.md" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/performance.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/performance.md" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/prod-serving.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/prod-serving.md" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/routing-fallback.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/routing-fallback.md" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/docs/security-policies.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/docs/security-policies.md" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/pom.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetCatalog.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetCatalog.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetEntry.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetEntry.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetIo.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetIo.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetLoadRequest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetLoadRequest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetMetadata.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetMetadata.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetPaths.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetPaths.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetsSource.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetsSource.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/AssetsSources.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetsSources.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/BasePathEnforcementMode.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/BasePathEnforcementMode.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/ClasspathAssetManifest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ClasspathAssetManifest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/ClasspathAssetsSource.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ClasspathAssetsSource.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/CommandOrchestrator.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandOrchestrator.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/CommandSafetyMode.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandSafetyMode.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/CommandSafetyPolicy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandSafetyPolicy.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/CommandTokens.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandTokens.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/FilesystemAssetsSource.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FilesystemAssetsSource.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/FrontendStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendStrategy.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/FrontendType.java" beforeDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/FrontendTypeResolver.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendTypeResolver.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/InstallCache.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/InstallCache.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/InstallPolicy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/InstallPolicy.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/LoggingMode.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/LoggingMode.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/MimeTypes.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/MimeTypes.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/ModeResolver.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ModeResolver.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/OperationMode.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/OperationMode.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/PackageManager.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/PackageManager.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/PackageManagerAdapter.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/PackageManagerAdapter.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/RuntimeEnvironment.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/RuntimeEnvironment.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/RuntimeMode.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/RuntimeMode.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/SpaFallbackPolicy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/SpaFallbackPolicy.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/StaticAssetServingPolicy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticAssetServingPolicy.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/ViteFrontendStrategy.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ViteFrontendStrategy.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/WatchList.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WatchList.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/WebBundlerConfig.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerConfig.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/WebBundlerExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerExtension.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/main/java/dev/relism/ext/webbundler/WebBundlerRuntime.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerRuntime.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/AssetsSourceTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/AssetsSourceTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/CommandSafetyPolicyTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/CommandSafetyPolicyTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/InstallCacheTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/InstallCacheTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/ModeResolverTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/ModeResolverTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/PackageManagerAdapterTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/PackageManagerAdapterTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/WebBundlerConfigTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerConfigTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/WebBundlerExtensionDevGuardTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionDevGuardTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-web-bundler/src/test/java/dev/relism/ext/webbundler/WebBundlerExtensionIntegrationTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionIntegrationTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/pom.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/ChunkedInputStream.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/ChunkedInputStream.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/Flash.java" beforeDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/HttpServer.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/HttpServer.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/RequestParser.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/RequestParser.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/ServerHandle.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/ServerHandle.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/api/multipart/Multipart.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/api/multipart/Multipart.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/api/multipart/Part.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/api/multipart/Part.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/exceptions/HttpException.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/exceptions/HttpException.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/exceptions/InitializationException.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/exceptions/InitializationException.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/AnnotationProcessor.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/AnnotationProcessor.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/ExtensionPhase.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/ExtensionPhase.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashApp.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashApp.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashConfiguration.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashConfiguration.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashContext.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashContext.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashExtension.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashRegistrar.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashRegistrar.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashScope.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/FlashScope.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/PackageScanner.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/PackageScanner.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/RouteDefinition.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/RouteDefinition.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/RouteEvent.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/RouteEvent.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/RouteListener.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/extension/RouteListener.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/http/ContentType.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/http/ContentType.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/http/HttpMethod.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/http/HttpMethod.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/http/HttpStatus.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/http/HttpStatus.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/HeaderMap.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/HeaderMap.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/PathParams.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/PathParams.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/QueryParams.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/QueryParams.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/Request.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/Request.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/RequestBody.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/RequestBody.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/RequestHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/RequestHandler.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/RequestHelper.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/RequestHelper.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/RequestLine.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/RequestLine.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/Response.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/Response.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/SimpleHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/models/SimpleHandler.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/AbstractRouter.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/CONNECT.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/CONNECT.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/DELETE.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/DELETE.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/GET.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/GET.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/HEAD.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/HEAD.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/Middleware.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/Middleware.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/OPTIONS.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/OPTIONS.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/PATCH.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/PATCH.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/POST.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/POST.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/PURGE.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/PURGE.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/PUT.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/PUT.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/PathUtils.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/PathUtils.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/Route.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/Route.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/Routes.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/Routes.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/TRACE.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/TRACE.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImpl.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterImpl.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathViews.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathViews.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/routers/radix/RadixPathRouterImpl.java" beforeDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/template/ByteTemplate.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/template/ByteTemplate.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/template/ErrorPages.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/flash/template/ErrorPages.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/ChunkedInputStreamTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/ChunkedInputStreamTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/HttpServerConcurrencyTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/HttpServerConcurrencyTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/HttpServerTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/HttpServerTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/RequestParserTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/RequestParserTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/api/multipart/MultipartTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/api/multipart/MultipartTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/http/ContentTypeTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/http/ContentTypeTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/http/HttpMethodTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/http/HttpMethodTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/http/HttpStatusTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/http/HttpStatusTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/HeaderMapTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/HeaderMapTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/PathParamsTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/PathParamsTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/QueryParamsTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/QueryParamsTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/RequestBodyTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/RequestBodyTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/RequestLineTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/RequestLineTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/RequestTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/RequestTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/ResponseTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/ResponseTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/models/SimpleHandlerTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/models/SimpleHandlerTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/AbstractRouterTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/routing/AbstractRouterTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/PathUtilsTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/routing/PathUtilsTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImplTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterImplTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathViewsTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathViewsTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/template/ByteTemplateTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/template/ByteTemplateTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/template/ErrorPagesTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/flash/template/ErrorPagesTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/pom.xml" afterDir="false" />
|
||||
</list>
|
||||
<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" />
|
||||
@@ -298,7 +48,7 @@
|
||||
</persistenceIdMap>
|
||||
</component>
|
||||
<component name="EmbeddingIndexingInfo">
|
||||
<option name="cachedIndexableFilesCount" value="407" />
|
||||
<option name="cachedIndexableFilesCount" value="448" />
|
||||
<option name="fileBasedEmbeddingIndicesEnabled" value="true" />
|
||||
</component>
|
||||
<component name="FileTemplateManagerImpl">
|
||||
@@ -534,7 +284,17 @@
|
||||
<workItem from="1776931805422" duration="16122000" />
|
||||
<workItem from="1777051880577" duration="2650000" />
|
||||
<workItem from="1777150747725" duration="837000" />
|
||||
<workItem from="1777198150359" duration="638000" />
|
||||
<workItem from="1777198150359" duration="5628000" />
|
||||
<workItem from="1777451402985" duration="709000" />
|
||||
<workItem from="1777534738187" duration="13411000" />
|
||||
<workItem from="1777970837797" duration="5042000" />
|
||||
<workItem from="1778160998498" duration="2931000" />
|
||||
<workItem from="1778319397472" duration="5040000" />
|
||||
<workItem from="1778352978922" duration="2169000" />
|
||||
<workItem from="1778414714349" duration="23000" />
|
||||
<workItem from="1778417425077" duration="3241000" />
|
||||
<workItem from="1778489168036" duration="9828000" />
|
||||
<workItem from="1778576795735" duration="5051000" />
|
||||
</task>
|
||||
<task id="LOCAL-00001" summary="Initial">
|
||||
<option name="closed" value="true" />
|
||||
@@ -632,7 +392,31 @@
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1776724331443</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="13" />
|
||||
<task id="LOCAL-00013" summary="refactor: rename packages and files to use 'flash' prefix for consistency">
|
||||
<option name="closed" value="true" />
|
||||
<created>1777199691803</created>
|
||||
<option name="number" value="00013" />
|
||||
<option name="presentableId" value="LOCAL-00013" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1777199691804</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00014" summary="fix: enhance global key validation and update template parameters">
|
||||
<option name="closed" value="true" />
|
||||
<created>1777451475753</created>
|
||||
<option name="number" value="00014" />
|
||||
<option name="presentableId" value="LOCAL-00014" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1777451475753</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00015" summary="feat: introduce WebSocket support with new endpoints and transaction propagation enhancements">
|
||||
<option name="closed" value="true" />
|
||||
<created>1778508899541</created>
|
||||
<option name="number" value="00015" />
|
||||
<option name="presentableId" value="LOCAL-00015" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1778508899541</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="16" />
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
@@ -674,7 +458,12 @@
|
||||
<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" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="add core view extension with JTE and Thymeleaf support" />
|
||||
<MESSAGE value="refactor: rename packages and files to use 'flash' prefix for consistency" />
|
||||
<MESSAGE value="fix: enhance global key validation and update template parameters" />
|
||||
<MESSAGE value="chore(release): prepare 2.1.0-SNAPSHOT" />
|
||||
<MESSAGE value="feat: introduce Spec and Query interfaces with transaction propagation enhancements" />
|
||||
<MESSAGE value="feat: introduce WebSocket support with new endpoints and transaction propagation enhancements" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="feat: introduce WebSocket support with new endpoints and transaction propagation enhancements" />
|
||||
</component>
|
||||
<component name="XSLT-Support.FileAssociations.UIState">
|
||||
<expand />
|
||||
|
||||
@@ -163,8 +163,96 @@ app.onException((ex, req, res) -> {
|
||||
|---|---|---|
|
||||
| `port` | — | TCP port to bind |
|
||||
| `host` | `"0.0.0.0"` | Bind address |
|
||||
| `tls` | `null` | TLS for the default listener — see [TLS](#tls) |
|
||||
| `listeners` | `[]` | Multiple bind targets (port + host + optional TLS) on one app — see [TLS](#tls) |
|
||||
| `maxHeaderBufferSize` | `65536` | Max size of the header buffer (bytes) |
|
||||
|
||||
## TLS
|
||||
|
||||
HTTPS and WSS are a transport-layer concern only: once a listener is bound, the accepted
|
||||
`Socket` is either plain or an `SSLSocket` indistinguishably from `HttpServer`'s point of view
|
||||
onward — the request parser, router, and WebSocket upgrade never branch on it. WSS is therefore
|
||||
not a separate feature; it's a WebSocket upgrade running over whatever transport it was handed.
|
||||
|
||||
### Quick start
|
||||
|
||||
```java
|
||||
FlashApp.create(FlashConfiguration.builder()
|
||||
.port(443)
|
||||
.tls(TlsConfig.keystore(Path.of("cert.p12"), "changeit"))
|
||||
.build())
|
||||
.get("/ping", (req, res) -> "pong") // HTTPS
|
||||
.ws("/live", handler) // WSS, same route API
|
||||
.start();
|
||||
```
|
||||
|
||||
### Multiple listeners
|
||||
|
||||
One app can bind any number of ports, each independently plain or TLS:
|
||||
|
||||
```java
|
||||
FlashApp.create(FlashConfiguration.builder()
|
||||
.listener(new FlashConfiguration.Listener(80)) // plain
|
||||
.listener(new FlashConfiguration.Listener(443, TlsConfig.keystore(cert, pass))) // TLS
|
||||
.build());
|
||||
```
|
||||
|
||||
A non-empty `listeners` list takes precedence over the top-level `port`/`host`/`tls` fields.
|
||||
Each listener gets its own accept threads; the router, WS router, and virtual-thread executor
|
||||
are shared by all of them — one app, N ports.
|
||||
|
||||
### `TlsConfig`
|
||||
|
||||
| Factory | Use |
|
||||
|---|---|
|
||||
| `TlsConfig.keystore(Path, String)` | Builds the `SSLContext` from a PKCS12/JKS keystore (type guessed from the extension). Pins `TLSv1.2`/`TLSv1.3` as enabled protocols; cipher suites are left at the JDK's own curated default. |
|
||||
| `TlsConfig.ofContext(SSLContext)` | Escape hatch — the given `SSLContext` is used exactly as built. Flash never calls `setSSLParameters` on this path beyond what you explicitly request via `clientAuth`/`applicationProtocols`, so anything else you configured (custom `KeyManager`, ALPN, cipher suites) is authoritative. |
|
||||
|
||||
Chainable on either factory:
|
||||
|
||||
```java
|
||||
TlsConfig.keystore(cert, pass)
|
||||
.clientAuth(ClientAuth.REQUIRE) // mTLS: NONE (default) | OPTIONAL | REQUIRE
|
||||
.applicationProtocols("acme-tls/1", "http/1.1") // ALPN, in preference order
|
||||
```
|
||||
|
||||
**SNI** falls out of `keystore()` for free: a keystore holding more than one certificate entry
|
||||
is matched against the requested hostname by each certificate's SAN (falling back to CN) — no
|
||||
per-hostname config. The first entry in the keystore is the default when SNI is absent or
|
||||
matches nothing (same convention as nginx/HAProxy's `default_server`).
|
||||
|
||||
**ALPN and custom certificate selection** (e.g. TLS-ALPN-01 / RFC 8737 for on-demand ACME
|
||||
issuance): ALPN is resolved while consuming `ClientHello`/producing `ServerHello`, which always
|
||||
precedes `Certificate` production. A custom `X509ExtendedKeyManager` passed via `ofContext`
|
||||
can therefore read `engine.getHandshakeApplicationProtocol()` (or
|
||||
`((SSLSocket) socket).getHandshakeApplicationProtocol()`) inside
|
||||
`chooseEngineServerAlias`/`chooseServerAlias` — the negotiated protocol is already resolved by
|
||||
then, so the certificate decision can key off it.
|
||||
|
||||
**mTLS with a private CA**: `clientAuth(...)` only requests/requires a client certificate;
|
||||
`keystore()` deliberately doesn't expose a way to configure which CAs are trusted for that
|
||||
certificate (it uses the JDK default trust store). For a private CA, build the `SSLContext`
|
||||
yourself with a `TrustManagerFactory` and use `ofContext(...)`.
|
||||
|
||||
### Reading TLS info from a request
|
||||
|
||||
```java
|
||||
app.get("/whoami", (req, res) -> {
|
||||
if (!req.isSecure()) return "plain";
|
||||
SSLSession session = req.sslSession(); // null iff !isSecure()
|
||||
X509Certificate peer = (X509Certificate) session.getPeerCertificates()[0]; // mTLS only
|
||||
return session.getCipherSuite() + " / " + session.getProtocol();
|
||||
});
|
||||
```
|
||||
|
||||
`Request.isSecure()` / `Request.sslSession()` cost nothing extra per request: the `SSLSocket`
|
||||
reference is threaded through once per connection (same mechanism as `remoteAddress()`), and
|
||||
`sslSession()` only calls `SSLSocket#getSession()` — a cached-field read once the handshake
|
||||
that got the request this far has already completed, never a forced handshake.
|
||||
|
||||
`WebSocketSession` mirrors this exactly (`isSecure()`, `sslSession()`) by delegating to the
|
||||
upgrading `Request` — no separate TLS state is tracked for WS.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
@@ -181,6 +269,7 @@ ServerSocket.accept()
|
||||
- **Zero-allocation router** — `FastPathRouterImpl` uses `fpr-core`, a byte-level FSM that matches on `METHOD + path` bytes with no per-request allocation.
|
||||
- **Keep-alive** — `RequestParser` reuses its header buffer across requests on the same connection.
|
||||
- **Chunked transfer** — both chunked request bodies (decoded via `ChunkedInputStream`) and chunked response bodies are supported.
|
||||
- **TLS is transport-only** — see [TLS](#tls). Listeners bind either a plain `ServerSocket` or an `SSLServerSocket`; nothing downstream of `accept()` branches on which.
|
||||
|
||||
## Build & test
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
config:
|
||||
target: "ws://localhost:8080/echo"
|
||||
engines:
|
||||
ws: {}
|
||||
phases:
|
||||
- duration: 30
|
||||
arrivalRate: 50
|
||||
rampTo: 500
|
||||
name: "Riscaldamento progressivo"
|
||||
- duration: 120
|
||||
arrivalRate: 1000 # 1000 nuovi utenti al secondo
|
||||
name: "Carico Estremo"
|
||||
ensure:
|
||||
maxErrorRate: 5
|
||||
p99: 150
|
||||
|
||||
scenarios:
|
||||
- name: "Saturazione Totale"
|
||||
engine: ws
|
||||
flow:
|
||||
- loop:
|
||||
- send: "Benchmark data"
|
||||
# Rimosso il 'think' per eliminare il limite artificiale di 10msg/s per utente
|
||||
count: 100 # Ogni utente spara a raffica 100 messaggi senza pause
|
||||
@@ -0,0 +1,81 @@
|
||||
# flash-ext-data-core
|
||||
|
||||
Core comune per il layer dati di Flash.
|
||||
|
||||
## Scopo
|
||||
|
||||
Questo modulo definisce il contratto transazionale condiviso tra le implementazioni backend.
|
||||
Non parla con Hibernate o JDBC direttamente: espone solo astrazioni e un runtime minimale.
|
||||
|
||||
## Componenti
|
||||
|
||||
- `TxDefinition`: metadata immutabile della transazione.
|
||||
- `TxStatus`: stato runtime restituito dal manager.
|
||||
- `TxManager`: contratto per `begin`, `commit`, `rollback`.
|
||||
- `Tx`: orchestration runtime e stack transazionale per thread.
|
||||
- `ResourceRegistry`: storage thread-local di risorse e synchronizations.
|
||||
- `Repository<T, ID>`: base repository auto-transazionale.
|
||||
- `Spec<T>`: predicato componibile.
|
||||
- `Query<T>`: oggetto query con spec, sort e paging.
|
||||
- `SpecBuilder<T>`: DSL fluente per costruire spec tipizzate.
|
||||
- `RepositorySupport<T, ID>`: helper interno condiviso.
|
||||
- `TransactionPropagation`: semantica di propagazione.
|
||||
- `TransactionIsolation`: livello di isolamento.
|
||||
- `TxSynchronization`: hook lifecycle.
|
||||
|
||||
## Modello di esecuzione
|
||||
|
||||
Il flusso è:
|
||||
|
||||
1. `Tx.call(definition, work)` chiama `TxManager.begin(definition)`.
|
||||
2. Il `TxManager` crea un `TxStatus` backend-specific.
|
||||
3. Lo status viene pushato nello stack thread-local.
|
||||
4. Il lavoro usa `Tx.resource(Class)` per ottenere la risorsa corrente.
|
||||
5. A fine lavoro `Tx` decide tra `commit` e `rollback`.
|
||||
6. Lo stack viene poppato e il thread-local viene pulito se vuoto.
|
||||
|
||||
## Propagation supportata
|
||||
|
||||
- `REQUIRED`: usa la tx attiva oppure ne apre una nuova.
|
||||
- `REQUIRES_NEW`: sospende la tx corrente e apre una nuova tx.
|
||||
- `SUPPORTS`: se esiste una tx attiva si aggancia, altrimenti esegue senza tx.
|
||||
- `NOT_SUPPORTED`: sospende la tx corrente ed esegue senza tx.
|
||||
- `MANDATORY`: richiede una tx attiva.
|
||||
|
||||
## Uso di `Repository`
|
||||
|
||||
`Repository` è la base comune per le repository concrete.
|
||||
Ogni operazione pubblica usa internamente una tx `REQUIRED` o `REQUIRED` read-only.
|
||||
|
||||
Le sottoclassi implementano i metodi `doXxx(...)` del nuovo modello:
|
||||
|
||||
- `doFind(Query<T>)`
|
||||
- `doFindOne(Spec<T>)`
|
||||
- `doFindPage(Query<T>)`
|
||||
- `doDeleteAll(Spec<T>)`
|
||||
- `doUpdateAll(Spec<T>, T)`
|
||||
|
||||
I vecchi overload di `findAll(...)` e `findPage(...)` sono stati ridotti a una combinazione di `Query<T>` e `Spec<T>`.
|
||||
|
||||
```java
|
||||
public abstract class Repository<T, ID> {
|
||||
protected Repository(Tx tx) { ... }
|
||||
protected final <R> R tx(Tx.TxCallable<R> work) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
## Composizione con Flash
|
||||
|
||||
`DataExtension` registra:
|
||||
|
||||
- `Tx` nel `FlashContext`
|
||||
- `TxManager` nel `FlashContext`
|
||||
- un annotation processor per `@Transactional`
|
||||
|
||||
Questo rende il layer dati componibile con il sistema di extension di Flash senza stato globale.
|
||||
|
||||
## Note implementative
|
||||
|
||||
- Lo stack transazionale è thread-local e viene ripulito quando torna vuoto.
|
||||
- Le risorse backend sono sospese e ripristinate per `REQUIRES_NEW` e `NOT_SUPPORTED`.
|
||||
- `TxSynchronization` è il punto di aggancio per hook di commit/rollback/completion.
|
||||
@@ -7,7 +7,7 @@
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-data-core</artifactId>
|
||||
|
||||
+6
-4
@@ -7,7 +7,6 @@ import dev.relism.flash.ext.data.core.TransactionPropagation;
|
||||
import dev.relism.flash.extension.ExtensionPhase;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.extension.FlashExtension;
|
||||
import dev.relism.flash.extension.FlashRegistrar;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
import jakarta.transaction.Transactional;
|
||||
|
||||
@@ -16,14 +15,16 @@ import java.util.Objects;
|
||||
|
||||
public final class DataExtension implements FlashExtension {
|
||||
private final TxManager txManager;
|
||||
private final Tx tx;
|
||||
|
||||
public DataExtension(TxManager txManager) {
|
||||
this.txManager = Objects.requireNonNull(txManager);
|
||||
this.tx = new Tx(txManager);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void provide(FlashContext ctx) {
|
||||
Tx.init(txManager);
|
||||
ctx.provide(Tx.class, tx);
|
||||
ctx.provide(TxManager.class, txManager);
|
||||
ctx.addAnnotationProcessor(handlerClass -> {
|
||||
Transactional ann = handlerClass.getAnnotation(Transactional.class);
|
||||
@@ -33,7 +34,7 @@ public final class DataExtension implements FlashExtension {
|
||||
TxDefinition definition = TxDefinition.DEFAULTS
|
||||
.withPropagation(mapTxType(ann.value()));
|
||||
Middleware middleware = next -> (req, res) -> {
|
||||
return Tx.call(definition, () -> next.handle(req, res));
|
||||
return tx.call(definition, () -> next.handle(req, res));
|
||||
};
|
||||
return List.of(middleware);
|
||||
});
|
||||
@@ -46,8 +47,9 @@ public final class DataExtension implements FlashExtension {
|
||||
|
||||
private TransactionPropagation mapTxType(Transactional.TxType txType) {
|
||||
return switch (txType) {
|
||||
case REQUIRED, SUPPORTS -> TransactionPropagation.REQUIRED;
|
||||
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;
|
||||
};
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package dev.relism.flash.ext.data.core;
|
||||
|
||||
public record Query<T>(Spec<T> spec, Sort sort, Integer page, Integer size) {
|
||||
public Query {
|
||||
spec = spec == null ? Spec.all() : spec;
|
||||
sort = sort == null ? Sort.unsorted() : sort;
|
||||
}
|
||||
|
||||
public static <T> Query<T> all() {
|
||||
return new Query<>(Spec.all(), Sort.unsorted(), null, null);
|
||||
}
|
||||
|
||||
public Query<T> where(Spec<T> spec) {
|
||||
return new Query<>(spec, sort, page, size);
|
||||
}
|
||||
|
||||
public Query<T> orderBy(Sort sort) {
|
||||
return new Query<>(spec, sort, page, size);
|
||||
}
|
||||
|
||||
public Query<T> page(int page, int size) {
|
||||
return new Query<>(spec, sort, page, size);
|
||||
}
|
||||
|
||||
public boolean isPaged() {
|
||||
return page != null && size != null;
|
||||
}
|
||||
}
|
||||
+100
-91
@@ -4,109 +4,118 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Base repository. Subclasses only extend this — never HibernateRepository
|
||||
* or JdbcRepository directly. The concrete backing is transparent.
|
||||
*
|
||||
* Every method auto-wraps in REQUIRED transaction — safe to call with or
|
||||
* without an active transaction on the thread.
|
||||
*/
|
||||
public abstract class Repository<T, ID> {
|
||||
public abstract class Repository<T, ID> extends RepositorySupport<T, ID> {
|
||||
|
||||
private final TxDefinition required = TxDefinition.DEFAULTS
|
||||
.withPropagation(TransactionPropagation.REQUIRED);
|
||||
|
||||
// ── CRUD ──────────────────────────────────────────────────────────────────
|
||||
protected Repository(Tx tx) {
|
||||
super(tx);
|
||||
}
|
||||
|
||||
public Optional<T> findById(ID id) {
|
||||
return tx(() -> doFindById(id));
|
||||
}
|
||||
|
||||
public List<T> findAll() {
|
||||
return tx(this::doFindAll);
|
||||
}
|
||||
|
||||
public List<T> findAll(int page, int size) {
|
||||
return tx(() -> doFindAll(page, size));
|
||||
}
|
||||
|
||||
public List<T> findAll(Sort sort) {
|
||||
return tx(() -> doFindAll(sort));
|
||||
}
|
||||
|
||||
public List<T> findAll(int page, int size, Sort sort) {
|
||||
return tx(() -> doFindAll(page, size, sort));
|
||||
}
|
||||
|
||||
public Page<T> findPage(int page, int size) {
|
||||
return tx(() -> doFindPage(page, size));
|
||||
}
|
||||
|
||||
public Page<T> findPage(int page, int size, Sort sort) {
|
||||
return tx(() -> doFindPage(page, size, sort));
|
||||
}
|
||||
|
||||
public T save(T entity) {
|
||||
return tx(() -> doSave(entity));
|
||||
}
|
||||
|
||||
public List<T> saveAll(Iterable<T> entities) {
|
||||
return tx(() -> {
|
||||
List<T> saved = new ArrayList<>();
|
||||
for (T e : entities) saved.add(doSave(e));
|
||||
return saved;
|
||||
});
|
||||
}
|
||||
|
||||
public T update(T entity) {
|
||||
return tx(() -> doUpdate(entity));
|
||||
}
|
||||
|
||||
public void delete(T entity) {
|
||||
tx(() -> { doDelete(entity); return null; });
|
||||
}
|
||||
|
||||
public void deleteById(ID id) {
|
||||
tx(() -> { doDeleteById(id); return null; });
|
||||
}
|
||||
|
||||
public void deleteAll(Iterable<T> entities) {
|
||||
tx(() -> { entities.forEach(this::doDelete); return null; });
|
||||
return roQuery(() -> doFindById(id));
|
||||
}
|
||||
|
||||
public boolean existsById(ID id) {
|
||||
return tx(() -> doExistsById(id));
|
||||
return roQuery(() -> doExistsById(id));
|
||||
}
|
||||
|
||||
public long count() {
|
||||
return tx(this::doCount);
|
||||
return roQuery(this::doCount);
|
||||
}
|
||||
|
||||
// ── Auto-wrap helper ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ensures the work runs inside a transaction.
|
||||
* If one is already active (caller annotated @Transactional or inside Tx.run)
|
||||
* it joins it — no new connection opened.
|
||||
* If none is active it opens one, commits, and closes it transparently.
|
||||
*/
|
||||
protected final <R> R tx(Tx.TxCallable<R> work) {
|
||||
return Tx.call(required, work);
|
||||
public List<T> findAll() {
|
||||
return findAll(Query.all());
|
||||
}
|
||||
|
||||
// ── Abstract — implemented by HibernateRepository / JdbcRepository ────────
|
||||
public List<T> findAll(Spec<T> spec) {
|
||||
return findAll(Query.<T>all().where(spec));
|
||||
}
|
||||
|
||||
public List<T> findAll(Query<T> query) {
|
||||
return roQuery(() -> doFind(query));
|
||||
}
|
||||
|
||||
public Page<T> findPage(Query<T> query) {
|
||||
return roQuery(() -> doFindPage(query));
|
||||
}
|
||||
|
||||
public Optional<T> findOne(Spec<T> spec) {
|
||||
return roQuery(() -> doFindOne(spec));
|
||||
}
|
||||
|
||||
public T save(T entity) {
|
||||
return rwQuery(() -> doSave(entity));
|
||||
}
|
||||
|
||||
public T update(T entity) {
|
||||
return rwQuery(() -> doUpdate(entity));
|
||||
}
|
||||
|
||||
public List<T> saveAll(Iterable<T> entities) {
|
||||
return rwQuery(() -> doSaveAll(entities));
|
||||
}
|
||||
|
||||
public void delete(T entity) {
|
||||
rwQuery(() -> {
|
||||
doDelete(entity);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
public void deleteById(ID id) {
|
||||
rwQuery(() -> {
|
||||
doDeleteById(id);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
public int deleteAll(Spec<T> spec) {
|
||||
return rwQuery(() -> doDeleteAll(spec));
|
||||
}
|
||||
|
||||
public int updateAll(Spec<T> spec, T patch) {
|
||||
return rwQuery(() -> doUpdateAll(spec, patch));
|
||||
}
|
||||
|
||||
public List<T> findAll(int page, int size) {
|
||||
return findAll(Query.<T>all().page(page, size));
|
||||
}
|
||||
|
||||
public List<T> findAll(Sort sort) {
|
||||
return findAll(Query.<T>all().orderBy(sort));
|
||||
}
|
||||
|
||||
public List<T> findAll(int page, int size, Sort sort) {
|
||||
return findAll(Query.<T>all().orderBy(sort).page(page, size));
|
||||
}
|
||||
|
||||
public Page<T> findPage(int page, int size) {
|
||||
return findPage(Query.<T>all().page(page, size));
|
||||
}
|
||||
|
||||
public Page<T> findPage(int page, int size, Sort sort) {
|
||||
return findPage(Query.<T>all().orderBy(sort).page(page, size));
|
||||
}
|
||||
|
||||
public void deleteAll(Iterable<T> entities) {
|
||||
rwQuery(() -> {
|
||||
for (T entity : entities) {
|
||||
doDelete(entity);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
protected abstract Optional<T> doFindById(ID id);
|
||||
protected abstract List<T> doFindAll();
|
||||
protected abstract List<T> doFindAll(int page, int size);
|
||||
protected abstract List<T> doFindAll(Sort sort);
|
||||
protected abstract List<T> doFindAll(int page, int size, Sort sort);
|
||||
protected abstract Page<T> doFindPage(int page, int size);
|
||||
protected abstract Page<T> doFindPage(int page, int size, Sort sort);
|
||||
protected abstract T doSave(T entity);
|
||||
protected abstract T doUpdate(T entity);
|
||||
protected abstract void doDelete(T entity);
|
||||
protected abstract void doDeleteById(ID id);
|
||||
protected abstract boolean doExistsById(ID id);
|
||||
protected abstract long doCount();
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.relism.flash.ext.data.core;
|
||||
|
||||
public abstract class RepositorySupport<T, ID> {
|
||||
private final Tx tx;
|
||||
private final TxDefinition rw = TxDefinition.DEFAULTS
|
||||
.withPropagation(TransactionPropagation.REQUIRED);
|
||||
private final TxDefinition ro = rw.asReadOnly();
|
||||
|
||||
protected RepositorySupport(Tx tx) {
|
||||
this.tx = tx;
|
||||
}
|
||||
|
||||
protected final Tx tx() {
|
||||
return tx;
|
||||
}
|
||||
|
||||
protected final <R> R roQuery(Tx.TxCallable<R> work) {
|
||||
return tx.call(ro, work);
|
||||
}
|
||||
|
||||
protected final <R> R rwQuery(Tx.TxCallable<R> work) {
|
||||
return tx.call(rw, work);
|
||||
}
|
||||
}
|
||||
+5
@@ -31,6 +31,11 @@ public final class ResourceRegistry {
|
||||
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) {
|
||||
|
||||
+5
-1
@@ -7,6 +7,10 @@ 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))); }
|
||||
@@ -19,4 +23,4 @@ public record Sort(List<Column> columns) {
|
||||
next.add(new Column(column, asc));
|
||||
return new Sort(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package dev.relism.flash.ext.data.core;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface Spec<T> {
|
||||
String toFragment(SpecContext ctx);
|
||||
|
||||
default Spec<T> and(Spec<T> other) {
|
||||
return ctx -> "(" + this.toFragment(ctx) + " AND " + other.toFragment(ctx) + ")";
|
||||
}
|
||||
|
||||
default Spec<T> or(Spec<T> other) {
|
||||
return ctx -> "(" + this.toFragment(ctx) + " OR " + other.toFragment(ctx) + ")";
|
||||
}
|
||||
|
||||
default Spec<T> not() {
|
||||
return ctx -> "NOT (" + this.toFragment(ctx) + ")";
|
||||
}
|
||||
|
||||
static <T> Spec<T> all() {
|
||||
return ctx -> "1=1";
|
||||
}
|
||||
|
||||
static <T> Spec<T> none() {
|
||||
return ctx -> "1=0";
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package dev.relism.flash.ext.data.core;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public final class SpecBuilder<T> {
|
||||
private SpecBuilder() {}
|
||||
|
||||
public static <T, V> FieldSpec<T, V> field(String column) {
|
||||
return new FieldSpec<>(column);
|
||||
}
|
||||
|
||||
public static final class FieldSpec<T, V> {
|
||||
private final String column;
|
||||
|
||||
private FieldSpec(String column) {
|
||||
this.column = Objects.requireNonNull(column);
|
||||
}
|
||||
|
||||
public Spec<T> eq(V value) { return ctx -> column + " = " + ctx.bind(value); }
|
||||
public Spec<T> neq(V value) { return ctx -> column + " != " + ctx.bind(value); }
|
||||
public Spec<T> like(String pattern) { return ctx -> column + " like " + ctx.bind(pattern); }
|
||||
public Spec<T> isNull() { return ctx -> column + " is null"; }
|
||||
public Spec<T> isNotNull() { return ctx -> column + " is not null"; }
|
||||
|
||||
public Spec<T> in(Collection<V> values) {
|
||||
return ctx -> column + " in (" + values.stream().map(ctx::bind).collect(Collectors.joining(", ")) + ")";
|
||||
}
|
||||
|
||||
public <C extends Comparable<C>> Spec<T> gt(C value) { return ctx -> column + " > " + ctx.bind(value); }
|
||||
public <C extends Comparable<C>> Spec<T> lt(C value) { return ctx -> column + " < " + ctx.bind(value); }
|
||||
public <C extends Comparable<C>> Spec<T> between(C lo, C hi) {
|
||||
return ctx -> column + " between " + ctx.bind(lo) + " and " + ctx.bind(hi);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package dev.relism.flash.ext.data.core;
|
||||
|
||||
public interface SpecContext {
|
||||
String bind(Object value);
|
||||
}
|
||||
+1
@@ -3,6 +3,7 @@ package dev.relism.flash.ext.data.core;
|
||||
public enum TransactionPropagation {
|
||||
REQUIRED,
|
||||
REQUIRES_NEW,
|
||||
SUPPORTS,
|
||||
NOT_SUPPORTED,
|
||||
MANDATORY
|
||||
}
|
||||
|
||||
+38
-31
@@ -2,83 +2,78 @@ 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 static volatile TxManager manager;
|
||||
private final TxManager manager;
|
||||
|
||||
private Tx() {}
|
||||
|
||||
public static void init(TxManager txManager) {
|
||||
if (manager != null) {
|
||||
throw new IllegalStateException("TxManager already initialized");
|
||||
}
|
||||
manager = txManager;
|
||||
public Tx(TxManager txManager) {
|
||||
this.manager = Objects.requireNonNull(txManager);
|
||||
}
|
||||
|
||||
public static void run(TxRunnable work) {
|
||||
public void run(TxRunnable work) {
|
||||
run(TxDefinition.DEFAULTS, work);
|
||||
}
|
||||
|
||||
public static void run(TxDefinition definition, TxRunnable work) {
|
||||
public void run(TxDefinition definition, TxRunnable work) {
|
||||
call(definition, () -> {
|
||||
work.run();
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
public static <T> T call(TxCallable<T> work) {
|
||||
public <T> T call(TxCallable<T> work) {
|
||||
return call(TxDefinition.DEFAULTS, work);
|
||||
}
|
||||
|
||||
public static <T> T call(TxDefinition definition, TxCallable<T> work) {
|
||||
TxStatus status = manager().begin(definition);
|
||||
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);
|
||||
manager.rollback(status);
|
||||
} else {
|
||||
manager().commit(status);
|
||||
manager.commit(status);
|
||||
}
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
manager().rollback(status);
|
||||
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 static boolean isActive() {
|
||||
public boolean isActive() {
|
||||
return !STATUS_STACK.get().isEmpty();
|
||||
}
|
||||
|
||||
public static void setRollbackOnly() {
|
||||
public void setRollbackOnly() {
|
||||
currentStatus().markRollbackOnly();
|
||||
}
|
||||
|
||||
public static <R> R resource(Class<R> type) {
|
||||
public <R> R resource(Class<R> type) {
|
||||
return currentStatus().resource(type);
|
||||
}
|
||||
|
||||
public static TxDefinition requiresNew() {
|
||||
public TxDefinition requiresNew() {
|
||||
return TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW);
|
||||
}
|
||||
|
||||
public static TxDefinition readOnly() {
|
||||
public TxDefinition readOnly() {
|
||||
return TxDefinition.DEFAULTS.asReadOnly();
|
||||
}
|
||||
|
||||
private static TxManager manager() {
|
||||
if (manager == null) {
|
||||
throw new IllegalStateException("No TxManager installed");
|
||||
}
|
||||
return manager;
|
||||
}
|
||||
|
||||
private static TxStatus currentStatus() {
|
||||
private TxStatus currentStatus() {
|
||||
TxStatus status = STATUS_STACK.get().peek();
|
||||
if (status == null) {
|
||||
throw new IllegalStateException("No active transaction");
|
||||
@@ -86,17 +81,29 @@ public final class Tx {
|
||||
return status;
|
||||
}
|
||||
|
||||
private static void pushStatus(TxStatus status) {
|
||||
private void pushStatus(TxStatus status) {
|
||||
STATUS_STACK.get().push(status);
|
||||
}
|
||||
|
||||
private static void popStatus() {
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# flash-ext-data-hibernate
|
||||
|
||||
Backend Hibernate per `flash-ext-data-core`.
|
||||
|
||||
## Scopo
|
||||
|
||||
Questo modulo implementa `TxManager` sopra `SessionFactory` e fornisce una base repository Hibernate-centric.
|
||||
|
||||
## Come si usa
|
||||
|
||||
### 1. Creare il manager
|
||||
|
||||
```java
|
||||
SessionFactory sessionFactory = ...;
|
||||
HibernateTxManager txManager = new HibernateTxManager(sessionFactory);
|
||||
DataExtension extension = new DataExtension(txManager);
|
||||
```
|
||||
|
||||
### 2. Installare l’estensione in Flash
|
||||
|
||||
L’estensione registra `Tx` e `TxManager` nel `FlashContext`.
|
||||
Le handler class-based annotate con `@Transactional` vengono wrappate automaticamente.
|
||||
|
||||
### 3. Definire una repository
|
||||
|
||||
```java
|
||||
public final class UserRepository extends HibernateRepository<User, Long> {
|
||||
public UserRepository(Tx tx) {
|
||||
super(tx, User.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Con il nuovo modello query/spec puoi esporre campi riusabili come costanti:
|
||||
|
||||
```java
|
||||
public final class UserRepository extends HibernateRepository<User, Long> {
|
||||
public static final SpecBuilder.FieldSpec<User, String> EMAIL = SpecBuilder.field("u.email");
|
||||
public static final SpecBuilder.FieldSpec<User, Boolean> ACTIVE = SpecBuilder.field("u.active");
|
||||
|
||||
public UserRepository(Tx tx) {
|
||||
super(tx, User.class);
|
||||
}
|
||||
|
||||
public Optional<User> findByEmail(String email) {
|
||||
return findOne(EMAIL.eq(email));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Le query domain-specific possono usare gli helper della base class:
|
||||
|
||||
```java
|
||||
public List<User> findByEmailDomain(String domain) {
|
||||
return findMany("from User u where u.email like :email", q ->
|
||||
q.setParameter("email", "%@" + domain)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Come funziona sotto
|
||||
|
||||
- La tx corrente è rappresentata da `HibernateTxStatus`.
|
||||
- La risorsa esposta al core è una `Session`.
|
||||
- `Tx.resource(Session.class)` recupera la `Session` dal contesto corrente.
|
||||
- `REQUIRES_NEW` sospende lo status attivo e apre una nuova `Session`.
|
||||
- `NOT_SUPPORTED` sospende la tx attiva e continua senza sessione bindata.
|
||||
|
||||
## Repository base
|
||||
|
||||
`HibernateRepository` fornisce:
|
||||
|
||||
- `findById`, `findAll`, `findPage`, `findOne`
|
||||
- `save`, `update`, `delete`, `saveAll`
|
||||
- bulk `deleteAll(Spec<T>)` e `updateAll(Spec<T>, T)`
|
||||
- helper HQL: `hql(...)`, `hqlMutate(...)`
|
||||
|
||||
Le classi concrete devono solo implementare query di dominio, non il plumbing transazionale.
|
||||
|
||||
## Semantica transazionale
|
||||
|
||||
- `REQUIRED`: join o apertura nuova tx.
|
||||
- `REQUIRES_NEW`: sospensione del contesto corrente.
|
||||
- `SUPPORTS`: join se c’è tx, altrimenti no-op.
|
||||
- `NOT_SUPPORTED`: sospende e prosegue senza tx.
|
||||
- `MANDATORY`: fallisce se non c’è tx.
|
||||
|
||||
## Note
|
||||
|
||||
- `Session` viene chiusa a fine tx nuova.
|
||||
- Le synchronizations vengono eseguite al commit/rollback.
|
||||
- Il backend è pensato per essere usato tramite la base class, non direttamente.
|
||||
@@ -7,7 +7,7 @@
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-data-hibernate</artifactId>
|
||||
|
||||
+77
-87
@@ -1,79 +1,74 @@
|
||||
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 jakarta.persistence.TypedQuery;
|
||||
import java.io.Serializable;
|
||||
import java.util.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Hibernate-backed repository base.
|
||||
* Never extend this directly — extend {@link Repository} from the core.
|
||||
* This class is instantiated internally by flash-ext-data-hibernate.
|
||||
*/
|
||||
public abstract class HibernateRepository<T, ID extends Serializable>
|
||||
extends Repository<T, ID> {
|
||||
public abstract class HibernateRepository<T, ID extends Serializable> extends Repository<T, ID> {
|
||||
|
||||
private final Class<T> type;
|
||||
|
||||
protected HibernateRepository(Class<T> type) {
|
||||
protected HibernateRepository(Tx tx, Class<T> type) {
|
||||
super(tx);
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
// ── Session — always safe, tx() wrapper guarantees active transaction ─────
|
||||
|
||||
protected Session session() {
|
||||
return Tx.resource(Session.class);
|
||||
return tx().resource(Session.class);
|
||||
}
|
||||
|
||||
// ── Repository abstract impl ──────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
protected Optional<T> doFindById(ID id) {
|
||||
return Optional.ofNullable(session().get(type, id));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<T> doFindAll() {
|
||||
return hql("from " + type.getSimpleName()).getResultList();
|
||||
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 List<T> doFindAll(int page, int size) {
|
||||
return hql("from " + type.getSimpleName())
|
||||
.setFirstResult(page * size)
|
||||
.setMaxResults(size)
|
||||
.getResultList();
|
||||
protected Optional<T> doFindOne(Spec<T> spec) {
|
||||
return doFind(Query.<T>all().where(spec).page(0, 1)).stream().findFirst();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<T> doFindAll(Sort sort) {
|
||||
return hql("from " + type.getSimpleName() + orderClause(sort))
|
||||
.getResultList();
|
||||
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 List<T> doFindAll(int page, int size, Sort sort) {
|
||||
return hql("from " + type.getSimpleName() + orderClause(sort))
|
||||
.setFirstResult(page * size)
|
||||
.setMaxResults(size)
|
||||
.getResultList();
|
||||
protected boolean doExistsById(ID id) {
|
||||
return doFindById(id).isPresent();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Page<T> doFindPage(int page, int size) {
|
||||
long total = doCount();
|
||||
return new Page<>(doFindAll(page, size), page, size, total);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Page<T> doFindPage(int page, int size, Sort sort) {
|
||||
long total = doCount();
|
||||
return new Page<>(doFindAll(page, size, sort), page, size, total);
|
||||
protected long doCount() {
|
||||
return countWhere(Spec.all());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -82,6 +77,22 @@ public abstract class HibernateRepository<T, ID extends Serializable>
|
||||
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);
|
||||
@@ -99,66 +110,37 @@ public abstract class HibernateRepository<T, ID extends Serializable>
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean doExistsById(ID id) {
|
||||
return doFindById(id).isPresent();
|
||||
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 long doCount() {
|
||||
return session()
|
||||
.createQuery("select count(*) from " + type.getSimpleName(), Long.class)
|
||||
.uniqueResultOptional()
|
||||
.orElse(0L);
|
||||
protected int doUpdateAll(Spec<T> spec, T patch) {
|
||||
throw new UnsupportedOperationException("Override doUpdateAll() for bulk UPDATE support");
|
||||
}
|
||||
|
||||
// ── Query helpers — usabili nelle sottoclassi domain ─────────────────────
|
||||
|
||||
protected TypedQuery<T> hql(String hql) {
|
||||
return session().createQuery(hql, type);
|
||||
}
|
||||
|
||||
protected <R> TypedQuery<R> hql(String hql, Class<R> resultType) {
|
||||
return session().createQuery(hql, resultType);
|
||||
}
|
||||
|
||||
protected Optional<T> findOne(String hql, Consumer<TypedQuery<T>> params) {
|
||||
TypedQuery<T> q = hql(hql);
|
||||
params.accept(q);
|
||||
return q.getResultStream().findFirst();
|
||||
}
|
||||
|
||||
protected List<T> findMany(String hql, Consumer<TypedQuery<T>> params) {
|
||||
return tx(() -> {
|
||||
TypedQuery<T> q = hql(hql);
|
||||
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 List<T> findMany(String hql, Consumer<TypedQuery<T>> params,
|
||||
int page, int size) {
|
||||
return tx(() -> {
|
||||
TypedQuery<T> q = hql(hql);
|
||||
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.setFirstResult(page * size).setMaxResults(size).getResultList();
|
||||
return q.getResultList();
|
||||
});
|
||||
}
|
||||
|
||||
protected Page<T> findManyPaged(String hql, String countHql,
|
||||
Consumer<TypedQuery<T>> params,
|
||||
int page, int size) {
|
||||
return tx(() -> {
|
||||
long total = session()
|
||||
.createQuery(countHql, Long.class)
|
||||
.uniqueResultOptional()
|
||||
.orElse(0L);
|
||||
List<T> content = findMany(hql, params, page, size);
|
||||
return new Page<>(content, page, size, total);
|
||||
});
|
||||
}
|
||||
|
||||
protected int execute(String hql, Consumer<MutationQuery> params) {
|
||||
return tx(() -> {
|
||||
protected int hqlMutate(String hql, Consumer<MutationQuery> params) {
|
||||
return rwQuery(() -> {
|
||||
MutationQuery q = session().createMutationQuery(hql);
|
||||
params.accept(q);
|
||||
return q.executeUpdate();
|
||||
@@ -169,9 +151,17 @@ public abstract class HibernateRepository<T, ID extends Serializable>
|
||||
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 " order by " + sort.columns().stream()
|
||||
return sort.columns().stream()
|
||||
.map(c -> c.column() + (c.asc() ? " ASC" : " DESC"))
|
||||
.collect(Collectors.joining(", "));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package dev.relism.flash.ext.data.hibernate;
|
||||
|
||||
import dev.relism.flash.ext.data.core.SpecContext;
|
||||
import jakarta.persistence.TypedQuery;
|
||||
import org.hibernate.query.MutationQuery;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
final class HibernateSpecContext implements SpecContext {
|
||||
private final Map<String, Object> params = new LinkedHashMap<>();
|
||||
private int counter;
|
||||
|
||||
@Override
|
||||
public String bind(Object value) {
|
||||
String name = "p" + (++counter);
|
||||
params.put(name, value);
|
||||
return ":" + name;
|
||||
}
|
||||
|
||||
void applyParameters(TypedQuery<?> query) {
|
||||
params.forEach(query::setParameter);
|
||||
}
|
||||
|
||||
void applyParameters(MutationQuery query) {
|
||||
params.forEach(query::setParameter);
|
||||
}
|
||||
}
|
||||
+88
-19
@@ -8,6 +8,7 @@ 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;
|
||||
|
||||
@@ -22,35 +23,56 @@ public class HibernateTxManager implements TxManager {
|
||||
? 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 -> throw new UnsupportedOperationException("NOT_SUPPORTED is not implemented");
|
||||
case NOT_SUPPORTED -> {
|
||||
HibernateTxStatus suspended = suspendIfNeeded();
|
||||
yield noOp(definition, suspended);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private TxStatus beginNew(TxDefinition definition) {
|
||||
HibernateTxStatus suspended = ResourceRegistry.getOrNull(HIBERNATE_STATUS_KEY, HibernateTxStatus.class);
|
||||
if (suspended != null) {
|
||||
ResourceRegistry.unbind(HIBERNATE_STATUS_KEY);
|
||||
}
|
||||
return beginNew(definition, suspendIfNeeded());
|
||||
}
|
||||
|
||||
private TxStatus beginNew(TxDefinition definition, HibernateTxStatus suspended) {
|
||||
Session s = sf.openSession();
|
||||
s.beginTransaction();
|
||||
if (definition.readOnly()) s.setDefaultReadOnly(true);
|
||||
if (definition.isolation() != TransactionIsolation.DEFAULT) {
|
||||
s.doWork(connection -> connection.setTransactionIsolation(definition.isolation().level()));
|
||||
boolean bound = false;
|
||||
try {
|
||||
s.beginTransaction();
|
||||
if (definition.readOnly()) s.setDefaultReadOnly(true);
|
||||
if (definition.isolation() != TransactionIsolation.DEFAULT) {
|
||||
s.doWork(connection -> connection.setTransactionIsolation(definition.isolation().level()));
|
||||
}
|
||||
HibernateTxStatus status = new HibernateTxStatus(
|
||||
s,
|
||||
true,
|
||||
definition.readOnly(),
|
||||
suspended,
|
||||
new HibernateTxStatus.RollbackMarker()
|
||||
);
|
||||
ResourceRegistry.bind(HIBERNATE_STATUS_KEY, status);
|
||||
bound = true;
|
||||
return status;
|
||||
} catch (RuntimeException e) {
|
||||
silentClose(s);
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
silentClose(s);
|
||||
throw new TxException(e);
|
||||
} finally {
|
||||
if (!bound && suspended != null) {
|
||||
ResourceRegistry.unbind(HIBERNATE_SUSPENDED_KEY);
|
||||
ResourceRegistry.bind(HIBERNATE_STATUS_KEY, suspended);
|
||||
}
|
||||
}
|
||||
HibernateTxStatus status = new HibernateTxStatus(
|
||||
s,
|
||||
true,
|
||||
definition.readOnly(),
|
||||
suspended,
|
||||
new HibernateTxStatus.RollbackMarker()
|
||||
);
|
||||
ResourceRegistry.bind(HIBERNATE_STATUS_KEY, status);
|
||||
return status;
|
||||
}
|
||||
|
||||
private TxStatus joinExisting(TxDefinition definition) {
|
||||
@@ -67,10 +89,29 @@ public class HibernateTxManager implements TxManager {
|
||||
);
|
||||
}
|
||||
|
||||
private TxStatus noOp(TxDefinition definition) {
|
||||
return noOp(definition, null);
|
||||
}
|
||||
|
||||
private TxStatus noOp(TxDefinition definition, HibernateTxStatus suspended) {
|
||||
return new HibernateTxStatus(null, false, definition.readOnly(), suspended, new HibernateTxStatus.RollbackMarker());
|
||||
}
|
||||
|
||||
private HibernateTxStatus suspendIfNeeded() {
|
||||
HibernateTxStatus suspended = ResourceRegistry.getOrNull(HIBERNATE_STATUS_KEY, HibernateTxStatus.class);
|
||||
if (suspended != null) {
|
||||
ResourceRegistry.unbind(HIBERNATE_STATUS_KEY);
|
||||
ResourceRegistry.bind(HIBERNATE_SUSPENDED_KEY, suspended);
|
||||
}
|
||||
return suspended;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void commit(TxStatus status) {
|
||||
HibernateTxStatus s = (HibernateTxStatus) status;
|
||||
if (!s.isNewTransaction()) {
|
||||
resumeIfNeeded(s);
|
||||
cleanupIfIdle();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -83,6 +124,7 @@ public class HibernateTxManager implements TxManager {
|
||||
}
|
||||
} finally {
|
||||
cleanupAndResume(s);
|
||||
cleanupIfIdle();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +133,8 @@ public class HibernateTxManager implements TxManager {
|
||||
HibernateTxStatus s = (HibernateTxStatus) status;
|
||||
if (!s.isNewTransaction()) {
|
||||
s.markRollbackOnly();
|
||||
resumeIfNeeded(s);
|
||||
cleanupIfIdle();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -100,15 +144,40 @@ public class HibernateTxManager implements TxManager {
|
||||
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK);
|
||||
} finally {
|
||||
cleanupAndResume(s);
|
||||
cleanupIfIdle();
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanupAndResume(HibernateTxStatus status) {
|
||||
ResourceRegistry.unbind(HIBERNATE_STATUS_KEY);
|
||||
status.session().close();
|
||||
silentClose(status.session());
|
||||
resumeIfNeeded(status);
|
||||
}
|
||||
|
||||
private void cleanupIfIdle() {
|
||||
if (!ResourceRegistry.isBound(HIBERNATE_STATUS_KEY) && !ResourceRegistry.isBound(HIBERNATE_SUSPENDED_KEY)) {
|
||||
ResourceRegistry.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
private void resumeIfNeeded(HibernateTxStatus status) {
|
||||
HibernateTxStatus suspended = status.suspended();
|
||||
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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -35,6 +35,9 @@ class HibernateTxStatus implements TxStatus {
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# flash-ext-data-jdbc
|
||||
|
||||
Backend JDBC per `flash-ext-data-core`.
|
||||
|
||||
## Scopo
|
||||
|
||||
Questo modulo implementa `TxManager` sopra `DataSource` e fornisce una base repository SQL raw.
|
||||
|
||||
## Come si usa
|
||||
|
||||
### 1. Creare il manager
|
||||
|
||||
```java
|
||||
DataSource dataSource = ...;
|
||||
JdbcTxManager txManager = new JdbcTxManager(dataSource);
|
||||
DataExtension extension = new DataExtension(txManager);
|
||||
```
|
||||
|
||||
### 2. Installare l’estensione in Flash
|
||||
|
||||
Come per Hibernate, `DataExtension` registra `Tx` nel `FlashContext` e abilita `@Transactional` sugli handler class-based.
|
||||
|
||||
### 3. Definire una repository
|
||||
|
||||
```java
|
||||
public final class UserRepository extends JdbcRepository<User, Long> {
|
||||
public UserRepository(Tx tx) {
|
||||
super(tx, "users", "id");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected User mapRow(ResultSet rs) throws SQLException {
|
||||
return new User(rs.getLong("id"), rs.getString("name"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Anche qui puoi esporre `Spec` riusabili e comporre query dal service layer:
|
||||
|
||||
```java
|
||||
public final class UserRepository extends JdbcRepository<User, Long> {
|
||||
public static final SpecBuilder.FieldSpec<User, String> EMAIL = SpecBuilder.field("email");
|
||||
|
||||
public UserRepository(Tx tx) {
|
||||
super(tx, "users", "id");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Per il salvataggio e l’update devi fornire il binding esplicito:
|
||||
|
||||
```java
|
||||
@Override
|
||||
protected String insertSql() {
|
||||
return "insert into users(name) values(?)";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void bindInsert(PreparedStatement ps, User entity) throws SQLException {
|
||||
ps.setString(1, entity.name());
|
||||
}
|
||||
```
|
||||
|
||||
## Come funziona sotto
|
||||
|
||||
- La tx corrente espone una `Connection`.
|
||||
- `Tx.resource(Connection.class)` recupera la connessione bindata al thread.
|
||||
- `REQUIRES_NEW` sospende la connessione attiva e ne apre una nuova.
|
||||
- `NOT_SUPPORTED` sospende il contesto e prosegue senza tx.
|
||||
|
||||
## Repository base
|
||||
|
||||
`JdbcRepository` fornisce:
|
||||
|
||||
- query `select` con `queryOne`, `queryMany`
|
||||
- mutation con `mutate`
|
||||
- persistenza con `doSave`, `doUpdate`
|
||||
- paging con `doFindPage`
|
||||
- bulk `deleteAll(Spec<T>)`
|
||||
- helper raw `queryOne(...)`, `queryMany(...)`, `mutate(...)`
|
||||
|
||||
Le repository concrete devono solo tradurre tra `ResultSet` e dominio.
|
||||
|
||||
## Semantica transazionale
|
||||
|
||||
- `REQUIRED`: join o apertura nuova tx.
|
||||
- `REQUIRES_NEW`: sospensione del contesto corrente.
|
||||
- `SUPPORTS`: join se c’è tx, altrimenti no-op.
|
||||
- `NOT_SUPPORTED`: sospende e prosegue senza tx.
|
||||
- `MANDATORY`: fallisce se non c’è tx.
|
||||
|
||||
## Note
|
||||
|
||||
- La `Connection` viene chiusa a fine tx nuova.
|
||||
- Le synchronizations vengono eseguite al commit/rollback.
|
||||
- Se una repository usa `doDelete(T)`, il comportamento predefinito è non supportato: usare `deleteById` o override specifico.
|
||||
@@ -7,7 +7,7 @@
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-data-jdbc</artifactId>
|
||||
|
||||
+95
-63
@@ -3,87 +3,99 @@ package dev.relism.flash.ext.data.jdbc;
|
||||
import dev.relism.flash.ext.data.core.*;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
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(String table, String idColumn) {
|
||||
this.table = table;
|
||||
protected JdbcRepository(Tx tx, String table, String idColumn) {
|
||||
super(tx);
|
||||
this.table = table;
|
||||
this.idColumn = idColumn;
|
||||
}
|
||||
|
||||
protected Connection connection() {
|
||||
return Tx.resource(Connection.class);
|
||||
return tx().resource(Connection.class);
|
||||
}
|
||||
|
||||
// ── Subclass contract ─────────────────────────────────────────────────────
|
||||
|
||||
protected abstract T mapRow(ResultSet rs) throws SQLException;
|
||||
protected abstract void bindInsert(PreparedStatement ps, T entity) throws SQLException;
|
||||
protected abstract void bindUpdate(PreparedStatement ps, T entity) throws SQLException;
|
||||
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();
|
||||
|
||||
// ── Repository abstract impl ──────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
protected Optional<T> doFindById(ID id) {
|
||||
return queryOne("select * from " + table + " where " + idColumn + " = ?",
|
||||
ps -> ps.setObject(1, id));
|
||||
return queryOne("select * from " + table + " where " + idColumn + " = ?", ps -> ps.setObject(1, id));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<T> doFindAll() {
|
||||
return queryMany("select * from " + table, ps -> {});
|
||||
}
|
||||
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 ?" : "";
|
||||
|
||||
@Override
|
||||
protected List<T> doFindAll(int page, int size) {
|
||||
return queryMany("select * from " + table + " limit ? offset ?", ps -> {
|
||||
ps.setInt(1, size);
|
||||
ps.setInt(2, page * size);
|
||||
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 List<T> doFindAll(Sort sort) {
|
||||
return queryMany("select * from " + table + orderClause(sort), ps -> {});
|
||||
protected Optional<T> doFindOne(Spec<T> spec) {
|
||||
return doFind(Query.<T>all().where(spec).page(0, 1)).stream().findFirst();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<T> doFindAll(int page, int size, Sort sort) {
|
||||
return queryMany("select * from " + table + orderClause(sort) + " limit ? offset ?",
|
||||
ps -> {
|
||||
ps.setInt(1, size);
|
||||
ps.setInt(2, page * size);
|
||||
});
|
||||
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 Page<T> doFindPage(int page, int size) {
|
||||
long total = doCount();
|
||||
return new Page<>(doFindAll(page, size), page, size, total);
|
||||
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 Page<T> doFindPage(int page, int size, Sort sort) {
|
||||
long total = doCount();
|
||||
return new Page<>(doFindAll(page, size, sort), page, size, total);
|
||||
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)) {
|
||||
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); }
|
||||
} 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
|
||||
@@ -92,7 +104,9 @@ public abstract class JdbcRepository<T, ID> extends Repository<T, ID> {
|
||||
bindUpdate(ps, entity);
|
||||
ps.executeUpdate();
|
||||
return entity;
|
||||
} catch (SQLException e) { throw new TxException(e); }
|
||||
} catch (SQLException e) {
|
||||
throw new TxException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -102,38 +116,35 @@ public abstract class JdbcRepository<T, ID> extends Repository<T, ID> {
|
||||
|
||||
@Override
|
||||
protected void doDeleteById(ID id) {
|
||||
mutate("delete from " + table + " where " + idColumn + " = ?",
|
||||
ps -> ps.setObject(1, id));
|
||||
mutate("delete from " + table + " where " + idColumn + " = ?", ps -> ps.setObject(1, id));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean doExistsById(ID id) {
|
||||
return queryOne("select 1 from " + table + " where " + idColumn + " = ?",
|
||||
ps -> ps.setObject(1, id),
|
||||
rs -> rs.getInt(1)).isPresent();
|
||||
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 long doCount() {
|
||||
return queryOne("select count(*) from " + table, ps -> {},
|
||||
rs -> rs.getLong(1)).orElse(0L);
|
||||
protected int doUpdateAll(Spec<T> spec, T patch) {
|
||||
throw new UnsupportedOperationException("Override doUpdateAll() for bulk UPDATE support");
|
||||
}
|
||||
|
||||
// ── Query helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
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) {
|
||||
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); }
|
||||
} catch (SQLException e) {
|
||||
throw new TxException(e);
|
||||
}
|
||||
}
|
||||
|
||||
protected List<T> queryMany(String sql, SqlBinder params) {
|
||||
@@ -144,26 +155,47 @@ public abstract class JdbcRepository<T, ID> extends Repository<T, ID> {
|
||||
while (rs.next()) results.add(mapRow(rs));
|
||||
return results;
|
||||
}
|
||||
} catch (SQLException e) { throw new TxException(e); }
|
||||
} 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); }
|
||||
} catch (SQLException e) {
|
||||
throw new TxException(e);
|
||||
}
|
||||
}
|
||||
|
||||
protected void applyGeneratedKey(PreparedStatement ps, T entity) throws SQLException {
|
||||
// override when entity has a generated PK
|
||||
}
|
||||
|
||||
private String orderClause(Sort sort) {
|
||||
return " order by " + sort.columns().stream()
|
||||
.map(c -> c.column() + (c.asc() ? " ASC" : " DESC"))
|
||||
.collect(Collectors.joining(", "));
|
||||
protected Class<T> entityType() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@FunctionalInterface public interface SqlBinder { void bind(PreparedStatement ps) throws SQLException; }
|
||||
@FunctionalInterface public interface SqlMapper<R> { R map(ResultSet rs) throws SQLException; }
|
||||
}
|
||||
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
@@ -0,0 +1,28 @@
|
||||
package dev.relism.flash.ext.data.jdbc;
|
||||
|
||||
import dev.relism.flash.ext.data.core.SpecContext;
|
||||
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
final class JdbcSpecContext implements SpecContext {
|
||||
private final List<Object> params = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public String bind(Object value) {
|
||||
params.add(value);
|
||||
return "?";
|
||||
}
|
||||
|
||||
void applyParameters(PreparedStatement ps) throws SQLException {
|
||||
for (int i = 0; i < params.size(); i++) {
|
||||
ps.setObject(i + 1, params.get(i));
|
||||
}
|
||||
}
|
||||
|
||||
int size() {
|
||||
return params.size();
|
||||
}
|
||||
}
|
||||
+69
-7
@@ -9,6 +9,7 @@ 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;
|
||||
|
||||
@@ -23,22 +24,27 @@ public class JdbcTxManager implements TxManager {
|
||||
? 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 -> throw new UnsupportedOperationException("NOT_SUPPORTED is not implemented");
|
||||
case NOT_SUPPORTED -> {
|
||||
JdbcTxStatus suspended = suspendIfNeeded();
|
||||
yield noOp(definition, suspended);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private TxStatus beginNew(TxDefinition definition) {
|
||||
Connection conn = null;
|
||||
JdbcTxStatus suspended = suspendIfNeeded();
|
||||
boolean bound = false;
|
||||
try {
|
||||
JdbcTxStatus suspended = ResourceRegistry.getOrNull(JDBC_STATUS_KEY, JdbcTxStatus.class);
|
||||
if (suspended != null) {
|
||||
ResourceRegistry.unbind(JDBC_STATUS_KEY);
|
||||
}
|
||||
Connection conn = ds.getConnection();
|
||||
conn = ds.getConnection();
|
||||
conn.setAutoCommit(false);
|
||||
if (definition.readOnly()) conn.setReadOnly(true);
|
||||
if (definition.isolation() != TransactionIsolation.DEFAULT) {
|
||||
@@ -52,9 +58,16 @@ public class JdbcTxManager implements TxManager {
|
||||
new JdbcTxStatus.RollbackMarker()
|
||||
);
|
||||
ResourceRegistry.bind(JDBC_STATUS_KEY, status);
|
||||
bound = true;
|
||||
return status;
|
||||
} catch (SQLException e) {
|
||||
silentClose(conn);
|
||||
throw new TxException(e);
|
||||
} finally {
|
||||
if (!bound && suspended != null) {
|
||||
ResourceRegistry.unbind(JDBC_SUSPENDED_KEY);
|
||||
ResourceRegistry.bind(JDBC_STATUS_KEY, suspended);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,10 +85,29 @@ public class JdbcTxManager implements TxManager {
|
||||
);
|
||||
}
|
||||
|
||||
private TxStatus noOp(TxDefinition definition) {
|
||||
return noOp(definition, null);
|
||||
}
|
||||
|
||||
private TxStatus noOp(TxDefinition definition, JdbcTxStatus suspended) {
|
||||
return new JdbcTxStatus(null, false, definition.readOnly(), suspended, new JdbcTxStatus.RollbackMarker());
|
||||
}
|
||||
|
||||
private JdbcTxStatus suspendIfNeeded() {
|
||||
JdbcTxStatus suspended = ResourceRegistry.getOrNull(JDBC_STATUS_KEY, JdbcTxStatus.class);
|
||||
if (suspended != null) {
|
||||
ResourceRegistry.unbind(JDBC_STATUS_KEY);
|
||||
ResourceRegistry.bind(JDBC_SUSPENDED_KEY, suspended);
|
||||
}
|
||||
return suspended;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void commit(TxStatus status) {
|
||||
JdbcTxStatus s = (JdbcTxStatus) status;
|
||||
if (!s.isNewTransaction()) {
|
||||
resumeIfNeeded(s);
|
||||
cleanupIfIdle();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -90,6 +122,7 @@ public class JdbcTxManager implements TxManager {
|
||||
throw new TxException(e);
|
||||
} finally {
|
||||
cleanupAndResume(s);
|
||||
cleanupIfIdle();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +131,8 @@ public class JdbcTxManager implements TxManager {
|
||||
JdbcTxStatus s = (JdbcTxStatus) status;
|
||||
if (!s.isNewTransaction()) {
|
||||
s.markRollbackOnly();
|
||||
resumeIfNeeded(s);
|
||||
cleanupIfIdle();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -107,18 +142,45 @@ public class JdbcTxManager implements TxManager {
|
||||
throw new TxException(e);
|
||||
} finally {
|
||||
cleanupAndResume(s);
|
||||
cleanupIfIdle();
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanupAndResume(JdbcTxStatus status) {
|
||||
ResourceRegistry.unbind(JDBC_STATUS_KEY);
|
||||
try {
|
||||
status.connection().close();
|
||||
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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -37,6 +37,9 @@ class JdbcTxStatus implements TxStatus {
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-jackson</artifactId>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-limiter</artifactId>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-oidc</artifactId>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-openapi</artifactId>
|
||||
|
||||
+1
-1
@@ -118,7 +118,7 @@ public class OpenApiExtension implements FlashExtension {
|
||||
"});\n" +
|
||||
"</script>\n" +
|
||||
"</body>\n" +
|
||||
"</html>";
|
||||
"</html>";
|
||||
}
|
||||
|
||||
private static void addOperationFromEvent(OpenApiBuilder builder, RouteEvent event) {
|
||||
|
||||
+6
@@ -12,6 +12,7 @@ import dev.relism.flash.models.RequestHandler;
|
||||
import dev.relism.flash.models.Response;
|
||||
import dev.relism.flash.routing.GET;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
import dev.relism.flash.websocket.WebSocketEndpoint;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -165,6 +166,11 @@ class OpenApiExtensionTest {
|
||||
routes.put(method.name() + " " + path, handler);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addWsRoute(String path, WebSocketEndpoint endpoint) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addMiddleware(Middleware mw) {
|
||||
middlewares.add(mw);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-routeviewer</artifactId>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-view-core</artifactId>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-view-jte</artifactId>
|
||||
|
||||
+6
@@ -5,6 +5,7 @@ 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.websocket.WebSocketEndpoint;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
@@ -172,6 +173,11 @@ class JteExtensionTest {
|
||||
routes.put(method.name() + " " + path, handler);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addWsRoute(String path, WebSocketEndpoint endpoint) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addMiddleware(dev.relism.flash.routing.Middleware mw) {
|
||||
mws.add(mw);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-view-thymeleaf</artifactId>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-web-bundler</artifactId>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-parent</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-parent</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash</artifactId>
|
||||
|
||||
@@ -1,89 +1,251 @@
|
||||
package dev.relism.flash;
|
||||
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.http.HttpStatus;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.RequestHandler;
|
||||
import dev.relism.flash.models.Response;
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.http.HttpStatus;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.routing.AbstractRouter;
|
||||
import dev.relism.flash.routing.AbstractWsRouter;
|
||||
import dev.relism.flash.tls.TlsConfig;
|
||||
import dev.relism.flash.websocket.WebSocketFrame;
|
||||
import dev.relism.flash.websocket.WebSocketHandler;
|
||||
import dev.relism.flash.websocket.WebSocketSession;
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import javax.net.ssl.SSLServerSocket;
|
||||
import javax.net.ssl.SSLSocket;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* Pure I/O transport layer. Owns the {@link ServerSocket}, the virtual-thread
|
||||
* executor, and the keep-alive accept loop. Routing is delegated to a single
|
||||
* {@link AbstractRouter}.
|
||||
* Pure I/O transport layer. Owns one {@link ServerSocket} per configured listener (plain or
|
||||
* TLS), the virtual-thread executor, and the keep-alive accept loop. Routing is delegated to
|
||||
* HTTP and WS routers — identically, regardless of which listener accepted the connection.
|
||||
*
|
||||
* <p>Package-private : use {@link FlashApp} as the single
|
||||
* entry point.
|
||||
* <p>TLS is a transport-level concern only: once a {@link BoundListener} is bound, an accepted
|
||||
* {@link Socket} is either plain or an {@code SSLSocket} indistinguishably from here on —
|
||||
* {@link #process} never branches on it. This is also why WSS needs no separate code path from
|
||||
* WS: the WebSocket upgrade happens over whatever transport {@link #process} was handed.
|
||||
*
|
||||
* <h3>Allocation model</h3>
|
||||
* <ul>
|
||||
* <li>{@code LONG_BUF} (20 bytes) and {@code STREAM_RELAY_BUFFER} (8 KB, for a streaming
|
||||
* {@link Response} body — see {@link #writeStreamingBody}) are the only {@link ThreadLocal}s
|
||||
* kept here. Both are per-connection, not per-request: one virtual thread runs a
|
||||
* connection's whole keep-alive request loop (see {@link #process}), so a handler that
|
||||
* streams a large response on every request allocates its relay buffer once per
|
||||
* connection, not once per request.</li>
|
||||
* <li>WS handshake SHA-1: {@link ThreadLocal}<{@link MessageDigest}> — one per
|
||||
* accept thread (there are now {@code ACCEPT_THREADS} of them, not one).</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
class HttpServer implements ServerHandle {
|
||||
|
||||
private final FlashConfiguration configuration;
|
||||
private final ServerSocket serverSocket;
|
||||
private final AbstractRouter router;
|
||||
private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
|
||||
private final Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
|
||||
private volatile boolean stopped = false;
|
||||
private final AtomicReference<Thread> acceptThread = new AtomicReference<>();
|
||||
// ── Tuning constants ──────────────────────────────────────────────────────
|
||||
|
||||
private static final byte[] HTTP_1_1 = "HTTP/1.1 ".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CRLF = "\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONTENT_TYPE = "Content-Type: ".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONTENT_LENGTH = "Content-Length: ".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONNECTION_CLOSE = "Connection: close\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONNECTION_KEEPALIVE = "Connection: keep-alive\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] TRANSFER_CHUNKED = "Transfer-Encoding: chunked\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] FINAL_CHUNK = "0\r\n\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
/**
|
||||
* Number of platform threads competing on {@code serverSocket.accept()}.
|
||||
* Rule of thumb: number of available CPU cores, capped at 8.
|
||||
* More than this rarely helps — accept is cheap; the bottleneck is usually
|
||||
* the virtual-thread executor dispatching the connection handler.
|
||||
*/
|
||||
private static final int ACCEPT_THREADS = Math.min(Runtime.getRuntime().availableProcessors(), 8);
|
||||
|
||||
/**
|
||||
* TCP listen backlog. The kernel holds up to this many fully-established
|
||||
* (SYN+ACK sent, ACK received) connections waiting for accept().
|
||||
* 4096 is safe on Linux; /proc/sys/net/core/somaxconn must be >= this value,
|
||||
* or the kernel silently caps it. Raise somaxconn if needed:
|
||||
* sysctl -w net.core.somaxconn=4096
|
||||
*/
|
||||
private static final int ACCEPT_BACKLOG = 4096;
|
||||
|
||||
/**
|
||||
* Socket send/receive buffer sizes. Matched to the WS frame read buffer
|
||||
* ({@link FlashConfiguration#getWsFrameBufferSize()}) so the kernel never
|
||||
* needs to fragment a full frame into multiple TCP segments on the receive
|
||||
* side, and never blocks a write waiting for the send buffer to drain.
|
||||
*
|
||||
* Linux default is 87380 bytes (rmem) / 16384 bytes (wmem). We raise both
|
||||
* to 256 KB — a good fit for up to ~250 KB WS frames with no partial reads.
|
||||
*/
|
||||
private static final int SOCKET_BUF_SIZE = 256 * 1024;
|
||||
|
||||
// ── Instance fields ───────────────────────────────────────────────────────
|
||||
|
||||
private final FlashConfiguration configuration;
|
||||
private final List<BoundListener> boundListeners;
|
||||
private final AbstractRouter router;
|
||||
private final AbstractWsRouter wsRouter;
|
||||
private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
|
||||
private final Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
|
||||
private volatile boolean stopped = false;
|
||||
|
||||
/** Latch that reaches 0 when all accept threads, across all listeners, have exited. */
|
||||
private final CountDownLatch acceptLatch;
|
||||
|
||||
/** One bound listener socket (plain or TLS) plus whether it is TLS, for logging only. */
|
||||
private record BoundListener(ServerSocket socket, boolean secure) {}
|
||||
|
||||
// ── Static byte constants (written once, read-only on hot path) ──────────
|
||||
|
||||
private static final byte[] HTTP_1_1 = "HTTP/1.1 ".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CRLF = "\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONTENT_TYPE = "Content-Type: ".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONTENT_LENGTH = "Content-Length: ".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONNECTION_CLOSE = "Connection: close\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONNECTION_KEEPALIVE = "Connection: keep-alive\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] TRANSFER_CHUNKED = "Transfer-Encoding: chunked\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] FINAL_CHUNK = "0\r\n\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] UNKNOWN_STATUS_SUFFIX = " Unknown".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
// Per-thread scratch buffers — allocated once per VT, reused for every request.
|
||||
private static final ThreadLocal<byte[]> LONG_BUF = ThreadLocal.withInitial(() -> new byte[20]);
|
||||
private static final ThreadLocal<byte[]> CHUNK_BUF = ThreadLocal.withInitial(() -> new byte[8192]);
|
||||
private static final byte[] WS_HANDSHAKE_PREFIX =
|
||||
("HTTP/1.1 101 Switching Protocols\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Connection: Upgrade\r\n" +
|
||||
"Sec-WebSocket-Accept: ")
|
||||
.getBytes(StandardCharsets.ISO_8859_1);
|
||||
private static final byte[] WS_HANDSHAKE_SUFFIX =
|
||||
"\r\n\r\n".getBytes(StandardCharsets.ISO_8859_1);
|
||||
private static final byte[] WS_REJECT_400 =
|
||||
"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
.getBytes(StandardCharsets.ISO_8859_1);
|
||||
|
||||
HttpServer(FlashConfiguration configuration, AbstractRouter router) throws IOException {
|
||||
private static final byte[] WS_GUID_BYTES =
|
||||
"258EAFA5-E914-47DA-95CA-C5AB0DC85B11".getBytes(StandardCharsets.ISO_8859_1);
|
||||
|
||||
private static final ThreadLocal<MessageDigest> SHA1 =
|
||||
ThreadLocal.withInitial(() -> {
|
||||
try { return MessageDigest.getInstance("SHA-1"); }
|
||||
catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); }
|
||||
});
|
||||
|
||||
private static final ThreadLocal<byte[]> LONG_BUF = ThreadLocal.withInitial(() -> new byte[20]);
|
||||
|
||||
/**
|
||||
* Relay buffer for copying a streaming {@link Response} body to the client — shared by
|
||||
* {@link #writeStreamingBody}'s non-chunked path and {@link #writeChunked}, so both draw
|
||||
* from the same reused array instead of each allocating its own {@code byte[8192]} (the
|
||||
* non-chunked path previously relied on {@link InputStream#transferTo}, which allocates
|
||||
* internally on every call). Sized to match the pre-existing behavior this replaces, not
|
||||
* newly tuned — not exposed as a {@link FlashConfiguration} tunable since nothing here
|
||||
* needed one before.
|
||||
*/
|
||||
private static final int STREAM_RELAY_BUFFER_SIZE = 8192;
|
||||
private static final ThreadLocal<byte[]> STREAM_RELAY_BUFFER =
|
||||
ThreadLocal.withInitial(() -> new byte[STREAM_RELAY_BUFFER_SIZE]);
|
||||
|
||||
private static final int SHA1_LEN = 20;
|
||||
private static final int WS_ACCEPT_LEN = 28;
|
||||
|
||||
// ── Constructor ───────────────────────────────────────────────────────────
|
||||
|
||||
HttpServer(FlashConfiguration configuration, AbstractRouter router, AbstractWsRouter wsRouter) throws IOException {
|
||||
this.configuration = configuration;
|
||||
this.serverSocket = new ServerSocket(configuration.getPort());
|
||||
this.router = router;
|
||||
this.wsRouter = wsRouter;
|
||||
|
||||
List<FlashConfiguration.Listener> specs = configuration.getListeners().isEmpty()
|
||||
? List.of(new FlashConfiguration.Listener(
|
||||
configuration.getPort(), configuration.getHost(), configuration.getTls()))
|
||||
: configuration.getListeners();
|
||||
|
||||
List<BoundListener> bound = new ArrayList<>(specs.size());
|
||||
for (FlashConfiguration.Listener spec : specs) bound.add(bind(spec));
|
||||
this.boundListeners = List.copyOf(bound);
|
||||
this.acceptLatch = new CountDownLatch(ACCEPT_THREADS * boundListeners.size());
|
||||
|
||||
for (BoundListener bl : boundListeners) {
|
||||
log.info("HttpServer bound on {}:{} (tls={}, backlog={}, acceptThreads={})",
|
||||
bl.socket().getInetAddress(), bl.socket().getLocalPort(), bl.secure(),
|
||||
ACCEPT_BACKLOG, ACCEPT_THREADS);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds one listener. A TLS listener gets its {@link ServerSocket} from
|
||||
* {@link TlsConfig#serverSocketFactory()} instead of {@code new ServerSocket()}, and its
|
||||
* protocol/client-auth parameters from {@link TlsConfig#applyTo} — reuse-address, receive
|
||||
* buffer size, backlog and the bind call itself are identical either way. TLS only changes
|
||||
* which bytes come out of {@code accept()}; it never changes how the accept loop, or
|
||||
* anything downstream of it, treats them.
|
||||
*/
|
||||
private static BoundListener bind(FlashConfiguration.Listener spec) throws IOException {
|
||||
TlsConfig tls = spec.tls();
|
||||
|
||||
ServerSocket socket = tls != null ? tls.serverSocketFactory().createServerSocket() : new ServerSocket();
|
||||
// setReuseAddress(true) must be called BEFORE bind().
|
||||
socket.setReuseAddress(true);
|
||||
socket.setReceiveBufferSize(SOCKET_BUF_SIZE);
|
||||
if (tls != null) tls.applyTo((SSLServerSocket) socket);
|
||||
|
||||
InetSocketAddress addr = spec.host() != null
|
||||
? new InetSocketAddress(spec.host(), spec.port())
|
||||
: new InetSocketAddress(spec.port());
|
||||
socket.bind(addr, ACCEPT_BACKLOG);
|
||||
|
||||
return new BoundListener(socket, tls != null);
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
acceptThread.set(Thread.ofPlatform().name("flash-accept-loop").daemon(false).start(this::run));
|
||||
for (int li = 0; li < boundListeners.size(); li++) {
|
||||
BoundListener listener = boundListeners.get(li);
|
||||
for (int i = 0; i < ACCEPT_THREADS; i++) {
|
||||
Thread.ofPlatform()
|
||||
.name("flash-accept-" + li + "-" + i)
|
||||
.daemon(false)
|
||||
.start(() -> acceptLoop(listener));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startAndBlock() {
|
||||
start();
|
||||
try { acceptThread.get().join(); }
|
||||
try { acceptLatch.await(); }
|
||||
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
|
||||
}
|
||||
|
||||
private void run() {
|
||||
while (!stopped) {
|
||||
try {
|
||||
process(serverSocket.accept());
|
||||
} catch (IOException e) {
|
||||
if (!stopped) log.error("Accept loop error", e);
|
||||
/**
|
||||
* Single accept loop body — runs on each of the {@code ACCEPT_THREADS}
|
||||
* platform threads bound to one {@code listener}. All threads for that listener block on
|
||||
* the same {@link ServerSocket}; the JVM ensures only one wakes per incoming connection
|
||||
* (no thundering herd). Other listeners' accept threads are entirely independent.
|
||||
*/
|
||||
private void acceptLoop(BoundListener listener) {
|
||||
try {
|
||||
while (!stopped) {
|
||||
try {
|
||||
process(listener.socket().accept());
|
||||
} catch (IOException e) {
|
||||
if (!stopped) log.error("Accept error", e);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
acceptLatch.countDown();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +253,9 @@ class HttpServer implements ServerHandle {
|
||||
public CompletableFuture<Void> stop() {
|
||||
return CompletableFuture.runAsync(() -> {
|
||||
stopped = true;
|
||||
try { serverSocket.close(); } catch (IOException e) { log.error("Error closing server socket", e); }
|
||||
for (BoundListener bl : boundListeners) {
|
||||
try { bl.socket().close(); } catch (IOException e) { log.error("Error closing server socket", e); }
|
||||
}
|
||||
activeSockets.forEach(s -> { try { s.close(); } catch (IOException ignored) {} });
|
||||
executorService.shutdown();
|
||||
try {
|
||||
@@ -104,24 +268,63 @@ class HttpServer implements ServerHandle {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Hot-path ─────────────────────────────────────────────────────────────
|
||||
// ── Hot-path ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void process(Socket socket) {
|
||||
try {
|
||||
executorService.submit(() -> {
|
||||
activeSockets.add(socket);
|
||||
try (socket;
|
||||
InputStream in = socket.getInputStream();
|
||||
OutputStream out = new BufferedOutputStream(socket.getOutputStream())) {
|
||||
InputStream in = socket.getInputStream();
|
||||
OutputStream out = new BufferedOutputStream(socket.getOutputStream())) {
|
||||
|
||||
// TCP_NODELAY: disable Nagle's algorithm.
|
||||
// Small WS frames (< MSS) are sent immediately rather than
|
||||
// waiting up to 200 ms for more data to coalesce. Latency
|
||||
// drops significantly at the cost of slightly more TCP segments
|
||||
// under sustained bulk transfer — acceptable for interactive WS.
|
||||
socket.setTcpNoDelay(true);
|
||||
socket.setSendBufferSize(SOCKET_BUF_SIZE);
|
||||
|
||||
// rawOut is the unbuffered socket stream — passed to WebSocketSession
|
||||
// directly. WS writes are already bulk (header + payload in two calls);
|
||||
// with TCP_NODELAY the kernel ships them without Nagle delay, so no
|
||||
// userspace buffer is needed and no flush() is required per frame.
|
||||
// HTTP responses continue to use the BufferedOutputStream (out) because
|
||||
// writeResponse() does many small individual writes that benefit from
|
||||
// userspace coalescing before a single syscall.
|
||||
OutputStream rawOut = socket.getOutputStream();
|
||||
|
||||
RequestParser parser = new RequestParser(
|
||||
configuration.getMaxHeaderBufferSize(),
|
||||
(InetSocketAddress) socket.getRemoteSocketAddress());
|
||||
(InetSocketAddress) socket.getRemoteSocketAddress(),
|
||||
socket instanceof SSLSocket sslSocket ? sslSocket : null);
|
||||
|
||||
while (!stopped) {
|
||||
Request request = parser.parse(in);
|
||||
if (request == null) break;
|
||||
|
||||
boolean keepAlive = isKeepAlive(request);
|
||||
Response response = new Response(200, ContentType.TEXT_PLAIN);
|
||||
if (request.method() == HttpMethod.GET && isWebSocketUpgrade(request)) {
|
||||
WebSocketHandler wsHandler = wsRouter.route(request);
|
||||
if (wsHandler == null) {
|
||||
out.write(WS_REJECT_400);
|
||||
out.flush();
|
||||
break;
|
||||
}
|
||||
// Flush buffered HTTP bytes (the 101 response) before WebSocketSession
|
||||
// takes over rawOut — otherwise the handshake reply stays stuck in
|
||||
// the BufferedOutputStream buffer and the client never sees it.
|
||||
performHandshake(out, request);
|
||||
out.flush();
|
||||
request.drain();
|
||||
WebSocketSession session = new WebSocketSession(
|
||||
in, rawOut, configuration.getWsFrameBufferSize(), request, false);
|
||||
runWsLoop(session, wsHandler);
|
||||
return;
|
||||
}
|
||||
|
||||
boolean keepAlive = isKeepAlive(request);
|
||||
Response response = new Response(200, ContentType.TEXT_PLAIN);
|
||||
|
||||
RequestHandler handler = router.route(request);
|
||||
if (handler == null) handler = router.getNotFoundHandler();
|
||||
@@ -140,6 +343,7 @@ class HttpServer implements ServerHandle {
|
||||
request.drain();
|
||||
if (!keepAlive) break;
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
if (!stopped) {
|
||||
if (e instanceof java.net.SocketException)
|
||||
@@ -147,23 +351,121 @@ class HttpServer implements ServerHandle {
|
||||
else
|
||||
log.error("I/O error handling request", e);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Anything not an IOException here means a collaborator misbehaved on the TLS
|
||||
// handshake path — most likely a custom TlsConfig#ofContext KeyManager/
|
||||
// TrustManager throwing (e.g. a failed DB lookup or on-demand cert issuance).
|
||||
// That failure is isolated to this one virtual thread/connection: the
|
||||
// try-with-resources above still closes the socket, the finally below still
|
||||
// runs, and the accept loop (a different thread entirely) never sees this.
|
||||
if (!stopped) log.error("Unexpected error handling connection", e);
|
||||
} finally {
|
||||
activeSockets.remove(socket);
|
||||
}
|
||||
});
|
||||
} catch (RejectedExecutionException ignored) {
|
||||
// Executor already shut down — close the socket so the client isn't left hanging.
|
||||
try { socket.close(); } catch (IOException e) { log.debug("Error closing socket on shutdown", e); }
|
||||
}
|
||||
}
|
||||
|
||||
// ── WebSocket upgrade detection (zero-alloc) ──────────────────────────────
|
||||
|
||||
private static boolean isWebSocketUpgrade(Request request) {
|
||||
ByteView upgrade = request.getRequestLine().getHeaders().view("Upgrade");
|
||||
if (upgrade == null) return false;
|
||||
if (!tokenEqualsIgnoreCase(upgrade, 0, upgrade.length(), "websocket")) return false;
|
||||
return connectionContainsUpgrade(request);
|
||||
}
|
||||
|
||||
private static boolean connectionContainsUpgrade(Request request) {
|
||||
ByteView conn = request.getRequestLine().getHeaders().view("Connection");
|
||||
if (conn == null) return false;
|
||||
int len = conn.length(), i = 0;
|
||||
while (i < len) {
|
||||
while (i < len && conn.byteAt(i) == ' ') i++;
|
||||
int start = i;
|
||||
while (i < len && conn.byteAt(i) != ',') i++;
|
||||
if (tokenEqualsIgnoreCase(conn, start, i, "upgrade")) return true;
|
||||
i++;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean tokenEqualsIgnoreCase(ByteView view, int start, int end, String token) {
|
||||
int tlen = token.length();
|
||||
int wlen = end - start;
|
||||
while (wlen > 0 && view.byteAt(start + wlen - 1) == ' ') wlen--;
|
||||
if (wlen != tlen) return false;
|
||||
for (int i = 0; i < tlen; i++) {
|
||||
byte b = view.byteAt(start + i);
|
||||
if (b >= 'A' && b <= 'Z') b += 32;
|
||||
if (b != (byte) token.charAt(i)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── WebSocket handshake ───────────────────────────────────────────────────
|
||||
|
||||
private void performHandshake(OutputStream out, Request request) throws IOException {
|
||||
ByteView keyView = request.getRequestLine().getHeaders().view("Sec-WebSocket-Key");
|
||||
if (keyView == null) throw new IOException("Missing Sec-WebSocket-Key header");
|
||||
|
||||
MessageDigest sha1 = SHA1.get();
|
||||
sha1.reset();
|
||||
for (int i = 0, len = keyView.length(); i < len; i++) sha1.update(keyView.byteAt(i));
|
||||
sha1.update(WS_GUID_BYTES);
|
||||
|
||||
byte[] accept = Base64.getEncoder().encode(sha1.digest());
|
||||
|
||||
out.write(WS_HANDSHAKE_PREFIX);
|
||||
out.write(accept);
|
||||
out.write(WS_HANDSHAKE_SUFFIX);
|
||||
out.flush();
|
||||
}
|
||||
|
||||
// ── WebSocket session loop ────────────────────────────────────────────────
|
||||
|
||||
private void runWsLoop(WebSocketSession session, WebSocketHandler handler) {
|
||||
handler.onOpen(session);
|
||||
WebSocketFrame frame = new WebSocketFrame();
|
||||
try {
|
||||
while (session.isOpen()) {
|
||||
if (!session.readFrame(frame)) break;
|
||||
switch (frame.opcode()) {
|
||||
case WebSocketFrame.OP_TEXT, WebSocketFrame.OP_BINARY
|
||||
-> handler.onMessage(session, frame);
|
||||
case WebSocketFrame.OP_CLOSE
|
||||
-> session.closeFromPeer(frame);
|
||||
case WebSocketFrame.OP_PING
|
||||
-> session.sendPong(frame);
|
||||
case WebSocketFrame.OP_PONG -> { /* heartbeat ack, no-op */ }
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
handler.onError(session, e);
|
||||
} finally {
|
||||
handler.onClose(session, session.closeCode());
|
||||
session.forceClose();
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTTP keep-alive detection ─────────────────────────────────────────────
|
||||
|
||||
private static boolean isKeepAlive(Request request) {
|
||||
if (request.headerEquals("Connection", "close")) return false;
|
||||
ByteView protocol = request.getRequestLine().getProtocol();
|
||||
return protocol.length() == 8 && protocol.byteAt(7) == '1'
|
||||
|| request.headerEquals("Connection", "keep-alive");
|
||||
int plen = protocol.length();
|
||||
if (plen == 8) {
|
||||
byte minor = protocol.byteAt(7);
|
||||
if (minor == '1') return true;
|
||||
if (minor == '0') return request.headerEquals("Connection", "keep-alive");
|
||||
}
|
||||
log.debug("Unrecognised protocol '{}', treating as close", protocol);
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Response serialisation ────────────────────────────────────────────────
|
||||
|
||||
private static void writeResponse(OutputStream out, Response response, boolean keepAlive) throws IOException {
|
||||
out.write(HTTP_1_1);
|
||||
byte[] statusBytes = response.getStatusBytes();
|
||||
@@ -196,7 +498,7 @@ class HttpServer implements ServerHandle {
|
||||
out.write(CRLF);
|
||||
out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
|
||||
out.write(CRLF);
|
||||
response.getStream().transferTo(out);
|
||||
relay(response.getStream(), out);
|
||||
} else {
|
||||
out.write(TRANSFER_CHUNKED);
|
||||
out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
|
||||
@@ -205,6 +507,18 @@ class HttpServer implements ServerHandle {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies {@code in} to {@code out} until EOF, same contract as {@link InputStream#transferTo}
|
||||
* — but via {@link #STREAM_RELAY_BUFFER} instead of a fresh {@code byte[]} per call, which is
|
||||
* what {@code transferTo}'s own (JDK-internal) implementation would otherwise allocate on
|
||||
* every streamed response.
|
||||
*/
|
||||
private static void relay(InputStream in, OutputStream out) throws IOException {
|
||||
byte[] buf = STREAM_RELAY_BUFFER.get();
|
||||
int n;
|
||||
while ((n = in.read(buf)) > 0) out.write(buf, 0, n);
|
||||
}
|
||||
|
||||
private static void writeStatusPhrase(OutputStream out, int statusCode) throws IOException {
|
||||
byte[] phrase = HttpStatus.bytesForCode(statusCode);
|
||||
if (phrase != null) out.write(phrase);
|
||||
@@ -223,7 +537,7 @@ class HttpServer implements ServerHandle {
|
||||
}
|
||||
|
||||
private static void writeChunked(OutputStream out, InputStream stream) throws IOException {
|
||||
byte[] buf = CHUNK_BUF.get();
|
||||
byte[] buf = STREAM_RELAY_BUFFER.get();
|
||||
int n;
|
||||
while ((n = stream.read(buf)) > 0) {
|
||||
writeHex(out, n);
|
||||
@@ -235,13 +549,16 @@ class HttpServer implements ServerHandle {
|
||||
}
|
||||
|
||||
private static void writeHex(OutputStream out, int value) throws IOException {
|
||||
int shift = 28;
|
||||
int shift = 28;
|
||||
boolean leading = true;
|
||||
while (shift >= 0) {
|
||||
int digit = (value >>> shift) & 0xF;
|
||||
if (digit != 0 || !leading) { leading = false; out.write(digit < 10 ? '0' + digit : 'a' + digit - 10); }
|
||||
if (digit != 0 || !leading) {
|
||||
leading = false;
|
||||
out.write(digit < 10 ? '0' + digit : 'a' + digit - 10);
|
||||
}
|
||||
shift -= 4;
|
||||
}
|
||||
if (leading) out.write('0');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,38 +8,78 @@ import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import javax.net.ssl.SSLSocket;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* One instance per connection, the buffer is allocated once and reused across keep-alive
|
||||
* requests. Grows on demand (doubling, up to {@code maxHeaderBufferSize}). Zero String
|
||||
* allocations during parsing; paths, headers and protocol are exposed as {@link dev.relism.fpr.core.ByteView} slices.
|
||||
* One instance per connection. The buffer is allocated once and reused across
|
||||
* keep-alive requests. Grows on demand (doubling, up to {@code maxHeaderBufferSize}).
|
||||
*
|
||||
* <h3>Zero-allocation design</h3>
|
||||
* <ul>
|
||||
* <li>No {@link String} allocations during parsing: paths, headers and
|
||||
* protocol are exposed as {@link dev.relism.fpr.core.ByteView} slices
|
||||
* into the shared buffer.</li>
|
||||
* <li>{@link #headerMap} is reset in-place per request — single allocation
|
||||
* for the lifetime of the connection.</li>
|
||||
* <li>Pipelining / keep-alive leftover bytes are tracked via {@code bufBase}
|
||||
* and {@code bufLen} — no copy between requests on the common path.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h3>State invariant</h3>
|
||||
* {@code bufBase} and {@code bufLen} always reflect unconsumed bytes that belong
|
||||
* to the <em>next</em> request. They are snapshotted at the top of {@link #parse}
|
||||
* and reset to {@code 0/0} before any work begins, so an exception thrown mid-parse
|
||||
* leaves the fields clean rather than pointing at stale data from a previous request.
|
||||
*/
|
||||
@Slf4j
|
||||
public class RequestParser {
|
||||
private static final int INITIAL_BUFFER_SIZE = 8192;
|
||||
|
||||
private final int maxHeaderBufferSize;
|
||||
private final InetSocketAddress remoteAddress; // set once per connection, never changes
|
||||
private final HeaderMap headerMap = new HeaderMap();
|
||||
private final int maxHeaderBufferSize;
|
||||
private final InetSocketAddress remoteAddress;
|
||||
private final SSLSocket sslSocket;
|
||||
private final HeaderMap headerMap = new HeaderMap();
|
||||
private byte[] buffer;
|
||||
private int bufBase = 0; // absolute start of valid data in buffer
|
||||
private int bufLen = 0; // number of valid bytes from bufBase
|
||||
|
||||
public RequestParser() { this(64 * 1024, null); }
|
||||
public RequestParser(int maxHeaderBufferSize) { this(maxHeaderBufferSize, null); }
|
||||
// Unconsumed bytes belonging to the NEXT request.
|
||||
// Reset to 0/0 at the start of every parse() call — see invariant above.
|
||||
private int bufBase = 0;
|
||||
private int bufLen = 0;
|
||||
|
||||
public RequestParser() { this(64 * 1024, null, null); }
|
||||
public RequestParser(int maxHeaderBufferSize) { this(maxHeaderBufferSize, null, null); }
|
||||
public RequestParser(int maxHeaderBufferSize, InetSocketAddress remoteAddress) {
|
||||
this(maxHeaderBufferSize, remoteAddress, null);
|
||||
}
|
||||
|
||||
/** {@code sslSocket} is {@code null} for a plain connection — see {@link Request#isSecure()}. */
|
||||
public RequestParser(int maxHeaderBufferSize, InetSocketAddress remoteAddress, SSLSocket sslSocket) {
|
||||
this.maxHeaderBufferSize = maxHeaderBufferSize;
|
||||
this.remoteAddress = remoteAddress;
|
||||
this.sslSocket = sslSocket;
|
||||
this.buffer = new byte[Math.min(INITIAL_BUFFER_SIZE, maxHeaderBufferSize)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the next HTTP request from {@code in}.
|
||||
*
|
||||
* <p><b>Exception safety:</b> {@code bufBase} and {@code bufLen} are reset
|
||||
* to {@code 0} before any parsing work begins. If an exception is thrown,
|
||||
* the connection will be closed by the caller, so stale leftover state is
|
||||
* never a problem — but the reset ensures correctness in test scenarios where
|
||||
* the same parser instance is reused after an error.
|
||||
*
|
||||
* @return the parsed {@link Request}, or {@code null} on clean EOF.
|
||||
* @throws IOException on malformed headers or I/O failure.
|
||||
*/
|
||||
public Request parse(InputStream in) throws IOException {
|
||||
// Take ownership of any leftover bytes from the previous request, then reset so
|
||||
// early-returns leave the fields in a clean state.
|
||||
// Snapshot leftover bytes from the previous request, then reset immediately.
|
||||
// Any exception thrown below leaves bufBase/bufLen at 0 — safe state.
|
||||
int base = bufBase;
|
||||
int totalRead = bufLen;
|
||||
bufBase = 0;
|
||||
@@ -49,8 +89,8 @@ public class RequestParser {
|
||||
while (headerEndIdx == -1) {
|
||||
if (base + totalRead == buffer.length) {
|
||||
if (base > 0) {
|
||||
// Compact: slide valid data to position 0 — rare path (~every N requests
|
||||
// where N = bufferSize / avgRequestSize rather than every request).
|
||||
// Compact: slide valid data to position 0.
|
||||
// Rare path (~every N requests where N ≈ bufferSize / avgRequestSize).
|
||||
System.arraycopy(buffer, base, buffer, 0, totalRead);
|
||||
base = 0;
|
||||
} else if (buffer.length >= maxHeaderBufferSize) {
|
||||
@@ -70,6 +110,8 @@ public class RequestParser {
|
||||
throw new IOException("Request headers exceed " + maxHeaderBufferSize + " bytes");
|
||||
}
|
||||
|
||||
// ── Request line ─────────────────────────────────────────────────────
|
||||
|
||||
int methodEnd = find(buffer, base, headerEndIdx, (byte) ' ');
|
||||
if (methodEnd == -1) throw new IOException("Invalid request line (method)");
|
||||
|
||||
@@ -81,20 +123,23 @@ public class RequestParser {
|
||||
if (pathEnd == -1) throw new IOException("Invalid request line (path)");
|
||||
|
||||
int queryMark = find(buffer, pathStart, pathEnd, (byte) '?');
|
||||
FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(buffer, pathStart,
|
||||
FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(buffer, pathStart,
|
||||
queryMark != -1 ? queryMark - pathStart : pathEnd - pathStart);
|
||||
FastPathViews.RequestByteView queryView = queryMark != -1
|
||||
? new FastPathViews.RequestByteView(buffer, queryMark + 1, pathEnd - queryMark - 1)
|
||||
: null;
|
||||
|
||||
int protocolStart = pathEnd + 1;
|
||||
int protocolEnd = find(buffer, protocolStart, headerEndIdx, (byte) '\r');
|
||||
int protocolEnd = find(buffer, protocolStart, headerEndIdx, (byte) '\r');
|
||||
if (protocolEnd == -1) throw new IOException("Invalid request line (protocol)");
|
||||
|
||||
FastPathViews.RequestByteView protocolView = new FastPathViews.RequestByteView(buffer, protocolStart, protocolEnd - protocolStart);
|
||||
FastPathViews.RequestByteView protocolView =
|
||||
new FastPathViews.RequestByteView(buffer, protocolStart, protocolEnd - protocolStart);
|
||||
|
||||
// ── Headers ──────────────────────────────────────────────────────────
|
||||
|
||||
int sectionStart = find(buffer, protocolEnd, headerEndIdx, (byte) '\n') + 1;
|
||||
int current = sectionStart;
|
||||
int current = sectionStart;
|
||||
long contentLength = 0;
|
||||
boolean isChunked = false;
|
||||
|
||||
@@ -118,35 +163,40 @@ public class RequestParser {
|
||||
|
||||
headerMap.reset(buffer, sectionStart, headerEndIdx);
|
||||
|
||||
// ── Body / pipelining accounting ─────────────────────────────────────
|
||||
|
||||
int bodyStart = headerEndIdx + 4;
|
||||
int preBufLen = (base + totalRead) - bodyStart;
|
||||
|
||||
// Any bytes read beyond this request's body belong to the next request.
|
||||
// Store their absolute position in the buffer — no copy needed; the next parse()
|
||||
// call will read directly from bufBase without touching the data.
|
||||
// Bytes read beyond this request's body belong to the next request.
|
||||
// Store their absolute position — no copy needed; the next parse() call
|
||||
// reads directly from bufBase without touching the data.
|
||||
if (!isChunked && contentLength == 0 && preBufLen > 0) {
|
||||
bufBase = bodyStart;
|
||||
bufLen = preBufLen;
|
||||
bufBase = bodyStart;
|
||||
bufLen = preBufLen;
|
||||
preBufLen = 0;
|
||||
} else if (!isChunked && contentLength > 0 && preBufLen > contentLength) {
|
||||
bufBase = bodyStart + (int) contentLength;
|
||||
bufLen = preBufLen - (int) contentLength;
|
||||
bufBase = bodyStart + (int) contentLength;
|
||||
bufLen = preBufLen - (int) contentLength;
|
||||
preBufLen = (int) contentLength;
|
||||
}
|
||||
|
||||
RequestLine requestLine = new RequestLine(method, pathView, queryView, protocolView, headerMap);
|
||||
|
||||
if (isChunked) {
|
||||
return Request.forParsed(requestLine, new ChunkedInputStream(in, buffer, bodyStart, preBufLen), -1L, null, 0, 0, remoteAddress);
|
||||
return Request.forParsed(requestLine,
|
||||
new ChunkedInputStream(in, buffer, bodyStart, preBufLen),
|
||||
-1L, null, 0, 0, remoteAddress, sslSocket);
|
||||
}
|
||||
return Request.forParsed(requestLine, in, contentLength, buffer, bodyStart, preBufLen, remoteAddress);
|
||||
return Request.forParsed(requestLine, in, contentLength, buffer, bodyStart, preBufLen, remoteAddress, sslSocket);
|
||||
}
|
||||
|
||||
// ── Buffer scanning utilities (hot path — keep branch-free where possible) ──
|
||||
|
||||
private static int findEndOfHeader(byte[] buf, int from, int len) {
|
||||
for (int i = from; i <= len - 4; i++) {
|
||||
if (buf[i] == '\r' && buf[i+1] == '\n' && buf[i+2] == '\r' && buf[i+3] == '\n') {
|
||||
if (buf[i] == '\r' && buf[i+1] == '\n' && buf[i+2] == '\r' && buf[i+3] == '\n')
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@@ -177,4 +227,4 @@ public class RequestParser {
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package dev.relism.flash;
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.routing.AbstractRouter;
|
||||
import dev.relism.flash.routing.AbstractWsRouter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
@@ -26,7 +27,9 @@ public interface ServerHandle {
|
||||
/** Gracefully stops the server, draining active connections. */
|
||||
CompletableFuture<Void> stop();
|
||||
|
||||
static ServerHandle create(FlashConfiguration config, AbstractRouter router) throws IOException {
|
||||
return new HttpServer(config, router);
|
||||
static ServerHandle create(FlashConfiguration config,
|
||||
AbstractRouter httpRouter,
|
||||
AbstractWsRouter wsRouter) throws IOException {
|
||||
return new HttpServer(config, httpRouter, wsRouter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,14 @@ import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.RequestHandler;
|
||||
import dev.relism.flash.models.SimpleHandler;
|
||||
import dev.relism.flash.routing.AbstractRouter;
|
||||
import dev.relism.flash.routing.AbstractWsRouter;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
import dev.relism.flash.routing.routers.fastpathrouter.FastPathRouterImpl;
|
||||
import dev.relism.flash.routing.routers.fastpathrouter.FastPathWsRouterImpl;
|
||||
import dev.relism.flash.websocket.WebSocketEndpoint;
|
||||
import dev.relism.flash.websocket.WebSocketFrame;
|
||||
import dev.relism.flash.websocket.WebSocketHandler;
|
||||
import dev.relism.flash.websocket.WebSocketSession;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -52,17 +58,19 @@ import java.util.function.Consumer;
|
||||
public final class FlashApp extends FlashRegistrar<FlashApp> {
|
||||
|
||||
private final AbstractRouter router = new FastPathRouterImpl();
|
||||
private final AbstractWsRouter wsRouter = new FastPathWsRouterImpl();
|
||||
private final ServerHandle server;
|
||||
private final FlashContext ctx = new FlashContext();
|
||||
private final List<FlashExtension> extensions = new ArrayList<>();
|
||||
private final List<Middleware> globalMiddlewares = new ArrayList<>();
|
||||
private final List<RouteDefinition> deferredRoutes = new ArrayList<>();
|
||||
private int port;
|
||||
private final List<WsRouteDefinition> deferredWsRoutes = new ArrayList<>();
|
||||
|
||||
private record WsRouteDefinition(String path, WebSocketEndpoint endpoint) {}
|
||||
|
||||
private FlashApp(FlashConfiguration config) {
|
||||
try {
|
||||
this.server = ServerHandle.create(config, router);
|
||||
this.port = config.getPort();
|
||||
this.server = ServerHandle.create(config, router, wsRouter);
|
||||
}
|
||||
catch (IOException e) { throw new InitializationException("Failed to bind on port " + config.getPort(), e); }
|
||||
}
|
||||
@@ -83,6 +91,26 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
||||
FlashScope scope = new FlashScope(namespace, ctx);
|
||||
configure.accept(scope);
|
||||
deferredRoutes.addAll(scope.routes());
|
||||
deferredWsRoutes.addAll(scope.wsRoutes().stream()
|
||||
.map(r -> new WsRouteDefinition(r.path(), r.endpoint()))
|
||||
.toList());
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a WebSocket endpoint at {@code path}.
|
||||
* Runs on the same virtual-thread model as HTTP handlers.
|
||||
* A path may have both an HTTP handler and a WS endpoint simultaneously.
|
||||
*/
|
||||
public FlashApp ws(String path, WebSocketHandler handler) {
|
||||
WebSocketEndpoint endpoint = handler instanceof WebSocketEndpoint e ? e
|
||||
: new WebSocketEndpoint() {
|
||||
@Override public void onOpen(WebSocketSession s) { handler.onOpen(s); }
|
||||
@Override public void onMessage(WebSocketSession s, WebSocketFrame f) { handler.onMessage(s, f); }
|
||||
@Override public void onClose(WebSocketSession s, int c) { handler.onClose(s, c); }
|
||||
@Override public void onError(WebSocketSession s, Throwable t) { handler.onError(s, t); }
|
||||
};
|
||||
deferredWsRoutes.add(new WsRouteDefinition(path, endpoint));
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -127,7 +155,7 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
||||
public FlashApp start() {
|
||||
boot();
|
||||
server.start();
|
||||
if (Flash.DEV) log.info("[Flash] Started (dev mode): listening on port " + port);
|
||||
if (Flash.DEV) log.info("[Flash] Started (dev mode) — see HttpServer bind logs above for listener details");
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -137,7 +165,7 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
||||
*/
|
||||
public void startAndBlock() {
|
||||
boot();
|
||||
if (Flash.DEV) log.info("[Flash] Started (dev mode): listening on port " + port);
|
||||
if (Flash.DEV) log.info("[Flash] Started (dev mode) — see HttpServer bind logs above for listener details");
|
||||
server.startAndBlock();
|
||||
}
|
||||
|
||||
@@ -152,6 +180,11 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
||||
!(handler instanceof SimpleHandler), ctx, "/"));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addWsRoute(String path, WebSocketEndpoint endpoint) {
|
||||
deferredWsRoutes.add(new WsRouteDefinition(path, endpoint));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addMiddleware(Middleware mw) { globalMiddlewares.add(mw); }
|
||||
|
||||
@@ -163,6 +196,7 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
||||
ctx.resolveAll();
|
||||
extensions.forEach(e -> e.routes(this, ctx));
|
||||
compile();
|
||||
compileWs();
|
||||
}
|
||||
|
||||
// ── Compilation ──────────────────────────────────────────────────────────
|
||||
@@ -187,6 +221,14 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
||||
}
|
||||
}
|
||||
|
||||
private void compileWs() {
|
||||
for (WsRouteDefinition def : deferredWsRoutes) {
|
||||
def.endpoint().bind(ctx);
|
||||
emitWsEvent(def.path(), def.endpoint());
|
||||
wsRouter.register(HttpMethod.GET, def.path(), def.endpoint());
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void emitEvent(RouteDefinition def, Middleware[] chain) {
|
||||
List<RouteListener> listeners = def.ctx().routeListeners();
|
||||
@@ -198,6 +240,14 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
||||
listeners.forEach(l -> l.onRoute(event));
|
||||
}
|
||||
|
||||
private void emitWsEvent(String path, WebSocketEndpoint endpoint) {
|
||||
List<RouteListener> listeners = ctx.routeListeners();
|
||||
if (listeners.isEmpty()) return;
|
||||
RouteEvent event = new RouteEvent(HttpMethod.GET, path, "/", "FlashApp",
|
||||
endpoint.getClass(), List.of());
|
||||
listeners.forEach(l -> l.onRoute(event));
|
||||
}
|
||||
|
||||
private static Middleware[] concat(List<Middleware> global, List<Middleware> scope,
|
||||
List<Middleware> injected, List<Middleware> explicit) {
|
||||
int total = global.size() + scope.size() + injected.size() + explicit.size();
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package dev.relism.flash.extension;
|
||||
|
||||
import dev.relism.flash.tls.TlsConfig;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Singular;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Configuration for a {@link FlashApp} instance.
|
||||
*
|
||||
@@ -16,6 +21,18 @@ import lombok.Value;
|
||||
* .host("127.0.0.1")
|
||||
* .maxHeaderBufferSize(128 * 1024)
|
||||
* .build());
|
||||
*
|
||||
* // TLS on the single default listener
|
||||
* FlashApp.create(FlashConfiguration.builder()
|
||||
* .port(443)
|
||||
* .tls(TlsConfig.keystore(Path.of("cert.p12"), "changeit"))
|
||||
* .build());
|
||||
*
|
||||
* // Multiple listeners on one app — takes precedence over port/host/tls above
|
||||
* FlashApp.create(FlashConfiguration.builder()
|
||||
* .listener(new FlashConfiguration.Listener(80))
|
||||
* .listener(new FlashConfiguration.Listener(443, TlsConfig.keystore(Path.of("cert.p12"), "changeit")))
|
||||
* .build());
|
||||
* }</pre>
|
||||
*/
|
||||
@Value
|
||||
@@ -25,7 +42,24 @@ public class FlashConfiguration {
|
||||
int port;
|
||||
String host;
|
||||
|
||||
/** TLS for the single default listener ({@link #port}/{@link #host}). Ignored if {@link #listeners} is non-empty. */
|
||||
TlsConfig tls;
|
||||
|
||||
/** One or more listeners for this app. Non-empty list takes precedence over {@link #port}/{@link #host}/{@link #tls}. */
|
||||
@Singular
|
||||
List<Listener> listeners;
|
||||
|
||||
/** Maximum size of the request header buffer in bytes. Default: 64 KB. */
|
||||
@Builder.Default
|
||||
int maxHeaderBufferSize = 64 * 1024;
|
||||
|
||||
/** Per-connection WebSocket read buffer size in bytes. Default: 64 KB. */
|
||||
@Builder.Default
|
||||
int wsFrameBufferSize = 64 * 1024;
|
||||
|
||||
/** One bind target: a TCP port, an optional bind host (default: all interfaces), and optional TLS. */
|
||||
public record Listener(int port, String host, TlsConfig tls) {
|
||||
public Listener(int port) { this(port, null, null); }
|
||||
public Listener(int port, TlsConfig tls) { this(port, null, tls); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import dev.relism.flash.exceptions.InitializationException;
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.RequestHandler;
|
||||
import dev.relism.flash.models.SimpleHandler;
|
||||
import dev.relism.flash.websocket.WebSocketEndpoint;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
import dev.relism.flash.routing.Route;
|
||||
import dev.relism.flash.routing.Routes;
|
||||
import dev.relism.flash.routing.Ws;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -68,10 +70,15 @@ public abstract class FlashRegistrar<SELF extends FlashRegistrar<SELF>> {
|
||||
* class fails to load or instantiate
|
||||
*/
|
||||
public final SELF scan(String packageName) {
|
||||
PackageScanner.findHandlers(packageName).forEach(cls -> {
|
||||
PackageScanner.ScanResult found = PackageScanner.scan(packageName);
|
||||
found.httpHandlers().forEach(cls -> {
|
||||
Route ann = Routes.of(cls);
|
||||
addRoute(ann.method(), ann.path(), instantiate(cls), List.of());
|
||||
});
|
||||
found.wsEndpoints().forEach(cls -> {
|
||||
Ws ann = cls.getAnnotation(Ws.class);
|
||||
addWsRoute(ann.value(), instantiateWs(cls));
|
||||
});
|
||||
return (SELF) this;
|
||||
}
|
||||
|
||||
@@ -87,6 +94,8 @@ public abstract class FlashRegistrar<SELF extends FlashRegistrar<SELF>> {
|
||||
protected abstract void addRoute(HttpMethod method, String path,
|
||||
RequestHandler handler, List<Middleware> mw);
|
||||
|
||||
protected abstract void addWsRoute(String path, WebSocketEndpoint endpoint);
|
||||
|
||||
/** Registers a middleware in this registrar's own scope (global or scope-level). */
|
||||
protected abstract void addMiddleware(Middleware mw);
|
||||
|
||||
@@ -103,4 +112,13 @@ public abstract class FlashRegistrar<SELF extends FlashRegistrar<SELF>> {
|
||||
" — ensure it has a public no-arg constructor", e);
|
||||
}
|
||||
}
|
||||
|
||||
protected static WebSocketEndpoint instantiateWs(Class<?> cls) {
|
||||
try { return (WebSocketEndpoint) cls.getDeclaredConstructor().newInstance(); }
|
||||
catch (Exception e) {
|
||||
throw new InitializationException(
|
||||
"Failed to instantiate WS endpoint " + cls.getName() +
|
||||
" — ensure public no-arg constructor", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import dev.relism.flash.models.RequestHandler;
|
||||
import dev.relism.flash.models.SimpleHandler;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
import dev.relism.flash.routing.PathUtils;
|
||||
import dev.relism.flash.websocket.WebSocketEndpoint;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -29,6 +30,9 @@ public final class FlashScope extends FlashRegistrar<FlashScope> {
|
||||
private final FlashContext ctx;
|
||||
private final List<Middleware> scopeMiddlewares = new ArrayList<>();
|
||||
private final List<RouteDefinition> deferredRoutes = new ArrayList<>();
|
||||
private final List<WsRouteDefinition> deferredWsRoutes = new ArrayList<>();
|
||||
|
||||
record WsRouteDefinition(String path, WebSocketEndpoint endpoint) {}
|
||||
|
||||
FlashScope(String namespace, FlashContext parentCtx) {
|
||||
this.namespace = PathUtils.sanitize(namespace);
|
||||
@@ -47,12 +51,18 @@ public final class FlashScope extends FlashRegistrar<FlashScope> {
|
||||
!(handler instanceof SimpleHandler), ctx, namespace));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addWsRoute(String path, WebSocketEndpoint endpoint) {
|
||||
deferredWsRoutes.add(new WsRouteDefinition(ns(path), endpoint));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addMiddleware(Middleware mw) { scopeMiddlewares.add(mw); }
|
||||
|
||||
// ── Internal (called by FlashApp.mount) ───────────────────────────────────
|
||||
|
||||
List<RouteDefinition> routes() { return deferredRoutes; }
|
||||
List<WsRouteDefinition> wsRoutes() { return deferredWsRoutes; }
|
||||
|
||||
private String ns(String path) { return PathUtils.join(namespace, path); }
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@ package dev.relism.flash.extension;
|
||||
|
||||
import dev.relism.flash.exceptions.InitializationException;
|
||||
import dev.relism.flash.models.RequestHandler;
|
||||
import dev.relism.flash.routing.Ws;
|
||||
import dev.relism.flash.routing.Routes;
|
||||
import dev.relism.flash.websocket.WebSocketEndpoint;
|
||||
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Enumeration;
|
||||
@@ -26,6 +29,8 @@ final class PackageScanner {
|
||||
|
||||
private PackageScanner() {}
|
||||
|
||||
record ScanResult(List<Class<?>> httpHandlers, List<Class<?>> wsEndpoints) {}
|
||||
|
||||
/**
|
||||
* Handlers in {@code packageName} that have {@link Routes#of(Class) resolvable} route metadata.
|
||||
*
|
||||
@@ -33,12 +38,17 @@ final class PackageScanner {
|
||||
* handler class fails to load
|
||||
*/
|
||||
static List<Class<?>> findHandlers(String packageName) {
|
||||
return scan(packageName).httpHandlers();
|
||||
}
|
||||
|
||||
static ScanResult scan(String packageName) {
|
||||
if (packageName == null || packageName.isBlank())
|
||||
throw new InitializationException("scan() called with null or blank package name");
|
||||
|
||||
String resourcePath = packageName.replace('.', '/');
|
||||
ClassLoader cl = Thread.currentThread().getContextClassLoader();
|
||||
List<Class<?>> result = new ArrayList<>();
|
||||
List<Class<?>> http = new ArrayList<>();
|
||||
List<Class<?>> ws = new ArrayList<>();
|
||||
List<String> errors = new ArrayList<>();
|
||||
boolean packageFound = false;
|
||||
|
||||
@@ -49,17 +59,17 @@ final class PackageScanner {
|
||||
URL url = resources.nextElement();
|
||||
String protocol = url.getProtocol();
|
||||
if ("file".equals(protocol)) {
|
||||
scanDirectory(new File(url.toURI()), packageName, cl, result, errors);
|
||||
scanDirectory(new File(url.toURI()), packageName, cl, http, ws, errors);
|
||||
} else if ("jar".equals(protocol)) {
|
||||
String jarPath = url.getPath();
|
||||
String filePart = jarPath.substring(jarPath.indexOf("file:") + 5, jarPath.indexOf('!'));
|
||||
try (JarFile jar = new JarFile(filePart)) {
|
||||
scanJar(jar, resourcePath, packageName, cl, result, errors);
|
||||
scanJar(jar, resourcePath, packageName, cl, http, ws, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (InitializationException e) {
|
||||
throw e; // re-throw our own exceptions
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new InitializationException("Failed to scan package: " + packageName, e);
|
||||
}
|
||||
@@ -74,38 +84,38 @@ final class PackageScanner {
|
||||
"scan(\"" + packageName + "\") — failed to load " + errors.size() + " handler(s):\n • " +
|
||||
String.join("\n • ", errors));
|
||||
|
||||
if (result.isEmpty())
|
||||
if (http.isEmpty() && ws.isEmpty())
|
||||
throw new InitializationException(
|
||||
"scan(\"" + packageName + "\") — no routable handlers found. " +
|
||||
"Ensure classes extend RequestHandler, declare @Route or @GET/@POST/…, are not abstract, " +
|
||||
"and have a public no-arg constructor.");
|
||||
"Ensure classes extend RequestHandler or WebSocketEndpoint, declare the right route annotation, " +
|
||||
"are not abstract, and have a public no-arg constructor.");
|
||||
|
||||
return result;
|
||||
return new ScanResult(List.copyOf(http), List.copyOf(ws));
|
||||
}
|
||||
|
||||
private static void scanDirectory(File dir, String packageName, ClassLoader cl,
|
||||
List<Class<?>> result, List<String> errors) {
|
||||
List<Class<?>> http, List<Class<?>> ws, List<String> errors) {
|
||||
File[] files = dir.listFiles();
|
||||
if (files == null) return;
|
||||
for (File file : files) {
|
||||
if (file.isDirectory()) {
|
||||
scanDirectory(file, packageName + '.' + file.getName(), cl, result, errors);
|
||||
scanDirectory(file, packageName + '.' + file.getName(), cl, http, ws, errors);
|
||||
} else if (file.getName().endsWith(".class") && !isAnonymous(file.getName())) {
|
||||
String className = packageName + '.' + file.getName().replace(".class", "");
|
||||
tryLoad(className, cl, result, errors);
|
||||
tryLoad(className, cl, http, ws, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void scanJar(JarFile jar, String resourcePath, String packageName,
|
||||
ClassLoader cl, List<Class<?>> result, List<String> errors) {
|
||||
ClassLoader cl, List<Class<?>> http, List<Class<?>> ws, List<String> errors) {
|
||||
String prefix = resourcePath + "/";
|
||||
Enumeration<JarEntry> entries = jar.entries();
|
||||
while (entries.hasMoreElements()) {
|
||||
String name = entries.nextElement().getName();
|
||||
if (name.startsWith(prefix) && name.endsWith(".class") && !isAnonymous(name)) {
|
||||
String className = name.replace('/', '.').replace(".class", "");
|
||||
tryLoad(className, cl, result, errors);
|
||||
tryLoad(className, cl, http, ws, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,22 +135,25 @@ final class PackageScanner {
|
||||
}
|
||||
|
||||
private static void tryLoad(String className, ClassLoader cl,
|
||||
List<Class<?>> result, List<String> errors) {
|
||||
List<Class<?>> http, List<Class<?>> ws, List<String> errors) {
|
||||
try {
|
||||
Class<?> cls = cl.loadClass(className);
|
||||
if (!RequestHandler.class.isAssignableFrom(cls)) return;
|
||||
if (java.lang.reflect.Modifier.isAbstract(cls.getModifiers())) return;
|
||||
if (Routes.of(cls) == null) return;
|
||||
if (Modifier.isAbstract(cls.getModifiers())) return;
|
||||
|
||||
// Verify no-arg constructor exists — fail-fast if missing
|
||||
try {
|
||||
cls.getDeclaredConstructor();
|
||||
} catch (NoSuchMethodException e) {
|
||||
errors.add(className + " — missing public no-arg constructor");
|
||||
if (RequestHandler.class.isAssignableFrom(cls)
|
||||
&& Routes.of(cls) != null
|
||||
&& !cls.isAnnotationPresent(Ws.class)) {
|
||||
assertNoArgConstructor(cls, errors);
|
||||
http.add(cls);
|
||||
return;
|
||||
}
|
||||
|
||||
result.add(cls);
|
||||
if (WebSocketEndpoint.class.isAssignableFrom(cls)
|
||||
&& cls.isAnnotationPresent(Ws.class)) {
|
||||
assertNoArgConstructor(cls, errors);
|
||||
ws.add(cls);
|
||||
return;
|
||||
}
|
||||
} catch (ClassNotFoundException e) {
|
||||
errors.add(className + " — class not found: " + e.getMessage());
|
||||
} catch (NoClassDefFoundError e) {
|
||||
@@ -149,4 +162,9 @@ final class PackageScanner {
|
||||
errors.add(className + " — linkage error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertNoArgConstructor(Class<?> cls, List<String> errors) {
|
||||
try { cls.getDeclaredConstructor(); }
|
||||
catch (NoSuchMethodException e) { errors.add(cls.getName() + " — missing public no-arg constructor"); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,13 @@ public class HeaderMap {
|
||||
private int sectionStart;
|
||||
private int sectionEnd;
|
||||
|
||||
// Lazily created, then reused for the life of this HeaderMap (i.e. the connection —
|
||||
// see the class javadoc) across every #forEach call and every header within a call.
|
||||
// Same idiom as #view's per-call anonymous ByteView, just amortized to zero allocations
|
||||
// instead of two per header: the slices are repositioned in place, not reallocated.
|
||||
private Slice nameSlice;
|
||||
private Slice valueSlice;
|
||||
|
||||
/** Resets this map to the header section {@code buffer[sectionStart, sectionEnd)}. */
|
||||
public void reset(byte[] buffer, int sectionStart, int sectionEnd) {
|
||||
this.buffer = buffer;
|
||||
@@ -43,6 +50,62 @@ public class HeaderMap {
|
||||
this.sectionEnd = sectionEnd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visits every header in declaration order without allocating — no per-header {@code
|
||||
* String}/{@link ByteView}/list-entry object, unlike {@link #all()}. {@code name}/{@code
|
||||
* value} are the same two {@link ByteView} instances on every call, repositioned in place;
|
||||
* they are valid only for the duration of that single {@link HeaderConsumer#accept} call —
|
||||
* same "do not retain past the handler" rule as {@link #view}, just per-invocation instead
|
||||
* of per-request. Prefer a non-capturing or field-reusing {@link HeaderConsumer} (see its
|
||||
* javadoc) if the call site itself needs to stay allocation-free too.
|
||||
*
|
||||
* <p>Exists for callers that must handle an open-ended set of header names — e.g. a reverse
|
||||
* proxy forwarding whatever the client sent — where {@link #first}/{@link #all}'s per-name
|
||||
* lookup isn't usable because the set of names isn't known upfront.
|
||||
*/
|
||||
public void forEach(HeaderConsumer consumer) {
|
||||
if (buffer == null) return;
|
||||
if (nameSlice == null) {
|
||||
nameSlice = new Slice();
|
||||
valueSlice = new Slice();
|
||||
}
|
||||
int i = sectionStart;
|
||||
while (i < sectionEnd) {
|
||||
int lineEnd = findCR(i);
|
||||
int colon = findColon(i, lineEnd);
|
||||
if (colon != -1) {
|
||||
int vs = skipSpaces(colon + 1, lineEnd);
|
||||
nameSlice.start = i;
|
||||
nameSlice.len = colon - i;
|
||||
valueSlice.start = vs;
|
||||
valueSlice.len = lineEnd - vs;
|
||||
consumer.accept(nameSlice, valueSlice);
|
||||
}
|
||||
i = lineEnd + 2;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for {@link #forEach}. Implement with a reusable, field-holding instance (reset
|
||||
* before each {@code forEach} call) rather than a capturing lambda if the call site itself
|
||||
* needs to be allocation-free too — a capturing lambda is its own per-call allocation, same
|
||||
* as anywhere else on a hot path (see {@code docs/CODE-STYLE.md} in the Pathway project for
|
||||
* the idiom this mirrors).
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface HeaderConsumer {
|
||||
void accept(ByteView name, ByteView value);
|
||||
}
|
||||
|
||||
/** Mutable zero-copy slice into {@link #buffer} — see {@link #forEach}. */
|
||||
private final class Slice implements ByteView {
|
||||
int start;
|
||||
int len;
|
||||
|
||||
@Override public int length() { return len; }
|
||||
@Override public byte byteAt(int i) { return buffer[start + i]; }
|
||||
}
|
||||
|
||||
/** Returns the first value of header {@code name} (case-insensitive), or {@code null}. */
|
||||
public String first(String name) {
|
||||
long r = findFirst(name);
|
||||
|
||||
@@ -9,6 +9,9 @@ import lombok.ToString;
|
||||
import lombok.Value;
|
||||
import lombok.experimental.NonFinal;
|
||||
|
||||
import javax.net.ssl.SSLSession;
|
||||
import javax.net.ssl.SSLSocket;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -60,12 +63,30 @@ public class Request {
|
||||
@ToString.Exclude
|
||||
InetSocketAddress remoteAddress;
|
||||
|
||||
private Request(RequestLine requestLine, RequestBody body, InetSocketAddress remoteAddress) {
|
||||
/**
|
||||
* The accepted socket for this connection, or {@code null} if plain HTTP — set once per
|
||||
* connection by {@link RequestParser}, same lifetime and reference-only cost as
|
||||
* {@link #remoteAddress}. Every request on the same keep-alive connection shares the
|
||||
* identical instance.
|
||||
*
|
||||
* <p>Never exposed directly: {@link #isSecure()} and {@link #sslSession()} are the public
|
||||
* surface. {@link javax.net.ssl.SSLSocket#getSession()} is deferred to {@link #sslSession()}
|
||||
* rather than called here — by the time a handler can call it, the handshake this connection
|
||||
* needed to reach the handler has already completed, so it is a cached-field read, never a
|
||||
* forced handshake.
|
||||
*/
|
||||
@Getter(lombok.AccessLevel.NONE)
|
||||
@EqualsAndHashCode.Exclude
|
||||
@ToString.Exclude
|
||||
SSLSocket sslSocket;
|
||||
|
||||
private Request(RequestLine requestLine, RequestBody body, InetSocketAddress remoteAddress, SSLSocket sslSocket) {
|
||||
this.requestLine = requestLine;
|
||||
this.body = body;
|
||||
this.pathParams = null;
|
||||
this.queryParams = null;
|
||||
this.remoteAddress = remoteAddress;
|
||||
this.sslSocket = sslSocket;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,19 +96,19 @@ public class Request {
|
||||
*/
|
||||
void setPathParams(PathParams p) { this.pathParams = p; }
|
||||
|
||||
/** Test / manual constructor — {@code remoteAddress()} returns {@code null}. */
|
||||
/** Test / manual constructor — {@code remoteAddress()} returns {@code null}, {@code isSecure()} is {@code false}. */
|
||||
public Request(RequestLine requestLine, byte[] body) {
|
||||
this(requestLine, RequestBody.of(body), null);
|
||||
this(requestLine, RequestBody.of(body), null, null);
|
||||
}
|
||||
|
||||
public static Request forParsed(RequestLine requestLine, InputStream stream,
|
||||
long contentLength, byte[] headerBuf,
|
||||
int bodyStart, int preBufLen,
|
||||
InetSocketAddress remoteAddress) {
|
||||
InetSocketAddress remoteAddress, SSLSocket sslSocket) {
|
||||
RequestBody rb = contentLength > 0 ? new RequestBody(stream, contentLength, headerBuf, bodyStart, preBufLen)
|
||||
: contentLength == 0 ? RequestBody.empty()
|
||||
: /* chunked */ new RequestBody(stream, -1L, null, 0, 0);
|
||||
return new Request(requestLine, rb, remoteAddress);
|
||||
return new Request(requestLine, rb, remoteAddress, sslSocket);
|
||||
}
|
||||
|
||||
// ── Request line ──────────────────────────────────────────────────────────
|
||||
@@ -170,6 +191,21 @@ public class Request {
|
||||
*/
|
||||
public InetSocketAddress remoteAddress() { return remoteAddress; }
|
||||
|
||||
// ── TLS ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Whether this request arrived over TLS (HTTPS). */
|
||||
public boolean isSecure() { return sslSocket != null; }
|
||||
|
||||
/**
|
||||
* Returns the TLS session for this connection, or {@code null} for plain HTTP.
|
||||
* Gives access to {@link SSLSession#getPeerCertificates()} (mTLS — the caller's certificate
|
||||
* chain, if {@code TlsConfig.clientAuth} required or requested one), and to
|
||||
* {@link SSLSession#getCipherSuite()} / {@link SSLSession#getProtocol()} for logging and
|
||||
* diagnostics. {@code null} rather than throwing when {@link #isSecure()} is {@code false} —
|
||||
* check that first, or just null-check the result.
|
||||
*/
|
||||
public SSLSession sslSession() { return sslSocket != null ? sslSocket.getSession() : null; }
|
||||
|
||||
// ── Body ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package dev.relism.flash.routing;
|
||||
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.PathParams;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.fpr.core.MatchResult;
|
||||
import dev.relism.flash.websocket.WebSocketHandler;
|
||||
|
||||
public abstract class AbstractWsRouter {
|
||||
|
||||
public final AbstractWsRouter register(HttpMethod method, String path, WebSocketHandler handler) {
|
||||
return addRoute(method, PathUtils.sanitize(path), handler);
|
||||
}
|
||||
|
||||
public abstract WebSocketHandler route(Request request);
|
||||
|
||||
protected abstract AbstractWsRouter addRoute(HttpMethod method, String path, WebSocketHandler handler);
|
||||
|
||||
protected static void setPathParams(Request request, MatchResult<WebSocketHandler> result,
|
||||
String[] allNames, int methodLen) {
|
||||
int count = result.paramCount();
|
||||
if (count == 0) return;
|
||||
String[] names = new String[count];
|
||||
int[] starts = new int[count];
|
||||
int[] lens = new int[count];
|
||||
for (int i = 0; i < count; i++) {
|
||||
names[i] = allNames[result.keyIdAt(i)];
|
||||
starts[i] = result.startAt(i) - methodLen;
|
||||
lens[i] = result.lenAt(i);
|
||||
}
|
||||
PathParams.inject(request,
|
||||
new PathParams(request.getRequestLine().getPath(), names, starts, lens));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package dev.relism.flash.routing;
|
||||
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Marks a {@link dev.relism.flash.models.RequestHandler} subclass as a
|
||||
* WebSocket endpoint. Resolved at boot time by {@link Routes#of} via the
|
||||
* existing meta-annotation mechanism — no additional reflection required.
|
||||
*
|
||||
* <pre>{@code
|
||||
* @Ws("/chat")
|
||||
* public class ChatHandler extends RequestHandler { ... }
|
||||
* }</pre>
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Route(method = HttpMethod.GET, path = "")
|
||||
public @interface Ws {
|
||||
String value();
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package dev.relism.flash.routing.routers.fastpathrouter;
|
||||
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
import dev.relism.fpr.core.FastPathRouter;
|
||||
import dev.relism.fpr.core.MatchResult;
|
||||
import dev.relism.fpr.core.RouterBuilder;
|
||||
import dev.relism.fpr.core.dsl.StringRouteParser;
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.routing.AbstractWsRouter;
|
||||
import dev.relism.flash.websocket.WebSocketHandler;
|
||||
|
||||
public final class FastPathWsRouterImpl extends AbstractWsRouter {
|
||||
|
||||
private final RouterBuilder<WebSocketHandler> builder = new RouterBuilder<>();
|
||||
private volatile FastPathRouter<ByteView, WebSocketHandler> router;
|
||||
private String[] cachedParamNames;
|
||||
|
||||
@Override
|
||||
protected AbstractWsRouter addRoute(HttpMethod method, String path, WebSocketHandler handler) {
|
||||
builder.add(StringRouteParser.parse(method.name() + path), handler);
|
||||
this.router = null;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WebSocketHandler route(Request request) {
|
||||
ensureCompiled();
|
||||
|
||||
MatchResult<WebSocketHandler> result = Context.result();
|
||||
result.reset();
|
||||
|
||||
HttpMethod method = request.getRequestLine().getMethod();
|
||||
ByteView pathView = request.getRequestLine().getPath();
|
||||
FastPathViews.MethodPathByteView combined = Context.combined();
|
||||
combined.reset(method.getBytes(), pathView);
|
||||
|
||||
int labelId = router.match(combined, result);
|
||||
if (labelId == FastPathRouter.NO_MATCH) return null;
|
||||
|
||||
if (result.paramCount() > 0) {
|
||||
setPathParams(request, result, cachedParamNames, method.getBytes().length);
|
||||
}
|
||||
|
||||
return result.handler();
|
||||
}
|
||||
|
||||
private void ensureCompiled() {
|
||||
if (router == null) {
|
||||
synchronized (this) {
|
||||
if (router == null) {
|
||||
cachedParamNames = builder.paramNames();
|
||||
router = builder.compile();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Context {
|
||||
private static final ThreadLocal<MatchResult<WebSocketHandler>> RESULT =
|
||||
ThreadLocal.withInitial(() -> new MatchResult<>(32, 128));
|
||||
private static final ThreadLocal<FastPathViews.MethodPathByteView> COMBINED =
|
||||
ThreadLocal.withInitial(FastPathViews.MethodPathByteView::new);
|
||||
|
||||
static MatchResult<WebSocketHandler> result() { return RESULT.get(); }
|
||||
static FastPathViews.MethodPathByteView combined() { return COMBINED.get(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package dev.relism.flash.tls;
|
||||
|
||||
/**
|
||||
* Client-certificate requirement for a TLS listener, applied via
|
||||
* {@link TlsConfig#clientAuth(ClientAuth)}.
|
||||
*/
|
||||
public enum ClientAuth {
|
||||
/** No client certificate requested. Default — Flash makes no client-auth call at all. */
|
||||
NONE,
|
||||
/** Client certificate requested; handshake still succeeds if the client presents none. */
|
||||
OPTIONAL,
|
||||
/** Handshake fails unless the client presents a certificate trusted by this listener. */
|
||||
REQUIRE
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package dev.relism.flash.tls;
|
||||
|
||||
import javax.net.ssl.ExtendedSSLSession;
|
||||
import javax.net.ssl.SNIHostName;
|
||||
import javax.net.ssl.SNIServerName;
|
||||
import javax.net.ssl.SSLEngine;
|
||||
import javax.net.ssl.SSLSession;
|
||||
import javax.net.ssl.SSLSocket;
|
||||
import javax.net.ssl.X509ExtendedKeyManager;
|
||||
|
||||
import java.net.Socket;
|
||||
import java.security.KeyStore;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.Principal;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.CertificateParsingException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Wraps a keystore's default {@link X509ExtendedKeyManager} with SNI-based alias selection:
|
||||
* every alias's certificate is inspected for its Subject Alternative Names (Common Name as
|
||||
* fallback) so a keystore holding one entry per domain serves the right certificate per
|
||||
* {@code ClientHello} — no explicit hostname-to-alias mapping needed from the caller.
|
||||
*
|
||||
* <p>{@link #defaultAlias} is simply the keystore's first key entry — same convention as
|
||||
* nginx/HAProxy's {@code default_server}: used when the client sends no SNI, or an SNI name
|
||||
* that matches nothing here.
|
||||
*
|
||||
* <p>Only server-alias selection is overridden; every other {@link X509ExtendedKeyManager}
|
||||
* method (client aliases, certificate chains, private keys) delegates unchanged.
|
||||
*/
|
||||
final class SniKeyManager extends X509ExtendedKeyManager {
|
||||
|
||||
private final X509ExtendedKeyManager delegate;
|
||||
private final Map<String, String> aliasByHostname;
|
||||
private final String defaultAlias;
|
||||
|
||||
SniKeyManager(X509ExtendedKeyManager delegate, KeyStore keyStore) throws KeyStoreException {
|
||||
this.delegate = delegate;
|
||||
|
||||
Map<String, String> byHostname = new HashMap<>();
|
||||
String first = null;
|
||||
for (String alias : Collections.list(keyStore.aliases())) {
|
||||
if (!keyStore.isKeyEntry(alias)) continue;
|
||||
if (first == null) first = alias;
|
||||
Certificate cert = keyStore.getCertificate(alias);
|
||||
if (cert instanceof X509Certificate x509) {
|
||||
for (String host : hostnamesOf(x509)) byHostname.putIfAbsent(host, alias);
|
||||
}
|
||||
}
|
||||
this.aliasByHostname = byHostname;
|
||||
this.defaultAlias = first;
|
||||
}
|
||||
|
||||
/** DNS SANs (preferred) or, failing that, the certificate's CN — all lower-cased for matching. */
|
||||
private static List<String> hostnamesOf(X509Certificate cert) {
|
||||
List<String> names = new ArrayList<>();
|
||||
try {
|
||||
Collection<List<?>> sans = cert.getSubjectAlternativeNames();
|
||||
if (sans != null) {
|
||||
for (List<?> san : sans) {
|
||||
if (san.get(0).equals(2)) // dNSName, see X509Certificate#getSubjectAlternativeNames
|
||||
names.add(san.get(1).toString().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
} catch (CertificateParsingException ignored) {
|
||||
// Fall through to the CN below.
|
||||
}
|
||||
if (names.isEmpty()) {
|
||||
for (String part : cert.getSubjectX500Principal().getName().split(",")) {
|
||||
part = part.trim();
|
||||
if (part.regionMatches(true, 0, "CN=", 0, 3)) {
|
||||
names.add(part.substring(3).toLowerCase(Locale.ROOT));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) {
|
||||
return resolve(engine.getHandshakeSession());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) {
|
||||
return resolve(socket instanceof SSLSocket ssl ? ssl.getHandshakeSession() : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every TLS implementation in practice sends at most one {@code server_name} entry (RFC 6066
|
||||
* permits a list, but only the {@code host_name} type exists and clients send zero or one of
|
||||
* it), so indexing {@code names.get(0)} directly — rather than a for-each, which would
|
||||
* allocate an {@link java.util.Iterator} per handshake — is both correct and allocation-free
|
||||
* on the JDK's own {@code List.copyOf}-backed {@link SSLSession#getRequestedServerNames()},
|
||||
* deterministically rather than relying on the JIT to prove the iterator never escapes.
|
||||
*/
|
||||
private String resolve(SSLSession session) {
|
||||
if (session instanceof ExtendedSSLSession ext) {
|
||||
List<SNIServerName> names = ext.getRequestedServerNames();
|
||||
if (!names.isEmpty() && names.get(0) instanceof SNIHostName host) {
|
||||
String alias = aliasByHostname.get(host.getAsciiName().toLowerCase(Locale.ROOT));
|
||||
if (alias != null) return alias;
|
||||
}
|
||||
}
|
||||
return defaultAlias;
|
||||
}
|
||||
|
||||
// ── Delegated — this class only changes server-alias selection ─────────────
|
||||
|
||||
@Override public String[] getClientAliases(String keyType, Principal[] issuers) { return delegate.getClientAliases(keyType, issuers); }
|
||||
@Override public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { return delegate.chooseClientAlias(keyType, issuers, socket); }
|
||||
@Override public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) { return delegate.chooseEngineClientAlias(keyType, issuers, engine); }
|
||||
@Override public String[] getServerAliases(String keyType, Principal[] issuers) { return delegate.getServerAliases(keyType, issuers); }
|
||||
@Override public X509Certificate[] getCertificateChain(String alias) { return delegate.getCertificateChain(alias); }
|
||||
@Override public PrivateKey getPrivateKey(String alias) { return delegate.getPrivateKey(alias); }
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package dev.relism.flash.tls;
|
||||
|
||||
import javax.net.ssl.KeyManager;
|
||||
import javax.net.ssl.KeyManagerFactory;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLParameters;
|
||||
import javax.net.ssl.SSLServerSocket;
|
||||
import javax.net.ssl.SSLServerSocketFactory;
|
||||
import javax.net.ssl.X509ExtendedKeyManager;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.KeyStore;
|
||||
|
||||
/**
|
||||
* Declarative TLS configuration for a {@link dev.relism.flash.extension.FlashConfiguration.Listener}.
|
||||
*
|
||||
* <h3>Two ways in</h3>
|
||||
* <ul>
|
||||
* <li>{@link #keystore(Path, String)} — Flash builds the {@link SSLContext} from a PKCS12/JKS
|
||||
* keystore. A keystore holding more than one certificate entry gets SNI-based selection
|
||||
* for free (see {@link SniKeyManager}) — no per-hostname config needed. Flash also pins
|
||||
* {@code TLSv1.2}/{@code TLSv1.3} as the enabled protocols; cipher suites are left at the
|
||||
* JDK's own curated default, which each JDK security release keeps current — Flash does
|
||||
* not maintain its own suite allow-list.</li>
|
||||
* <li>{@link #ofContext(SSLContext)} — escape hatch. The given {@link SSLContext} is used
|
||||
* exactly as built: Flash never calls {@code setSSLParameters} on this path unless you
|
||||
* explicitly call {@link #applicationProtocols} or {@link #clientAuth} yourself, so
|
||||
* anything else you configured on it is 100% authoritative.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>{@link #clientAuth(ClientAuth)} and {@link #applicationProtocols(String...)} apply on
|
||||
* either path — they are explicit instructions through this API, not Flash-chosen defaults, so
|
||||
* each is only ever applied when called. Neither has a value by default, on either path.
|
||||
*
|
||||
* <h3>ALPN (e.g. TLS-ALPN-01 / RFC 8737)</h3>
|
||||
* {@link #applicationProtocols(String...)} sets the listener's negotiable protocol list via
|
||||
* {@link SSLParameters#setApplicationProtocols}, inherited by every accepted socket exactly like
|
||||
* {@link ClientAuth} — no per-connection code needed. ALPN is resolved during {@code ClientHello}
|
||||
* processing/{@code ServerHello} production, which always precedes {@code Certificate} production
|
||||
* — so a custom {@link javax.net.ssl.X509ExtendedKeyManager} deciding which certificate to serve
|
||||
* can read the client's negotiated protocol via {@code engine.getHandshakeApplicationProtocol()}
|
||||
* (or {@code ((SSLSocket) socket).getHandshakeApplicationProtocol()}) inside
|
||||
* {@code chooseEngineServerAlias}/{@code chooseServerAlias} and it is already resolved by then.
|
||||
*/
|
||||
public final class TlsConfig {
|
||||
|
||||
private static final String[] SECURE_PROTOCOLS = { "TLSv1.3", "TLSv1.2" };
|
||||
|
||||
private final SSLContext context;
|
||||
private final boolean hardenDefaults;
|
||||
private final ClientAuth clientAuth;
|
||||
private final String[] applicationProtocols;
|
||||
|
||||
private TlsConfig(SSLContext context, boolean hardenDefaults, ClientAuth clientAuth, String[] applicationProtocols) {
|
||||
this.context = context;
|
||||
this.hardenDefaults = hardenDefaults;
|
||||
this.clientAuth = clientAuth;
|
||||
this.applicationProtocols = applicationProtocols;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an {@link SSLContext} from a PKCS12/JKS keystore — type is guessed from the file
|
||||
* extension ({@code .jks} means JKS, anything else PKCS12). The private-key password is
|
||||
* assumed equal to the store password, the common case for PKCS12.
|
||||
*/
|
||||
public static TlsConfig keystore(Path path, String password) {
|
||||
try {
|
||||
KeyStore store = KeyStore.getInstance(path.toString().endsWith(".jks") ? "JKS" : "PKCS12");
|
||||
try (InputStream in = Files.newInputStream(path)) {
|
||||
store.load(in, password.toCharArray());
|
||||
}
|
||||
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
|
||||
kmf.init(store, password.toCharArray());
|
||||
|
||||
KeyManager[] managers = kmf.getKeyManagers();
|
||||
for (int i = 0; i < managers.length; i++) {
|
||||
if (managers[i] instanceof X509ExtendedKeyManager x509) {
|
||||
managers[i] = new SniKeyManager(x509, store);
|
||||
}
|
||||
}
|
||||
|
||||
SSLContext ctx = SSLContext.getInstance("TLS");
|
||||
ctx.init(managers, null, null);
|
||||
return new TlsConfig(ctx, true, ClientAuth.NONE, null);
|
||||
} catch (GeneralSecurityException | IOException e) {
|
||||
throw new IllegalArgumentException("Failed to load TLS keystore: " + path, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape hatch — see class Javadoc. Flash applies nothing to the socket beyond what you
|
||||
* explicitly call ({@link #clientAuth}/{@link #applicationProtocols}) on this instance.
|
||||
*/
|
||||
public static TlsConfig ofContext(SSLContext context) {
|
||||
return new TlsConfig(context, false, ClientAuth.NONE, null);
|
||||
}
|
||||
|
||||
/** Client-certificate requirement. Applies on either construction path — see class Javadoc. */
|
||||
public TlsConfig clientAuth(ClientAuth mode) {
|
||||
return new TlsConfig(context, hardenDefaults, mode, applicationProtocols);
|
||||
}
|
||||
|
||||
/**
|
||||
* ALPN protocols this listener negotiates, in preference order (e.g.
|
||||
* {@code "acme-tls/1", "http/1.1"}). Applies on either construction path — see class Javadoc
|
||||
* for how a custom {@code KeyManager} observes the negotiated value.
|
||||
*/
|
||||
public TlsConfig applicationProtocols(String... protocols) {
|
||||
return new TlsConfig(context, hardenDefaults, clientAuth, protocols.clone());
|
||||
}
|
||||
|
||||
// ── Consumed by HttpServer at bind time — not meant for direct use ──────────
|
||||
|
||||
public SSLServerSocketFactory serverSocketFactory() {
|
||||
return context.getServerSocketFactory();
|
||||
}
|
||||
|
||||
public void applyTo(SSLServerSocket socket) {
|
||||
if (hardenDefaults || applicationProtocols != null) {
|
||||
SSLParameters params = socket.getSSLParameters();
|
||||
if (hardenDefaults) params.setProtocols(SECURE_PROTOCOLS);
|
||||
if (applicationProtocols != null) params.setApplicationProtocols(applicationProtocols);
|
||||
socket.setSSLParameters(params);
|
||||
}
|
||||
if (clientAuth == ClientAuth.REQUIRE) socket.setNeedClientAuth(true);
|
||||
else if (clientAuth == ClientAuth.OPTIONAL) socket.setWantClientAuth(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package dev.relism.flash.websocket;
|
||||
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Base class for class-based WebSocket endpoints registered via {@code @Ws} or
|
||||
* {@code app.ws(path, endpoint)}.
|
||||
*
|
||||
* Lifecycle: bind() once at boot → onOpen / onMessage* / onClose | onError per connection.
|
||||
* No relation to {@link dev.relism.flash.models.RequestHandler} — HTTP and WS lifecycles
|
||||
* are fully separate.
|
||||
*/
|
||||
public abstract class WebSocketEndpoint implements WebSocketHandler {
|
||||
|
||||
private FlashContext ctx;
|
||||
|
||||
/** Called once by framework at boot. Do not call from user code. */
|
||||
public final void bind(FlashContext ctx) {
|
||||
this.ctx = ctx;
|
||||
onInit();
|
||||
}
|
||||
|
||||
/** Override to resolve and cache services before first connection. */
|
||||
protected void onInit() {}
|
||||
|
||||
protected <T> T require(Class<T> type) {
|
||||
checkBound();
|
||||
return ctx.require(type);
|
||||
}
|
||||
|
||||
protected <T> Optional<T> find(Class<T> type) {
|
||||
checkBound();
|
||||
return ctx.find(type);
|
||||
}
|
||||
|
||||
private void checkBound() {
|
||||
if (ctx == null) throw new IllegalStateException(
|
||||
getClass().getSimpleName() + " not bound — register via app.scan() or app.ws()");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package dev.relism.flash.websocket;
|
||||
|
||||
/**
|
||||
* Zero-copy view over {@link WebSocketSession}'s read buffer.
|
||||
* One instance allocated per connection, reset per frame — zero steady-state allocation.
|
||||
*
|
||||
* <p><b>Lifetime contract:</b> valid ONLY within the synchronous scope of
|
||||
* {@link WebSocketHandler#onMessage(WebSocketSession, WebSocketFrame)}.
|
||||
* The underlying buffer is owned by {@link WebSocketSession} and will be
|
||||
* overwritten on the next frame read. Never retain a reference beyond the
|
||||
* callback; use {@link #copyPayload()} when data must outlive the call.
|
||||
*
|
||||
* <p><b>Debug safety:</b> {@link #buffer()} is package-private to prevent
|
||||
* accidental cross-boundary access. External consumers must go through
|
||||
* {@link #copyPayload()} or process in-place within the callback.
|
||||
*/
|
||||
public final class WebSocketFrame {
|
||||
|
||||
public static final byte OP_CONTINUATION = 0x0;
|
||||
public static final byte OP_TEXT = 0x1;
|
||||
public static final byte OP_BINARY = 0x2;
|
||||
public static final byte OP_CLOSE = 0x8;
|
||||
public static final byte OP_PING = 0x9;
|
||||
public static final byte OP_PONG = 0xA;
|
||||
|
||||
private byte[] buf;
|
||||
private int payloadOff;
|
||||
private int payloadLen;
|
||||
private byte opcode;
|
||||
private boolean fin;
|
||||
|
||||
// Monotonically incremented on every reset. Allows callers that hold a
|
||||
// reference beyond onMessage() to detect stale access in assertions/tests.
|
||||
private int generation;
|
||||
|
||||
/** Called by {@link WebSocketSession} only. */
|
||||
void reset(byte[] buf, int off, int len, byte opcode, boolean fin) {
|
||||
this.buf = buf;
|
||||
this.payloadOff = off;
|
||||
this.payloadLen = len;
|
||||
this.opcode = opcode;
|
||||
this.fin = fin;
|
||||
this.generation++;
|
||||
}
|
||||
|
||||
public byte opcode() { return opcode; }
|
||||
public boolean isFin() { return fin; }
|
||||
public int payloadOffset() { return payloadOff; }
|
||||
public int payloadLength() { return payloadLen; }
|
||||
|
||||
/**
|
||||
* Returns the generation counter at the moment of this call.
|
||||
* Store it at callback entry and compare later to detect stale retention:
|
||||
* <pre>{@code
|
||||
* int gen = frame.generation();
|
||||
* executor.submit(() -> {
|
||||
* assert frame.generation() == gen : "frame buffer was recycled!";
|
||||
* });
|
||||
* }</pre>
|
||||
*/
|
||||
public int generation() { return generation; }
|
||||
|
||||
/**
|
||||
* Direct reference to the session read buffer — package-private to prevent
|
||||
* accidental retention outside the websocket package.
|
||||
* External callers: use {@link #copyPayload()}.
|
||||
*/
|
||||
byte[] buffer() { return buf; }
|
||||
|
||||
/**
|
||||
* Copies the payload into a fresh array. Allocates — O(n) in payload size.
|
||||
* Use only when data must outlive the {@code onMessage} callback or be
|
||||
* handed off to another thread. For payloads > a few KB consider wrapping
|
||||
* the result in a pooled {@link java.nio.ByteBuffer} to avoid GC pressure.
|
||||
*/
|
||||
public byte[] copyPayload() {
|
||||
byte[] copy = new byte[payloadLen];
|
||||
System.arraycopy(buf, payloadOff, copy, 0, payloadLen);
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package dev.relism.flash.websocket;
|
||||
|
||||
/**
|
||||
* Handler for a WebSocket endpoint. Lifecycle:
|
||||
* onOpen → onMessage* → onClose | onError.
|
||||
*
|
||||
* Lifetime contract for {@link WebSocketFrame}: valid ONLY within
|
||||
* the synchronous scope of {@link #onMessage}. Do not retain references
|
||||
* to the frame or its buffer across calls — copy with {@link WebSocketFrame#copyPayload()}
|
||||
* if data must outlive the callback.
|
||||
*
|
||||
* OP_CONTINUATION frames are delivered as-is; fragment reassembly is an
|
||||
* application concern.
|
||||
*/
|
||||
public interface WebSocketHandler {
|
||||
void onOpen(WebSocketSession session);
|
||||
void onMessage(WebSocketSession session, WebSocketFrame frame);
|
||||
default void onClose(WebSocketSession session, int code) {}
|
||||
default void onError(WebSocketSession session, Throwable t) {}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package dev.relism.flash.websocket;
|
||||
|
||||
import dev.relism.flash.models.Request;
|
||||
|
||||
import javax.net.ssl.SSLSession;
|
||||
|
||||
import java.io.EOFException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Per-connection WebSocket I/O state. One instance per virtual thread.
|
||||
*
|
||||
* <ul>
|
||||
* <li>
|
||||
* <p>With {@code TCP_NODELAY} enabled on the socket (set in {@code HttpServer}),
|
||||
* Nagle's algorithm is disabled: the kernel sends data as soon as it lands in
|
||||
* the send buffer, without waiting. {@link java.io.BufferedOutputStream} will
|
||||
* still batch multiple small writes into one syscall when they happen in the
|
||||
* same virtual-thread scheduling quantum — giving us coalescing where it's
|
||||
* free, and immediate delivery where it matters.
|
||||
*
|
||||
* <p>The only place an explicit flush is still needed is after the WS
|
||||
* handshake (one-time, not on the hot path) and after the CLOSE frame
|
||||
* (end of session). Both are handled in {@link #close} and in
|
||||
* {@code HttpServer#performHandshake}.</li>
|
||||
*
|
||||
* <li><b>Flush on CLOSE frame</b>: {@link #close} still flushes explicitly
|
||||
* because the CLOSE frame is the last thing written before the stream is
|
||||
* abandoned — without a flush, the 4 bytes could sit in the buffer forever.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h3>Thread safety</h3>
|
||||
* {@link #sendText}, {@link #send}, and {@link #close} are synchronized on
|
||||
* {@code out} and safe to call from threads other than the session loop.
|
||||
* {@link #close} uses a CAS on {@code open} to guarantee exactly-once CLOSE
|
||||
* frame emission under concurrent calls.
|
||||
*/
|
||||
public final class WebSocketSession {
|
||||
|
||||
private final InputStream in;
|
||||
private final OutputStream out;
|
||||
private final byte[] readBuf;
|
||||
private final Request request;
|
||||
private final boolean maskOutgoing;
|
||||
|
||||
private final AtomicBoolean open = new AtomicBoolean(true);
|
||||
private int closeCode = 1000;
|
||||
|
||||
/** 1 opcode byte + up to 8 extended-length bytes + up to 4 mask-key bytes (masked mode only). */
|
||||
private final byte[] hdrScratch = new byte[14];
|
||||
|
||||
public WebSocketSession(InputStream in, OutputStream out, int bufferSize) {
|
||||
this(in, out, bufferSize, null, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param request the HTTP request that upgraded to this session, or {@code null} if the
|
||||
* caller has no use for it (e.g. a session opened as a WS client rather
|
||||
* than accepted as a WS server). Stored as-is, no copy.
|
||||
* @param maskOutgoing {@code true} if this session is acting as a WS <em>client</em> — RFC 6455
|
||||
* requires client-to-server frames to be masked, unlike the server-to-client
|
||||
* direction {@link #writeFrame} originally only supported. See {@link
|
||||
* #writeFrame} for how masking is applied without allocating.
|
||||
*/
|
||||
public WebSocketSession(InputStream in, OutputStream out, int bufferSize, Request request, boolean maskOutgoing) {
|
||||
this.in = in;
|
||||
this.out = out;
|
||||
this.readBuf = new byte[bufferSize];
|
||||
this.request = request;
|
||||
this.maskOutgoing = maskOutgoing;
|
||||
}
|
||||
|
||||
public boolean isOpen() { return open.get(); }
|
||||
public int closeCode() { return closeCode; }
|
||||
|
||||
/** The request that upgraded this connection, or {@code null} — see the 4-arg constructor. */
|
||||
public Request request() { return request; }
|
||||
|
||||
/**
|
||||
* Whether this connection is TLS (WSS). Delegates to the upgrading {@link #request}'s
|
||||
* {@link Request#isSecure()} rather than tracking the socket a second time — the request
|
||||
* already carries it. {@code false} if this session has no backing request (e.g. one opened
|
||||
* in WS *client* mode via the 5-arg constructor with {@code request == null}).
|
||||
*/
|
||||
public boolean isSecure() { return request != null && request.isSecure(); }
|
||||
|
||||
/** TLS session for this connection, or {@code null} for plain WS or no backing request. */
|
||||
public SSLSession sslSession() { return request != null ? request.sslSession() : null; }
|
||||
|
||||
// ── Public send API ────────────────────────────────────────────────────
|
||||
|
||||
public void sendText(byte[] utf8, int off, int len) throws IOException {
|
||||
writeFrame(WebSocketFrame.OP_TEXT, utf8, off, len);
|
||||
}
|
||||
|
||||
public void send(byte[] payload, int off, int len) throws IOException {
|
||||
writeFrame(WebSocketFrame.OP_BINARY, payload, off, len);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a CLOSE frame exactly once.
|
||||
* No flush needed — {@code out} is the raw socket OutputStream (not buffered);
|
||||
* each write goes directly to the kernel send buffer, and TCP_NODELAY ensures
|
||||
* it's transmitted immediately.
|
||||
*/
|
||||
public void close(int code) throws IOException {
|
||||
if (!open.compareAndSet(true, false)) return;
|
||||
synchronized (out) {
|
||||
out.write(0x88);
|
||||
out.write(0x02);
|
||||
out.write((code >> 8) & 0xFF);
|
||||
out.write(code & 0xFF);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Session loop internals ─────────────────────────────────────────────
|
||||
|
||||
public boolean readFrame(WebSocketFrame frame) throws IOException {
|
||||
int b0 = in.read();
|
||||
if (b0 < 0) return false;
|
||||
int b1 = in.read();
|
||||
if (b1 < 0) return false;
|
||||
|
||||
boolean fin = (b0 & 0x80) != 0;
|
||||
byte opcode = (byte) (b0 & 0x0F);
|
||||
boolean masked = (b1 & 0x80) != 0;
|
||||
long payLen = (b1 & 0x7F);
|
||||
|
||||
if (payLen == 126) {
|
||||
payLen = ((in.read() & 0xFF) << 8) | (in.read() & 0xFF);
|
||||
} else if (payLen == 127) {
|
||||
payLen = 0;
|
||||
for (int i = 0; i < 8; i++) payLen = (payLen << 8) | (in.read() & 0xFF);
|
||||
}
|
||||
|
||||
if (payLen > readBuf.length) throw new IOException(
|
||||
"WS frame payload " + payLen + " bytes exceeds buffer " + readBuf.length);
|
||||
|
||||
byte m0 = 0, m1 = 0, m2 = 0, m3 = 0;
|
||||
if (masked) {
|
||||
m0 = (byte) in.read(); m1 = (byte) in.read();
|
||||
m2 = (byte) in.read(); m3 = (byte) in.read();
|
||||
}
|
||||
|
||||
int len = (int) payLen;
|
||||
readFully(readBuf, 0, len);
|
||||
|
||||
if (masked) unmaskInPlace(readBuf, 0, len, m0, m1, m2, m3);
|
||||
|
||||
frame.reset(readBuf, 0, len, opcode, fin);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void sendPong(WebSocketFrame ping) throws IOException {
|
||||
writeFrame(WebSocketFrame.OP_PONG, ping.buffer(), ping.payloadOffset(), ping.payloadLength());
|
||||
}
|
||||
|
||||
public void echo(WebSocketFrame frame) throws IOException {
|
||||
if (frame.opcode() == WebSocketFrame.OP_TEXT)
|
||||
sendText(frame.buffer(), frame.payloadOffset(), frame.payloadLength());
|
||||
else
|
||||
send(frame.buffer(), frame.payloadOffset(), frame.payloadLength());
|
||||
}
|
||||
|
||||
public void closeFromPeer(WebSocketFrame frame) {
|
||||
if (frame.payloadLength() >= 2) {
|
||||
byte[] b = frame.copyPayload();
|
||||
int o = frame.payloadOffset();
|
||||
closeCode = ((b[o] & 0xFF) << 8) | (b[o + 1] & 0xFF);
|
||||
}
|
||||
open.set(false);
|
||||
}
|
||||
|
||||
public void forceClose() {
|
||||
open.set(false);
|
||||
try { in.close(); } catch (IOException ignored) {}
|
||||
}
|
||||
|
||||
// ── Private ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Encodes the WS frame header into {@link #hdrScratch} (at most 14 bytes: 1 opcode + up to 8
|
||||
* extended-length + up to 4 mask-key), then writes header + payload in two bulk calls to the
|
||||
* raw socket stream.
|
||||
*
|
||||
* <p>No {@code flush()} — {@code out} is the unbuffered socket {@link OutputStream}
|
||||
* (see {@code HttpServer#process}). Each {@code write()} lands directly in the
|
||||
* kernel send buffer. With {@code TCP_NODELAY} set on the socket, the kernel
|
||||
* transmits the segment immediately without Nagle coalescing. The two writes
|
||||
* (header then payload) will be merged into a single TCP segment by the kernel
|
||||
* because they arrive faster than the ACK from the peer — exactly the coalescing
|
||||
* we want, at zero cost.
|
||||
*
|
||||
* <p><b>{@link #maskOutgoing} (client mode):</b> RFC 6455 requires every client-to-server frame
|
||||
* to be masked. The mask key is generated into {@link #hdrScratch} (no new allocation — same
|
||||
* fixed field every frame reuses) and the payload is masked <em>in place</em> via {@link
|
||||
* #unmaskInPlace} — XOR is its own inverse, so the exact routine {@link #readFrame} already
|
||||
* uses to unmask an inbound payload masks an outbound one too, with no separate code path and
|
||||
* no copy. This mutates the caller's {@code payload} array as a side effect: callers using
|
||||
* masked mode must not reuse that buffer expecting it unchanged after the call.
|
||||
*/
|
||||
private void writeFrame(byte opcode, byte[] payload, int off, int len) throws IOException {
|
||||
synchronized (out) {
|
||||
int hlen = 0;
|
||||
hdrScratch[hlen++] = (byte) (0x80 | opcode);
|
||||
int maskBit = maskOutgoing ? 0x80 : 0x00;
|
||||
if (len <= 125) {
|
||||
hdrScratch[hlen++] = (byte) (maskBit | len);
|
||||
} else if (len <= 0xFFFF) {
|
||||
hdrScratch[hlen++] = (byte) (maskBit | 126);
|
||||
hdrScratch[hlen++] = (byte) ((len >> 8) & 0xFF);
|
||||
hdrScratch[hlen++] = (byte) (len & 0xFF);
|
||||
} else {
|
||||
hdrScratch[hlen++] = (byte) (maskBit | 127);
|
||||
hdrScratch[hlen++] = 0; hdrScratch[hlen++] = 0;
|
||||
hdrScratch[hlen++] = 0; hdrScratch[hlen++] = 0;
|
||||
hdrScratch[hlen++] = (byte) ((len >> 24) & 0xFF);
|
||||
hdrScratch[hlen++] = (byte) ((len >> 16) & 0xFF);
|
||||
hdrScratch[hlen++] = (byte) ((len >> 8) & 0xFF);
|
||||
hdrScratch[hlen++] = (byte) (len & 0xFF);
|
||||
}
|
||||
if (maskOutgoing) {
|
||||
// ThreadLocalRandom needs no seeding/allocation per call; the four mask bytes are
|
||||
// carved out of one int, never boxed.
|
||||
int mask = ThreadLocalRandom.current().nextInt();
|
||||
byte m0 = (byte) (mask >>> 24), m1 = (byte) (mask >>> 16), m2 = (byte) (mask >>> 8), m3 = (byte) mask;
|
||||
hdrScratch[hlen++] = m0;
|
||||
hdrScratch[hlen++] = m1;
|
||||
hdrScratch[hlen++] = m2;
|
||||
hdrScratch[hlen++] = m3;
|
||||
unmaskInPlace(payload, off, len, m0, m1, m2, m3);
|
||||
}
|
||||
out.write(hdrScratch, 0, hlen);
|
||||
out.write(payload, off, len);
|
||||
// No flush — TCP_NODELAY handles delivery. See Javadoc above.
|
||||
}
|
||||
}
|
||||
|
||||
private void readFully(byte[] buf, int off, int len) throws IOException {
|
||||
int remaining = len;
|
||||
while (remaining > 0) {
|
||||
int n = in.read(buf, off + (len - remaining), remaining);
|
||||
if (n < 0) throw new EOFException("WebSocket stream closed mid-frame");
|
||||
remaining -= n;
|
||||
}
|
||||
}
|
||||
|
||||
private static void unmaskInPlace(byte[] buf, int off, int len,
|
||||
byte m0, byte m1, byte m2, byte m3) {
|
||||
int i = off;
|
||||
int end = off + len;
|
||||
int end4 = off + (len & ~3);
|
||||
while (i < end4) {
|
||||
buf[i] ^= m0;
|
||||
buf[i+1] ^= m1;
|
||||
buf[i+2] ^= m2;
|
||||
buf[i+3] ^= m3;
|
||||
i += 4;
|
||||
}
|
||||
if (i < end) { buf[i++] ^= m0; }
|
||||
if (i < end) { buf[i++] ^= m1; }
|
||||
if (i < end) { buf[i] ^= m2; }
|
||||
}
|
||||
}
|
||||
@@ -228,4 +228,37 @@ class HttpServerTest {
|
||||
assertTrue(second.contains("Connection: keep-alive"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression guard for the shared {@code STREAM_RELAY_BUFFER}: both the non-chunked
|
||||
* ({@code /api/stream}) and chunked ({@code /api/chunked-out}) streaming paths reuse the same
|
||||
* per-connection buffer now — sending one of each back to back on one connection must not
|
||||
* leave either response corrupted by the other reusing the array mid-transfer.
|
||||
*/
|
||||
@Test
|
||||
void testKeepAlive_streamedAndChunkedResponsesOnSameConnectionDontCorruptEachOther() throws Exception {
|
||||
String streamReq = "GET /api/stream HTTP/1.1\r\nHost: localhost\r\n\r\n";
|
||||
String chunkedReq = "GET /api/chunked-out HTTP/1.1\r\nHost: localhost\r\n\r\n";
|
||||
|
||||
try (Socket socket = new Socket("127.0.0.1", port);
|
||||
OutputStream out = socket.getOutputStream();
|
||||
InputStream in = socket.getInputStream()) {
|
||||
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
|
||||
out.write(streamReq.getBytes(StandardCharsets.UTF_8));
|
||||
out.write(chunkedReq.getBytes(StandardCharsets.UTF_8));
|
||||
out.write(streamReq.getBytes(StandardCharsets.UTF_8));
|
||||
out.flush();
|
||||
|
||||
String first = readOneResponse(in);
|
||||
String second = readOneResponse(in);
|
||||
String third = readOneResponse(in);
|
||||
|
||||
assertTrue(first.contains("Content-Length: 23"));
|
||||
assertTrue(first.endsWith("streaming response body"));
|
||||
assertTrue(second.contains("Transfer-Encoding: chunked"));
|
||||
assertTrue(second.endsWith("streaming response body"));
|
||||
assertTrue(third.contains("Content-Length: 23"));
|
||||
assertTrue(third.endsWith("streaming response body"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
package dev.relism.flash;
|
||||
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.tls.ClientAuth;
|
||||
import dev.relism.flash.tls.TestKeystores;
|
||||
import dev.relism.flash.tls.TlsConfig;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import dev.relism.flash.websocket.WebSocketFrame;
|
||||
import dev.relism.flash.websocket.WebSocketHandler;
|
||||
import dev.relism.flash.websocket.WebSocketSession;
|
||||
|
||||
import javax.net.ssl.KeyManager;
|
||||
import javax.net.ssl.KeyManagerFactory;
|
||||
import javax.net.ssl.SNIHostName;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLEngine;
|
||||
import javax.net.ssl.SSLParameters;
|
||||
import javax.net.ssl.SSLPeerUnverifiedException;
|
||||
import javax.net.ssl.SSLSession;
|
||||
import javax.net.ssl.SSLSocket;
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.TrustManagerFactory;
|
||||
import javax.net.ssl.X509ExtendedKeyManager;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.Socket;
|
||||
import java.net.ServerSocket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyStore;
|
||||
import java.security.Principal;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Base64;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* End-to-end TLS coverage over real sockets — same style as {@link HttpServerTest}, just with
|
||||
* an {@link SSLSocketFactory} client instead of a raw one. Certificates are generated per test
|
||||
* via {@link TestKeystores} (JDK {@code keytool}, no fixture files, no extra crypto dependency).
|
||||
*/
|
||||
class HttpServerTlsTest {
|
||||
|
||||
private static final int SOCKET_TIMEOUT_MS = 5000;
|
||||
|
||||
private FlashApp app;
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (app != null) app.stop();
|
||||
}
|
||||
|
||||
private static int freePort() throws IOException {
|
||||
try (ServerSocket s = new ServerSocket(0)) { return s.getLocalPort(); }
|
||||
}
|
||||
|
||||
private static String httpGet(SSLSocket socket, String path) throws IOException {
|
||||
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
|
||||
socket.startHandshake();
|
||||
OutputStream out = socket.getOutputStream();
|
||||
out.write(("GET " + path + " HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
out.flush();
|
||||
return new String(socket.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
// ── Basic HTTPS ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void httpsRequest_servedOverModernTls(@TempDir java.nio.file.Path dir) throws Exception {
|
||||
var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost"));
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.tls(TlsConfig.keystore(ks, "changeit"))
|
||||
.build());
|
||||
app.get("/ping", (req, res) -> "pong");
|
||||
app.start();
|
||||
|
||||
SSLSocketFactory factory = TestKeystores.trustAllClientContext().getSocketFactory();
|
||||
try (SSLSocket socket = (SSLSocket) factory.createSocket("127.0.0.1", port)) {
|
||||
String response = httpGet(socket, "/ping");
|
||||
assertTrue(response.startsWith("HTTP/1.1 200 OK"));
|
||||
assertTrue(response.endsWith("pong"));
|
||||
assertTrue(java.util.List.of("TLSv1.2", "TLSv1.3").contains(socket.getSession().getProtocol()));
|
||||
}
|
||||
}
|
||||
|
||||
// ── SNI: one keystore, two domains, two certificates ────────────────────
|
||||
|
||||
@Test
|
||||
void sni_servesCertificateMatchingRequestedHostname(@TempDir java.nio.file.Path dir) throws Exception {
|
||||
var ks = TestKeystores.build(dir, "sni.p12", "changeit",
|
||||
TestKeystores.Entry.of("a", "a.test"),
|
||||
TestKeystores.Entry.of("b", "b.test"));
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.tls(TlsConfig.keystore(ks, "changeit"))
|
||||
.build());
|
||||
app.get("/ping", (req, res) -> "pong");
|
||||
app.start();
|
||||
|
||||
assertEquals("a.test", peerCn(port, "a.test"));
|
||||
assertEquals("b.test", peerCn(port, "b.test"));
|
||||
// No/unknown SNI falls back to the first keystore entry ("a") — same convention as
|
||||
// nginx/HAProxy's default_server.
|
||||
assertEquals("a.test", peerCn(port, null));
|
||||
}
|
||||
|
||||
private static String peerCn(int port, String sniHostname) throws Exception {
|
||||
SSLContext ctx = TestKeystores.trustAllClientContext();
|
||||
try (SSLSocket socket = (SSLSocket) ctx.getSocketFactory().createSocket("127.0.0.1", port)) {
|
||||
if (sniHostname != null) {
|
||||
SSLParameters params = socket.getSSLParameters();
|
||||
params.setServerNames(java.util.List.of(new SNIHostName(sniHostname)));
|
||||
socket.setSSLParameters(params);
|
||||
}
|
||||
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
|
||||
socket.startHandshake();
|
||||
X509Certificate cert = (X509Certificate) socket.getSession().getPeerCertificates()[0];
|
||||
String dn = cert.getSubjectX500Principal().getName();
|
||||
for (String part : dn.split(",")) {
|
||||
part = part.trim();
|
||||
if (part.regionMatches(true, 0, "CN=", 0, 3)) return part.substring(3);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── mTLS ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void mTls_requireRejectsClientWithNoCertificate(@TempDir java.nio.file.Path dir) throws Exception {
|
||||
var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost"));
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.tls(TlsConfig.keystore(ks, "changeit").clientAuth(ClientAuth.REQUIRE))
|
||||
.build());
|
||||
app.get("/ping", (req, res) -> "pong");
|
||||
app.start();
|
||||
|
||||
SSLSocketFactory factory = TestKeystores.trustAllClientContext().getSocketFactory();
|
||||
try (SSLSocket socket = (SSLSocket) factory.createSocket("127.0.0.1", port)) {
|
||||
// TLS 1.3 validates the (here: empty) client certificate chain only after the
|
||||
// client's Finished message — startHandshake() alone can return cleanly from the
|
||||
// client's point of view. The server's fatal alert only surfaces on the next I/O,
|
||||
// so the round trip (not the handshake call itself) is what must throw.
|
||||
assertThrows(IOException.class, () -> httpGet(socket, "/ping"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void mTls_requireAcceptsClientWithTrustedCertificate(@TempDir java.nio.file.Path dir) throws Exception {
|
||||
var serverKs = TestKeystores.build(dir, "server.p12", "changeit", TestKeystores.Entry.of("srv", "localhost"));
|
||||
var clientKs = TestKeystores.build(dir, "client.p12", "changeit", TestKeystores.Entry.of("cli", "test-client"));
|
||||
|
||||
// Escape hatch: mutual trust needs a trust store on both sides, which the keystore()
|
||||
// convenience path deliberately doesn't expose (see TlsConfig javadoc) — this is
|
||||
// exactly the case ofContext() exists for.
|
||||
KeyStore serverIdentity = load(serverKs, "changeit");
|
||||
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
|
||||
kmf.init(serverIdentity, "changeit".toCharArray());
|
||||
|
||||
KeyStore clientTrust = load(clientKs, "changeit"); // client's own cert acts as its trust anchor here
|
||||
TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
|
||||
tmf.init(clientTrust);
|
||||
|
||||
SSLContext serverCtx = SSLContext.getInstance("TLS");
|
||||
serverCtx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null);
|
||||
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.tls(TlsConfig.ofContext(serverCtx).clientAuth(ClientAuth.REQUIRE))
|
||||
.build());
|
||||
app.get("/ping", (req, res) -> "pong");
|
||||
app.start();
|
||||
|
||||
KeyStore clientIdentity = load(clientKs, "changeit");
|
||||
KeyManagerFactory clientKmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
|
||||
clientKmf.init(clientIdentity, "changeit".toCharArray());
|
||||
SSLContext clientCtx = SSLContext.getInstance("TLS");
|
||||
clientCtx.init(clientKmf.getKeyManagers(), new javax.net.ssl.TrustManager[] { trustAll() }, null);
|
||||
|
||||
try (SSLSocket socket = (SSLSocket) clientCtx.getSocketFactory().createSocket("127.0.0.1", port)) {
|
||||
String response = httpGet(socket, "/ping");
|
||||
assertTrue(response.startsWith("HTTP/1.1 200 OK"));
|
||||
}
|
||||
}
|
||||
|
||||
private static KeyStore load(java.nio.file.Path path, String password) throws Exception {
|
||||
KeyStore store = KeyStore.getInstance("PKCS12");
|
||||
try (InputStream in = java.nio.file.Files.newInputStream(path)) { store.load(in, password.toCharArray()); }
|
||||
return store;
|
||||
}
|
||||
|
||||
private static javax.net.ssl.X509TrustManager trustAll() {
|
||||
return new javax.net.ssl.X509TrustManager() {
|
||||
public void checkClientTrusted(X509Certificate[] chain, String authType) {}
|
||||
public void checkServerTrusted(X509Certificate[] chain, String authType) {}
|
||||
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
|
||||
};
|
||||
}
|
||||
|
||||
// ── Multiple listeners, one app ──────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void multipleListeners_plainAndTlsServeTheSameApp(@TempDir java.nio.file.Path dir) throws Exception {
|
||||
var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost"));
|
||||
int plainPort = freePort();
|
||||
int tlsPort = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.listener(new FlashConfiguration.Listener(plainPort, "127.0.0.1", null))
|
||||
.listener(new FlashConfiguration.Listener(tlsPort, "127.0.0.1", TlsConfig.keystore(ks, "changeit")))
|
||||
.build());
|
||||
app.get("/ping", (req, res) -> "pong");
|
||||
app.start();
|
||||
|
||||
try (Socket socket = new Socket("127.0.0.1", plainPort)) {
|
||||
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
|
||||
socket.getOutputStream().write("GET /ping HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
String response = new String(socket.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||
assertTrue(response.startsWith("HTTP/1.1 200 OK"));
|
||||
}
|
||||
|
||||
SSLSocketFactory factory = TestKeystores.trustAllClientContext().getSocketFactory();
|
||||
try (SSLSocket socket = (SSLSocket) factory.createSocket("127.0.0.1", tlsPort)) {
|
||||
String response = httpGet(socket, "/ping");
|
||||
assertTrue(response.startsWith("HTTP/1.1 200 OK"));
|
||||
}
|
||||
}
|
||||
|
||||
// ── KeyManager failure isolation ──────────────────────────────────────────
|
||||
|
||||
/** Throws on its first invocation (any keyType), then delegates normally — simulates a
|
||||
* one-off failure in a custom KeyManager (e.g. a failed DB lookup or on-demand cert
|
||||
* issuance) without permanently breaking the listener. */
|
||||
private static final class FlakyKeyManager extends X509ExtendedKeyManager {
|
||||
private final X509ExtendedKeyManager delegate;
|
||||
private final AtomicInteger calls = new AtomicInteger();
|
||||
|
||||
FlakyKeyManager(X509ExtendedKeyManager delegate) { this.delegate = delegate; }
|
||||
|
||||
private void maybeFail() {
|
||||
if (calls.getAndIncrement() == 0) throw new RuntimeException("simulated KeyManager failure");
|
||||
}
|
||||
|
||||
@Override public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) {
|
||||
maybeFail();
|
||||
return delegate.chooseEngineServerAlias(keyType, issuers, engine);
|
||||
}
|
||||
@Override public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) {
|
||||
maybeFail();
|
||||
return delegate.chooseServerAlias(keyType, issuers, socket);
|
||||
}
|
||||
@Override public String[] getClientAliases(String keyType, Principal[] issuers) { return delegate.getClientAliases(keyType, issuers); }
|
||||
@Override public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { return delegate.chooseClientAlias(keyType, issuers, socket); }
|
||||
@Override public String[] getServerAliases(String keyType, Principal[] issuers) { return delegate.getServerAliases(keyType, issuers); }
|
||||
@Override public X509Certificate[] getCertificateChain(String alias) { return delegate.getCertificateChain(alias); }
|
||||
@Override public PrivateKey getPrivateKey(String alias) { return delegate.getPrivateKey(alias); }
|
||||
}
|
||||
|
||||
@Test
|
||||
void keyManagerFailure_isolatedToOneConnection_listenerServesTheNext(@TempDir java.nio.file.Path dir) throws Exception {
|
||||
var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost"));
|
||||
KeyStore store = load(ks, "changeit");
|
||||
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
|
||||
kmf.init(store, "changeit".toCharArray());
|
||||
|
||||
KeyManager[] managers = kmf.getKeyManagers();
|
||||
for (int i = 0; i < managers.length; i++) {
|
||||
if (managers[i] instanceof X509ExtendedKeyManager x509) managers[i] = new FlakyKeyManager(x509);
|
||||
}
|
||||
SSLContext ctx = SSLContext.getInstance("TLS");
|
||||
ctx.init(managers, null, null);
|
||||
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.tls(TlsConfig.ofContext(ctx))
|
||||
.build());
|
||||
app.get("/ping", (req, res) -> "pong");
|
||||
app.start();
|
||||
|
||||
SSLSocketFactory factory = TestKeystores.trustAllClientContext().getSocketFactory();
|
||||
|
||||
// Connection 1: the KeyManager throws — handshake must fail, but must not take the
|
||||
// listener down with it.
|
||||
try (SSLSocket socket = (SSLSocket) factory.createSocket("127.0.0.1", port)) {
|
||||
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
|
||||
assertThrows(IOException.class, socket::startHandshake);
|
||||
}
|
||||
|
||||
// Connection 2: same listener, no reconfiguration — must succeed and serve normally.
|
||||
try (SSLSocket socket = (SSLSocket) factory.createSocket("127.0.0.1", port)) {
|
||||
String response = httpGet(socket, "/ping");
|
||||
assertTrue(response.startsWith("HTTP/1.1 200 OK"));
|
||||
assertTrue(response.endsWith("pong"));
|
||||
}
|
||||
}
|
||||
|
||||
// ── ALPN visibility for on-demand cert issuance (TLS-ALPN-01 / RFC 8737) ──
|
||||
|
||||
/** Delegates alias selection, but first records what the KeyManager itself observed as the
|
||||
* negotiated ALPN protocol at the exact point a real ACME-style KeyManager would decide
|
||||
* whether to serve a challenge certificate instead of the real one. */
|
||||
private static final class RecordingKeyManager extends X509ExtendedKeyManager {
|
||||
private final X509ExtendedKeyManager delegate;
|
||||
private final AtomicReference<String> observedProtocol = new AtomicReference<>();
|
||||
|
||||
RecordingKeyManager(X509ExtendedKeyManager delegate) { this.delegate = delegate; }
|
||||
|
||||
@Override public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) {
|
||||
observedProtocol.set(engine.getHandshakeApplicationProtocol());
|
||||
return delegate.chooseEngineServerAlias(keyType, issuers, engine);
|
||||
}
|
||||
@Override public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) {
|
||||
observedProtocol.set(((SSLSocket) socket).getHandshakeApplicationProtocol());
|
||||
return delegate.chooseServerAlias(keyType, issuers, socket);
|
||||
}
|
||||
@Override public String[] getClientAliases(String keyType, Principal[] issuers) { return delegate.getClientAliases(keyType, issuers); }
|
||||
@Override public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { return delegate.chooseClientAlias(keyType, issuers, socket); }
|
||||
@Override public String[] getServerAliases(String keyType, Principal[] issuers) { return delegate.getServerAliases(keyType, issuers); }
|
||||
@Override public X509Certificate[] getCertificateChain(String alias) { return delegate.getCertificateChain(alias); }
|
||||
@Override public PrivateKey getPrivateKey(String alias) { return delegate.getPrivateKey(alias); }
|
||||
}
|
||||
|
||||
private static RecordingKeyManager buildRecordingContext(java.nio.file.Path ks, SSLContext[] outCtx) throws Exception {
|
||||
KeyStore store = load(ks, "changeit");
|
||||
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
|
||||
kmf.init(store, "changeit".toCharArray());
|
||||
|
||||
RecordingKeyManager recorder = null;
|
||||
KeyManager[] managers = kmf.getKeyManagers();
|
||||
for (int i = 0; i < managers.length; i++) {
|
||||
if (managers[i] instanceof X509ExtendedKeyManager x509) {
|
||||
recorder = new RecordingKeyManager(x509);
|
||||
managers[i] = recorder;
|
||||
}
|
||||
}
|
||||
SSLContext ctx = SSLContext.getInstance("TLS");
|
||||
ctx.init(managers, null, null);
|
||||
outCtx[0] = ctx;
|
||||
return recorder;
|
||||
}
|
||||
|
||||
@Test
|
||||
void alpn_negotiatedProtocolIsVisibleToKeyManagerBeforeCertificateIsChosen(@TempDir java.nio.file.Path dir) throws Exception {
|
||||
var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost"));
|
||||
|
||||
SSLContext[] boxedCtx = new SSLContext[1];
|
||||
RecordingKeyManager recorder = buildRecordingContext(ks, boxedCtx);
|
||||
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.tls(TlsConfig.ofContext(boxedCtx[0]).applicationProtocols("acme-tls/1", "http/1.1"))
|
||||
.build());
|
||||
app.get("/ping", (req, res) -> "pong");
|
||||
app.start();
|
||||
|
||||
// Each connection below uses its own fresh client SSLContext, deliberately — reusing one
|
||||
// SSLContext across connections lets JSSE resume the second handshake's TLS session,
|
||||
// which skips the Certificate message (and therefore the KeyManager call) entirely. A
|
||||
// fresh SSLContext has nothing to resume, forcing a full handshake every time, which is
|
||||
// what this test needs to observe the KeyManager on every connection.
|
||||
|
||||
// A client offering only the ACME challenge protocol: the KeyManager must see it
|
||||
// *before* choosing which certificate to serve — this is the hook the challenge-cert
|
||||
// decision hangs off.
|
||||
try (SSLSocket socket = (SSLSocket) TestKeystores.trustAllClientContext()
|
||||
.getSocketFactory().createSocket("127.0.0.1", port)) {
|
||||
SSLParameters params = socket.getSSLParameters();
|
||||
params.setApplicationProtocols(new String[] { "acme-tls/1" });
|
||||
socket.setSSLParameters(params);
|
||||
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
|
||||
socket.startHandshake();
|
||||
assertEquals("acme-tls/1", recorder.observedProtocol.get());
|
||||
assertEquals("acme-tls/1", socket.getApplicationProtocol());
|
||||
}
|
||||
|
||||
// Zero regression: a normal client (http/1.1) behaves exactly as before ALPN existed —
|
||||
// same route, same response, and the KeyManager sees the normal protocol, not the
|
||||
// challenge one.
|
||||
try (SSLSocket socket = (SSLSocket) TestKeystores.trustAllClientContext()
|
||||
.getSocketFactory().createSocket("127.0.0.1", port)) {
|
||||
SSLParameters params = socket.getSSLParameters();
|
||||
params.setApplicationProtocols(new String[] { "http/1.1" });
|
||||
socket.setSSLParameters(params);
|
||||
String response = httpGet(socket, "/ping");
|
||||
assertTrue(response.startsWith("HTTP/1.1 200 OK"));
|
||||
assertTrue(response.endsWith("pong"));
|
||||
assertEquals("http/1.1", recorder.observedProtocol.get());
|
||||
}
|
||||
|
||||
// Zero regression: a client sending no ALPN at all — today's default — is unaffected.
|
||||
try (SSLSocket socket = (SSLSocket) TestKeystores.trustAllClientContext()
|
||||
.getSocketFactory().createSocket("127.0.0.1", port)) {
|
||||
String response = httpGet(socket, "/ping");
|
||||
assertTrue(response.startsWith("HTTP/1.1 200 OK"));
|
||||
assertTrue(response.endsWith("pong"));
|
||||
// "" — not null — is JSSE's sentinel for "peer sent no ALPN extension at all".
|
||||
assertEquals("", recorder.observedProtocol.get());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request.isSecure() / sslSession() ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void request_isSecureAndSessionAvailableOverTls(@TempDir java.nio.file.Path dir) throws Exception {
|
||||
var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost"));
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.tls(TlsConfig.keystore(ks, "changeit"))
|
||||
.build());
|
||||
app.get("/secure-info", (req, res) -> {
|
||||
SSLSession session = req.sslSession();
|
||||
return req.isSecure() + ":" + (session != null) + ":" + (session != null ? session.getCipherSuite() : "");
|
||||
});
|
||||
app.start();
|
||||
|
||||
try (SSLSocket socket = (SSLSocket) TestKeystores.trustAllClientContext()
|
||||
.getSocketFactory().createSocket("127.0.0.1", port)) {
|
||||
String response = httpGet(socket, "/secure-info");
|
||||
assertTrue(response.startsWith("HTTP/1.1 200 OK"));
|
||||
assertTrue(response.contains("true:true:TLS_"),
|
||||
"expected isSecure=true, non-null session, real cipher suite; got: " + response);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void request_peerCertificateVisibleWhenClientPresentsOne(@TempDir java.nio.file.Path dir) throws Exception {
|
||||
var serverKs = TestKeystores.build(dir, "server.p12", "changeit", TestKeystores.Entry.of("srv", "localhost"));
|
||||
var clientKs = TestKeystores.build(dir, "client.p12", "changeit", TestKeystores.Entry.of("cli", "test-client"));
|
||||
|
||||
KeyStore serverIdentity = load(serverKs, "changeit");
|
||||
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
|
||||
kmf.init(serverIdentity, "changeit".toCharArray());
|
||||
KeyStore clientTrust = load(clientKs, "changeit"); // client's own cert as its trust anchor, as elsewhere in this file
|
||||
TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
|
||||
tmf.init(clientTrust);
|
||||
SSLContext serverCtx = SSLContext.getInstance("TLS");
|
||||
serverCtx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null);
|
||||
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
// OPTIONAL, not REQUIRE: proves getPeerCertificates() works without also
|
||||
// re-testing the REQUIRE-rejection path already covered elsewhere in this file.
|
||||
.tls(TlsConfig.ofContext(serverCtx).clientAuth(ClientAuth.OPTIONAL))
|
||||
.build());
|
||||
app.get("/secure-info", (req, res) -> {
|
||||
try {
|
||||
X509Certificate peer = (X509Certificate) req.sslSession().getPeerCertificates()[0];
|
||||
return "peer:" + peer.getSubjectX500Principal().getName();
|
||||
} catch (SSLPeerUnverifiedException e) {
|
||||
return "no-peer-cert";
|
||||
}
|
||||
});
|
||||
app.start();
|
||||
|
||||
KeyStore clientIdentity = load(clientKs, "changeit");
|
||||
KeyManagerFactory clientKmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
|
||||
clientKmf.init(clientIdentity, "changeit".toCharArray());
|
||||
SSLContext clientCtx = SSLContext.getInstance("TLS");
|
||||
clientCtx.init(clientKmf.getKeyManagers(), new TrustManager[] { trustAll() }, null);
|
||||
|
||||
try (SSLSocket socket = (SSLSocket) clientCtx.getSocketFactory().createSocket("127.0.0.1", port)) {
|
||||
String response = httpGet(socket, "/secure-info");
|
||||
assertTrue(response.startsWith("HTTP/1.1 200 OK"));
|
||||
assertTrue(response.contains("peer:CN=test-client"), "response was: " + response);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void request_isNotSecureAndSessionIsNullOnPlainListener() throws Exception {
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder().port(port).host("127.0.0.1").build());
|
||||
app.get("/secure-info", (req, res) -> req.isSecure() + ":" + (req.sslSession() == null));
|
||||
app.start();
|
||||
|
||||
try (Socket socket = new Socket("127.0.0.1", port)) {
|
||||
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
|
||||
socket.getOutputStream().write(
|
||||
"GET /secure-info HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
String response = new String(socket.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||
assertTrue(response.startsWith("HTTP/1.1 200 OK"));
|
||||
assertTrue(response.endsWith("false:true"), "expected isSecure=false, session=null; got: " + response);
|
||||
}
|
||||
}
|
||||
|
||||
// ── WebSocketSession.isSecure() / sslSession() — same info, WSS path ──────
|
||||
|
||||
private static String wsHandshakeKey() {
|
||||
return Base64.getEncoder().encodeToString("flash-tls-test-key".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static String readWsHandshakeResponse(InputStream in) throws IOException {
|
||||
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
|
||||
int b, prev3 = -1, prev2 = -1, prev1 = -1;
|
||||
while ((b = in.read()) != -1) {
|
||||
out.write(b);
|
||||
if (prev3 == '\r' && prev2 == '\n' && prev1 == '\r' && b == '\n') break;
|
||||
prev3 = prev2; prev2 = prev1; prev1 = b;
|
||||
}
|
||||
return out.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@Test
|
||||
void wss_sessionIsSecureAndExposesSslSession(@TempDir java.nio.file.Path dir) throws Exception {
|
||||
var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost"));
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.tls(TlsConfig.keystore(ks, "changeit"))
|
||||
.build());
|
||||
|
||||
AtomicReference<Boolean> observedSecure = new AtomicReference<>();
|
||||
AtomicReference<SSLSession> observedSession = new AtomicReference<>();
|
||||
// onOpen runs on the server's virtual thread, asynchronously with respect to the client
|
||||
// reading the 101 response bytes below (both happen after the same flush, in either
|
||||
// order) — the latch is what makes the assertions below wait for onOpen, not the socket read.
|
||||
java.util.concurrent.CountDownLatch opened = new java.util.concurrent.CountDownLatch(1);
|
||||
app.ws("/echo", new WebSocketHandler() {
|
||||
@Override public void onOpen(WebSocketSession session) {
|
||||
observedSecure.set(session.isSecure());
|
||||
observedSession.set(session.sslSession());
|
||||
opened.countDown();
|
||||
}
|
||||
@Override public void onMessage(WebSocketSession session, WebSocketFrame frame) {}
|
||||
});
|
||||
app.start();
|
||||
|
||||
try (SSLSocket socket = (SSLSocket) TestKeystores.trustAllClientContext()
|
||||
.getSocketFactory().createSocket("127.0.0.1", port)) {
|
||||
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
|
||||
socket.startHandshake();
|
||||
OutputStream out = socket.getOutputStream();
|
||||
out.write(("GET /echo HTTP/1.1\r\n" +
|
||||
"Host: localhost\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Connection: Upgrade\r\n" +
|
||||
"Sec-WebSocket-Key: " + wsHandshakeKey() + "\r\n" +
|
||||
"Sec-WebSocket-Version: 13\r\n\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
out.flush();
|
||||
|
||||
String headers = readWsHandshakeResponse(socket.getInputStream());
|
||||
assertTrue(headers.startsWith("HTTP/1.1 101 Switching Protocols"), "handshake response: " + headers);
|
||||
assertTrue(opened.await(SOCKET_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS),
|
||||
"onOpen was not called within timeout");
|
||||
}
|
||||
|
||||
assertEquals(Boolean.TRUE, observedSecure.get());
|
||||
assertNotNull(observedSession.get());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package dev.relism.flash;
|
||||
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.websocket.WebSocketFrame;
|
||||
import dev.relism.flash.websocket.WebSocketHandler;
|
||||
import dev.relism.flash.websocket.WebSocketSession;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class HttpServerWebSocketTest {
|
||||
|
||||
private FlashApp app;
|
||||
private int port;
|
||||
private final AtomicReference<Integer> closed = new AtomicReference<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
try (ServerSocket s = new ServerSocket(0)) {
|
||||
port = s.getLocalPort();
|
||||
}
|
||||
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port)
|
||||
.host("127.0.0.1")
|
||||
.build());
|
||||
|
||||
app.ws("/chat", new WebSocketHandler() {
|
||||
@Override
|
||||
public void onOpen(WebSocketSession session) {}
|
||||
|
||||
@Override
|
||||
public void onMessage(WebSocketSession session, WebSocketFrame frame) {
|
||||
try {
|
||||
if (frame.opcode() == WebSocketFrame.OP_TEXT) {
|
||||
session.sendText(frame.copyPayload(), frame.payloadOffset(), frame.payloadLength());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.ws("/close", new WebSocketHandler() {
|
||||
@Override public void onOpen(WebSocketSession session) {}
|
||||
@Override public void onMessage(WebSocketSession session, WebSocketFrame frame) {}
|
||||
@Override public void onClose(WebSocketSession session, int code) { closed.set(code); }
|
||||
});
|
||||
|
||||
app.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (app != null) app.stop();
|
||||
}
|
||||
|
||||
private static final int SOCKET_TIMEOUT_MS = 5000;
|
||||
|
||||
private static String handshakeKey() {
|
||||
return Base64.getEncoder().encodeToString("flash-test-key".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static String readHeaders(InputStream in) throws Exception {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
int b, prev3 = -1, prev2 = -1, prev1 = -1;
|
||||
while ((b = in.read()) != -1) {
|
||||
out.write(b);
|
||||
if (prev3 == '\r' && prev2 == '\n' && prev1 == '\r' && b == '\n') break;
|
||||
prev3 = prev2; prev2 = prev1; prev1 = b;
|
||||
}
|
||||
return out.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static byte[] textFrame(String message) {
|
||||
byte[] payload = message.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] frame = new byte[payload.length + 6];
|
||||
frame[0] = (byte) 0x81;
|
||||
frame[1] = (byte) (0x80 | payload.length);
|
||||
byte[] mask = {1, 2, 3, 4};
|
||||
System.arraycopy(mask, 0, frame, 2, 4);
|
||||
for (int i = 0; i < payload.length; i++) {
|
||||
frame[6 + i] = (byte) (payload[i] ^ mask[i & 3]);
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
private static byte[] closeFrame(int code) {
|
||||
byte[] frame = new byte[8];
|
||||
frame[0] = (byte) 0x88;
|
||||
frame[1] = (byte) 0x82;
|
||||
byte[] mask = {1, 2, 3, 4};
|
||||
System.arraycopy(mask, 0, frame, 2, 4);
|
||||
frame[6] = (byte) (((code >> 8) & 0xFF) ^ mask[0]);
|
||||
frame[7] = (byte) ((code & 0xFF) ^ mask[1]);
|
||||
return frame;
|
||||
}
|
||||
|
||||
@Test
|
||||
void websocket_upgrade_returns101AndEchoesText() throws Exception {
|
||||
try (Socket socket = new Socket("127.0.0.1", port);
|
||||
InputStream in = socket.getInputStream();
|
||||
OutputStream out = socket.getOutputStream()) {
|
||||
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
|
||||
|
||||
String key = handshakeKey();
|
||||
String req = "GET /chat HTTP/1.1\r\n" +
|
||||
"Host: localhost\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Connection: keep-alive, Upgrade\r\n" +
|
||||
"Sec-WebSocket-Key: " + key + "\r\n" +
|
||||
"Sec-WebSocket-Version: 13\r\n\r\n";
|
||||
out.write(req.getBytes(StandardCharsets.UTF_8));
|
||||
out.flush();
|
||||
|
||||
String headers = readHeaders(in);
|
||||
assertTrue(headers.startsWith("HTTP/1.1 101 Switching Protocols"));
|
||||
assertTrue(headers.contains("Upgrade: websocket"));
|
||||
assertTrue(headers.contains("Connection: Upgrade"));
|
||||
assertTrue(headers.contains("Sec-WebSocket-Accept: "));
|
||||
|
||||
out.write(textFrame("hello"));
|
||||
out.flush();
|
||||
|
||||
byte[] frame = in.readNBytes(7);
|
||||
assertEquals((byte) 0x81, frame[0]);
|
||||
assertEquals((byte) 0x05, frame[1]);
|
||||
assertEquals("hello", new String(frame, 2, 5, StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void websocket_ping_is_ponged() throws Exception {
|
||||
try (Socket socket = new Socket("127.0.0.1", port);
|
||||
InputStream in = socket.getInputStream();
|
||||
OutputStream out = socket.getOutputStream()) {
|
||||
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
|
||||
|
||||
out.write(("GET /chat HTTP/1.1\r\n" +
|
||||
"Host: localhost\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Connection: Upgrade\r\n" +
|
||||
"Sec-WebSocket-Key: " + handshakeKey() + "\r\n" +
|
||||
"Sec-WebSocket-Version: 13\r\n\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
out.flush();
|
||||
readHeaders(in);
|
||||
|
||||
out.write(new byte[] {(byte) 0x89, (byte) 0x80, 1, 2, 3, 4});
|
||||
out.flush();
|
||||
|
||||
byte[] pong = in.readNBytes(2);
|
||||
assertEquals((byte) 0x8A, pong[0]);
|
||||
assertEquals((byte) 0x00, pong[1]);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void websocket_close_frame_closesSession() throws Exception {
|
||||
try (Socket socket = new Socket("127.0.0.1", port);
|
||||
InputStream in = socket.getInputStream();
|
||||
OutputStream out = socket.getOutputStream()) {
|
||||
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
|
||||
|
||||
out.write(("GET /close HTTP/1.1\r\n" +
|
||||
"Host: localhost\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Connection: Upgrade\r\n" +
|
||||
"Sec-WebSocket-Key: " + handshakeKey() + "\r\n" +
|
||||
"Sec-WebSocket-Version: 13\r\n\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
out.flush();
|
||||
readHeaders(in);
|
||||
|
||||
out.write(closeFrame(1000));
|
||||
out.flush();
|
||||
|
||||
Thread.sleep(100);
|
||||
assertEquals(1000, closed.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package dev.relism.flash.extension;
|
||||
|
||||
import dev.relism.flash.websocket.WebSocketFrame;
|
||||
import dev.relism.flash.websocket.WebSocketHandler;
|
||||
import dev.relism.flash.websocket.WebSocketSession;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.net.ServerSocket;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class FlashAppWebSocketTest {
|
||||
|
||||
private FlashApp app;
|
||||
private int port;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
try (ServerSocket s = new ServerSocket(0)) {
|
||||
port = s.getLocalPort();
|
||||
}
|
||||
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port)
|
||||
.host("127.0.0.1")
|
||||
.build());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (app != null) app.stop();
|
||||
}
|
||||
|
||||
@Test
|
||||
void ws_registersDirectEndpoint() {
|
||||
WebSocketHandler handler = new WebSocketHandler() {
|
||||
public void onOpen(WebSocketSession session) {}
|
||||
public void onMessage(WebSocketSession session, WebSocketFrame frame) {}
|
||||
};
|
||||
|
||||
assertSame(app, app.ws("/chat", handler));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package dev.relism.flash.extension;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class PackageScannerTest {
|
||||
|
||||
@Test
|
||||
void scan_separatesHttpHandlersAndWsEndpoints() {
|
||||
PackageScanner.ScanResult result = PackageScanner.scan("dev.relism.flash.websocket.onlyws");
|
||||
|
||||
assertTrue(result.wsEndpoints().stream().anyMatch(c -> c.getSimpleName().equals("OnlyWsEndpoint")));
|
||||
assertFalse(result.httpHandlers().stream().anyMatch(c -> c.getSimpleName().equals("OnlyWsEndpoint")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void scan_includesHttpHandlerAndWsEndpointFromTestPackage() {
|
||||
PackageScanner.ScanResult result = PackageScanner.scan("dev.relism.flash.websocket.scantest");
|
||||
|
||||
assertTrue(result.httpHandlers().stream().anyMatch(c -> c.getSimpleName().equals("ScanHttpHandler")));
|
||||
assertTrue(result.wsEndpoints().stream().anyMatch(c -> c.getSimpleName().equals("ScanWsEndpoint")));
|
||||
}
|
||||
}
|
||||
@@ -90,4 +90,41 @@ class HeaderMapTest {
|
||||
assertTrue(map.all("Host").isEmpty());
|
||||
assertTrue(map.all().isEmpty());
|
||||
}
|
||||
|
||||
// --- forEach ---
|
||||
|
||||
@Test
|
||||
void forEach_visitsEveryHeaderInDeclarationOrder() {
|
||||
HeaderMap map = parse("Host: localhost", "Accept: text/plain", "Cookie: a=1");
|
||||
List<String> seen = new java.util.ArrayList<>();
|
||||
map.forEach((name, value) -> seen.add(toStr(name) + "=" + toStr(value)));
|
||||
assertEquals(List.of("Host=localhost", "Accept=text/plain", "Cookie=a=1"), seen);
|
||||
}
|
||||
|
||||
@Test
|
||||
void forEach_emptyMap_neverInvokesConsumer() {
|
||||
HeaderMap map = new HeaderMap();
|
||||
map.forEach((name, value) -> fail("must not be called on an empty map"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void forEach_reusesTheSameTwoViewInstancesAcrossEveryHeader() {
|
||||
// The zero-allocation contract: forEach must reposition two ByteViews in place, not
|
||||
// allocate a fresh pair per header — same instances across all three calls here.
|
||||
HeaderMap map = parse("A: 1", "B: 2", "C: 3");
|
||||
List<ByteView> names = new java.util.ArrayList<>();
|
||||
List<ByteView> values = new java.util.ArrayList<>();
|
||||
map.forEach((name, value) -> { names.add(name); values.add(value); });
|
||||
|
||||
assertSame(names.get(0), names.get(1));
|
||||
assertSame(names.get(1), names.get(2));
|
||||
assertSame(values.get(0), values.get(1));
|
||||
assertSame(values.get(1), values.get(2));
|
||||
}
|
||||
|
||||
private static String toStr(ByteView v) {
|
||||
byte[] b = new byte[v.length()];
|
||||
for (int i = 0; i < b.length; i++) b[i] = v.byteAt(i);
|
||||
return new String(b, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package dev.relism.flash.routing;
|
||||
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.websocket.WebSocketHandler;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AbstractWsRouterTest {
|
||||
|
||||
static class DummyWsRouter extends AbstractWsRouter {
|
||||
WebSocketHandler lastHandler;
|
||||
HttpMethod lastMethod;
|
||||
String lastPath;
|
||||
|
||||
@Override
|
||||
public WebSocketHandler route(Request request) { return null; }
|
||||
|
||||
@Override
|
||||
protected AbstractWsRouter addRoute(HttpMethod method, String path, WebSocketHandler handler) {
|
||||
this.lastMethod = method;
|
||||
this.lastPath = path;
|
||||
this.lastHandler = handler;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_sanitizesPathAndStoresHandler() {
|
||||
DummyWsRouter router = new DummyWsRouter();
|
||||
WebSocketHandler handler = new WebSocketHandler() {
|
||||
public void onOpen(dev.relism.flash.websocket.WebSocketSession session) {}
|
||||
public void onMessage(dev.relism.flash.websocket.WebSocketSession session, dev.relism.flash.websocket.WebSocketFrame frame) {}
|
||||
};
|
||||
|
||||
router.register(HttpMethod.GET, "chat/", handler);
|
||||
|
||||
assertEquals(HttpMethod.GET, router.lastMethod);
|
||||
assertEquals("/chat", router.lastPath);
|
||||
assertSame(handler, router.lastHandler);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package dev.relism.flash.tls;
|
||||
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyManagementException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Test-only: builds self-signed PKCS12 keystores via the JDK's own {@code keytool} so TLS
|
||||
* tests need no external certificate fixtures and no extra crypto test-dependency.
|
||||
*/
|
||||
public final class TestKeystores {
|
||||
private TestKeystores() {}
|
||||
|
||||
public record Entry(String alias, String cn, String... sans) {
|
||||
public static Entry of(String alias, String cn, String... sans) { return new Entry(alias, cn, sans); }
|
||||
}
|
||||
|
||||
public static Path build(Path dir, String fileName, String password, Entry... entries)
|
||||
throws IOException, InterruptedException {
|
||||
Path keystore = dir.resolve(fileName);
|
||||
String keytool = Path.of(System.getProperty("java.home"), "bin", "keytool").toString();
|
||||
for (Entry e : entries) {
|
||||
List<String> cmd = new ArrayList<>(List.of(
|
||||
keytool, "-genkeypair", "-noprompt",
|
||||
"-alias", e.alias(),
|
||||
"-keyalg", "RSA", "-keysize", "2048",
|
||||
"-validity", "3650",
|
||||
"-keystore", keystore.toString(),
|
||||
"-storetype", "PKCS12",
|
||||
"-storepass", password,
|
||||
"-dname", "CN=" + e.cn()));
|
||||
if (e.sans().length > 0) {
|
||||
StringBuilder san = new StringBuilder();
|
||||
for (String s : e.sans()) {
|
||||
if (!san.isEmpty()) san.append(',');
|
||||
san.append("dns:").append(s);
|
||||
}
|
||||
cmd.add("-ext");
|
||||
cmd.add("SAN=" + san);
|
||||
}
|
||||
Process p = new ProcessBuilder(cmd).redirectErrorStream(true).start();
|
||||
String output = new String(p.getInputStream().readAllBytes());
|
||||
if (p.waitFor() != 0) throw new IOException("keytool failed: " + output);
|
||||
}
|
||||
return keystore;
|
||||
}
|
||||
|
||||
/** A client-side {@link SSLContext} that trusts any server certificate — self-signed test certs only. */
|
||||
public static SSLContext trustAllClientContext() throws NoSuchAlgorithmException, KeyManagementException {
|
||||
TrustManager trustAll = new X509TrustManager() {
|
||||
public void checkClientTrusted(X509Certificate[] chain, String authType) {}
|
||||
public void checkServerTrusted(X509Certificate[] chain, String authType) {}
|
||||
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
|
||||
};
|
||||
SSLContext ctx = SSLContext.getInstance("TLS");
|
||||
ctx.init(null, new TrustManager[] { trustAll }, null);
|
||||
return ctx;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package dev.relism.flash.tls;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLParameters;
|
||||
import javax.net.ssl.SSLServerSocket;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class TlsConfigTest {
|
||||
|
||||
private static SSLServerSocket unboundSocket(TlsConfig tls) throws IOException {
|
||||
return (SSLServerSocket) tls.serverSocketFactory().createServerSocket();
|
||||
}
|
||||
|
||||
@Test
|
||||
void keystore_pinsProtocolsToTls12And13(@TempDir Path dir) throws Exception {
|
||||
Path ks = TestKeystores.build(dir, "id.p12", "changeit",
|
||||
TestKeystores.Entry.of("only", "single.test"));
|
||||
TlsConfig tls = TlsConfig.keystore(ks, "changeit");
|
||||
|
||||
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||
tls.applyTo(socket);
|
||||
List<String> protocols = Arrays.asList(socket.getSSLParameters().getProtocols());
|
||||
assertTrue(protocols.contains("TLSv1.2"));
|
||||
assertTrue(protocols.contains("TLSv1.3"));
|
||||
assertFalse(protocols.contains("SSLv3"));
|
||||
assertFalse(protocols.contains("TLSv1"));
|
||||
assertFalse(protocols.contains("TLSv1.1"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void ofContext_appliesNoParameterOverlay() throws Exception {
|
||||
SSLContext ctx = TestKeystores.trustAllClientContext(); // any valid SSLContext will do here
|
||||
TlsConfig tls = TlsConfig.ofContext(ctx);
|
||||
|
||||
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||
SSLParameters before = socket.getSSLParameters();
|
||||
String[] protocolsBefore = before.getProtocols();
|
||||
|
||||
tls.applyTo(socket);
|
||||
|
||||
assertArrayEquals(protocolsBefore, socket.getSSLParameters().getProtocols(),
|
||||
"ofContext must not narrow/override protocols set on the caller's SSLContext");
|
||||
assertFalse(socket.getNeedClientAuth());
|
||||
assertFalse(socket.getWantClientAuth());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void ofContext_preservesCallerConfiguredAlpnAndProtocols() throws Exception {
|
||||
// Mirrors the real use case this escape hatch exists for (TLS-ALPN-01 / RFC 8737):
|
||||
// the caller sets its own ALPN protocol list — and, to make the point unambiguous,
|
||||
// a protocol list *narrower* than what Flash's own keystore() path would pin — directly
|
||||
// on the socket. applyTo() must not touch either. There is no SSLContext#setDefault-
|
||||
// SSLParameters in the public JSSE API, so this per-socket SSLParameters is the only
|
||||
// place such configuration can live; this test is the contract that makes it safe to
|
||||
// rely on.
|
||||
SSLContext ctx = TestKeystores.trustAllClientContext();
|
||||
TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.OPTIONAL);
|
||||
|
||||
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||
SSLParameters custom = socket.getSSLParameters();
|
||||
custom.setApplicationProtocols(new String[] { "acme-tls/1", "http/1.1" });
|
||||
custom.setProtocols(new String[] { "TLSv1.3" });
|
||||
socket.setSSLParameters(custom);
|
||||
|
||||
tls.applyTo(socket);
|
||||
|
||||
SSLParameters after = socket.getSSLParameters();
|
||||
assertArrayEquals(new String[] { "acme-tls/1", "http/1.1" }, after.getApplicationProtocols(),
|
||||
"ofContext must not touch ALPN protocols the caller configured on its own socket");
|
||||
assertArrayEquals(new String[] { "TLSv1.3" }, after.getProtocols(),
|
||||
"ofContext must not widen/override the caller's own protocol list");
|
||||
// clientAuth still applies — it is the caller's own explicit instruction through
|
||||
// this API, not a Flash-imposed default. See TlsConfig's class Javadoc.
|
||||
assertTrue(socket.getWantClientAuth());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void clientAuth_none_makesNoClientAuthCall() throws Exception {
|
||||
SSLContext ctx = TestKeystores.trustAllClientContext();
|
||||
TlsConfig tls = TlsConfig.ofContext(ctx);
|
||||
|
||||
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||
tls.applyTo(socket);
|
||||
assertFalse(socket.getNeedClientAuth());
|
||||
assertFalse(socket.getWantClientAuth());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void clientAuth_require_setsNeedClientAuth() throws Exception {
|
||||
SSLContext ctx = TestKeystores.trustAllClientContext();
|
||||
TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.REQUIRE);
|
||||
|
||||
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||
tls.applyTo(socket);
|
||||
assertTrue(socket.getNeedClientAuth());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void clientAuth_optional_setsWantClientAuth() throws Exception {
|
||||
SSLContext ctx = TestKeystores.trustAllClientContext();
|
||||
TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.OPTIONAL);
|
||||
|
||||
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||
tls.applyTo(socket);
|
||||
assertTrue(socket.getWantClientAuth());
|
||||
assertFalse(socket.getNeedClientAuth());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package dev.relism.flash.websocket;
|
||||
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class WebSocketEndpointTest {
|
||||
|
||||
static class DummyEndpoint extends WebSocketEndpoint {
|
||||
boolean initCalled;
|
||||
|
||||
@Override
|
||||
protected void onInit() {
|
||||
initCalled = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onOpen(WebSocketSession session) {}
|
||||
|
||||
@Override
|
||||
public void onMessage(WebSocketSession session, WebSocketFrame frame) {}
|
||||
}
|
||||
|
||||
@Test
|
||||
void bind_callsOnInit() {
|
||||
DummyEndpoint endpoint = new DummyEndpoint();
|
||||
|
||||
endpoint.bind(new FlashContext());
|
||||
|
||||
assertTrue(endpoint.initCalled);
|
||||
}
|
||||
|
||||
@Test
|
||||
void requireBeforeBind_throws() {
|
||||
DummyEndpoint endpoint = new DummyEndpoint();
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> endpoint.require(String.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package dev.relism.flash.websocket;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class WebSocketFrameTest {
|
||||
|
||||
@Test
|
||||
void copyPayload_copiesActiveSlice() {
|
||||
WebSocketFrame frame = new WebSocketFrame();
|
||||
byte[] buf = "hello".getBytes();
|
||||
frame.reset(buf, 1, 3, WebSocketFrame.OP_TEXT, true);
|
||||
|
||||
byte[] copy = frame.copyPayload();
|
||||
|
||||
assertArrayEquals("ell".getBytes(), copy);
|
||||
assertNotSame(buf, copy);
|
||||
assertEquals(WebSocketFrame.OP_TEXT, frame.opcode());
|
||||
assertTrue(frame.isFin());
|
||||
assertEquals(1, frame.payloadOffset());
|
||||
assertEquals(3, frame.payloadLength());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package dev.relism.flash.websocket;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class WebSocketSessionFrameTest {
|
||||
|
||||
@Test
|
||||
void readFrame_unmasksMaskedPayload() throws Exception {
|
||||
byte[] raw = new byte[] {
|
||||
(byte) 0x81,
|
||||
(byte) 0x85,
|
||||
1, 2, 3, 4,
|
||||
(byte) ('h' ^ 1),
|
||||
(byte) ('i' ^ 2),
|
||||
(byte) ('!' ^ 3),
|
||||
(byte) ('!' ^ 4),
|
||||
(byte) ('?' ^ 1)
|
||||
};
|
||||
|
||||
WebSocketSession session = new WebSocketSession(new ByteArrayInputStream(raw), new ByteArrayOutputStream(), 16);
|
||||
WebSocketFrame frame = new WebSocketFrame();
|
||||
|
||||
assertTrue(session.readFrame(frame));
|
||||
assertEquals(WebSocketFrame.OP_TEXT, frame.opcode());
|
||||
assertTrue(frame.isFin());
|
||||
assertEquals(5, frame.payloadLength());
|
||||
assertEquals("hi!!?", new String(frame.copyPayload(), frame.payloadOffset(), frame.payloadLength()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readFrame_rejectsOversizedPayload() {
|
||||
byte[] raw = new byte[] {(byte) 0x82, (byte) 0x7E, 0x01, 0x00};
|
||||
WebSocketSession session = new WebSocketSession(new ByteArrayInputStream(raw), new ByteArrayOutputStream(), 8);
|
||||
WebSocketFrame frame = new WebSocketFrame();
|
||||
|
||||
assertThrows(Exception.class, () -> session.readFrame(frame));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package dev.relism.flash.websocket;
|
||||
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.HeaderMap;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.RequestLine;
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class WebSocketSessionTest {
|
||||
|
||||
private static ByteView viewOf(String s) {
|
||||
byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
|
||||
return new ByteView() {
|
||||
public int length() { return bytes.length; }
|
||||
public byte byteAt(int idx) { return bytes[idx]; }
|
||||
};
|
||||
}
|
||||
|
||||
@Test
|
||||
void request_returnsWhatWasPassedToConstructor() {
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/chat"), null, viewOf("HTTP/1.1"), new HeaderMap());
|
||||
Request req = new Request(line, new byte[0]);
|
||||
WebSocketSession session = new WebSocketSession(
|
||||
new ByteArrayInputStream(new byte[0]), new ByteArrayOutputStream(), 64, req, false);
|
||||
|
||||
assertSame(req, session.request());
|
||||
}
|
||||
|
||||
@Test
|
||||
void request_defaultsToNullOnThreeArgConstructor() {
|
||||
WebSocketSession session = new WebSocketSession(new ByteArrayInputStream(new byte[0]), new ByteArrayOutputStream(), 64);
|
||||
|
||||
assertNull(session.request());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendText_masksWhenActingAsClient() throws Exception {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
WebSocketSession session = new WebSocketSession(
|
||||
new ByteArrayInputStream(new byte[0]), out, 64, null, true);
|
||||
|
||||
byte[] payload = "hi".getBytes();
|
||||
session.sendText(payload, 0, payload.length);
|
||||
|
||||
byte[] bytes = out.toByteArray();
|
||||
assertEquals((byte) 0x81, bytes[0]); // FIN + TEXT
|
||||
assertEquals((byte) (0x80 | 2), bytes[1]); // masked bit + length 2
|
||||
byte m0 = bytes[2], m1 = bytes[3], m2 = bytes[4], m3 = bytes[5];
|
||||
assertEquals((byte) ('h' ^ m0), bytes[6]);
|
||||
assertEquals((byte) ('i' ^ m1), bytes[7]);
|
||||
// The caller's buffer is mutated in place by the mask (documented, zero-copy tradeoff).
|
||||
assertEquals((byte) ('h' ^ m0), payload[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendText_doesNotMaskWhenActingAsServer() throws Exception {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
WebSocketSession session = new WebSocketSession(new ByteArrayInputStream(new byte[0]), out, 64);
|
||||
|
||||
byte[] payload = "hi".getBytes();
|
||||
session.sendText(payload, 0, payload.length);
|
||||
|
||||
byte[] bytes = out.toByteArray();
|
||||
assertEquals((byte) 0x81, bytes[0]);
|
||||
assertEquals((byte) 2, bytes[1]); // no masked bit
|
||||
assertEquals('h', bytes[2]);
|
||||
assertEquals('i', bytes[3]);
|
||||
}
|
||||
|
||||
@Test
|
||||
void close_setsClosedAndWritesFrame() throws Exception {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
WebSocketSession session = new WebSocketSession(new ByteArrayInputStream(new byte[0]), out, 64);
|
||||
|
||||
session.close(1000);
|
||||
|
||||
assertFalse(session.isOpen());
|
||||
assertEquals(1000, session.closeCode());
|
||||
byte[] bytes = out.toByteArray();
|
||||
assertEquals((byte) 0x88, bytes[0]);
|
||||
assertEquals((byte) 0x02, bytes[1]);
|
||||
assertEquals((byte) 0x03, bytes[2]);
|
||||
assertEquals((byte) 0xE8, bytes[3]);
|
||||
}
|
||||
|
||||
@Test
|
||||
void closeFromPeer_extractsCloseCode() {
|
||||
WebSocketSession session = new WebSocketSession(new ByteArrayInputStream(new byte[0]), new ByteArrayOutputStream(), 64);
|
||||
WebSocketFrame frame = new WebSocketFrame();
|
||||
frame.reset(new byte[] {(byte) 0x03, (byte) 0xE8}, 0, 2, WebSocketFrame.OP_CLOSE, true);
|
||||
|
||||
session.closeFromPeer(frame);
|
||||
|
||||
assertFalse(session.isOpen());
|
||||
assertEquals(1000, session.closeCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package dev.relism.flash.websocket.onlyws;
|
||||
|
||||
import dev.relism.flash.routing.Ws;
|
||||
import dev.relism.flash.websocket.WebSocketEndpoint;
|
||||
import dev.relism.flash.websocket.WebSocketFrame;
|
||||
import dev.relism.flash.websocket.WebSocketSession;
|
||||
|
||||
@Ws("/only")
|
||||
public class OnlyWsEndpoint extends WebSocketEndpoint {
|
||||
|
||||
@Override
|
||||
public void onOpen(WebSocketSession session) {}
|
||||
|
||||
@Override
|
||||
public void onMessage(WebSocketSession session, WebSocketFrame frame) {
|
||||
try {
|
||||
session.sendText(frame.copyPayload(), frame.payloadOffset(), frame.payloadLength());
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package dev.relism.flash.websocket.scantest;
|
||||
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.RequestHandler;
|
||||
import dev.relism.flash.models.Response;
|
||||
import dev.relism.flash.routing.GET;
|
||||
|
||||
@GET("/http")
|
||||
public class ScanHttpHandler extends RequestHandler {
|
||||
|
||||
@Override
|
||||
public Object handle(Request request, Response response) {
|
||||
return "http";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package dev.relism.flash.websocket.scantest;
|
||||
|
||||
import dev.relism.flash.routing.Ws;
|
||||
import dev.relism.flash.websocket.WebSocketEndpoint;
|
||||
import dev.relism.flash.websocket.WebSocketFrame;
|
||||
import dev.relism.flash.websocket.WebSocketSession;
|
||||
|
||||
@Ws("/ws")
|
||||
public class ScanWsEndpoint extends WebSocketEndpoint {
|
||||
|
||||
@Override
|
||||
public void onOpen(WebSocketSession session) {}
|
||||
|
||||
@Override
|
||||
public void onMessage(WebSocketSession session, WebSocketFrame frame) {
|
||||
try {
|
||||
session.sendText(frame.copyPayload(), frame.payloadOffset(), frame.payloadLength());
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-parent</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
<packaging>pom</packaging>
|
||||
|
||||
<modules>
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
#-------------------------------------------------------------------------------#
|
||||
# Qodana analysis is configured by qodana.yaml file #
|
||||
# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
|
||||
#-------------------------------------------------------------------------------#
|
||||
|
||||
#################################################################################
|
||||
# WARNING: Do not store sensitive information in this file, #
|
||||
# as its contents will be included in the Qodana report. #
|
||||
#################################################################################
|
||||
version: "1.0"
|
||||
|
||||
#Specify inspection profile for code analysis
|
||||
profile:
|
||||
name: qodana.starter
|
||||
|
||||
#Enable inspections
|
||||
#include:
|
||||
# - name: <SomeEnabledInspectionId>
|
||||
|
||||
#Disable inspections
|
||||
#exclude:
|
||||
# - name: <SomeDisabledInspectionId>
|
||||
# paths:
|
||||
# - <path/where/not/run/inspection>
|
||||
|
||||
projectJDK: "21" #(Applied in CI/CD pipeline)
|
||||
|
||||
#Execute shell command before Qodana execution (Applied in CI/CD pipeline)
|
||||
#bootstrap: sh ./prepare-qodana.sh
|
||||
|
||||
#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
|
||||
#plugins:
|
||||
# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)
|
||||
|
||||
# Quality gate. Will fail the CI/CD pipeline if any condition is not met
|
||||
# severityThresholds - configures maximum thresholds for different problem severities
|
||||
# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code
|
||||
# Code Coverage is available in Ultimate and Ultimate Plus plans
|
||||
#failureConditions:
|
||||
# severityThresholds:
|
||||
# any: 15
|
||||
# critical: 5
|
||||
# testCoverageThresholds:
|
||||
# fresh: 70
|
||||
# total: 50
|
||||
|
||||
#Qodana supports other languages, for example, Python, JavaScript, TypeScript, Go, C#, PHP
|
||||
#For all supported languages see https://www.jetbrains.com/help/qodana/linters.html
|
||||
linter: jetbrains/qodana-jvm-community:2025.3
|
||||
Reference in New Issue
Block a user