Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0c5eb7bd9 | ||
|
|
f2f11c895a | ||
|
|
02ea247dab |
@@ -1,17 +0,0 @@
|
|||||||
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0
|
|
||||||
http://maven.apache.org/xsd/maven-1.0.0.xsd">
|
|
||||||
<servers>
|
|
||||||
<server>
|
|
||||||
<id>Personal</id>
|
|
||||||
<username>${env.MAVEN_USERNAME}</username>
|
|
||||||
<password>${env.MAVEN_PASSWORD}</password>
|
|
||||||
</server>
|
|
||||||
<server>
|
|
||||||
<id>Personal-snapshots</id>
|
|
||||||
<username>${env.MAVEN_USERNAME}</username>
|
|
||||||
<password>${env.MAVEN_PASSWORD}</password>
|
|
||||||
</server>
|
|
||||||
</servers>
|
|
||||||
</settings>
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
- 'feature/**'
|
|
||||||
- 'fix/**'
|
|
||||||
- 'hotfix/**'
|
|
||||||
tags-ignore:
|
|
||||||
- 'v*'
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build & Test
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Temurin 21
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: 21
|
|
||||||
cache: maven
|
|
||||||
server-id: Personal
|
|
||||||
server-username: MAVEN_USERNAME
|
|
||||||
server-password: MAVEN_PASSWORD
|
|
||||||
|
|
||||||
- name: Build and test
|
|
||||||
run: mvn -B --settings .github/settings.xml clean verify
|
|
||||||
env:
|
|
||||||
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
|
||||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Publish test results
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
name: test-results
|
|
||||||
path: '**/target/surefire-reports/*.xml'
|
|
||||||
retention-days: 7
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
name: Prepare Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: 'Release version (e.g. 2.0.0)'
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
next_version:
|
|
||||||
description: 'Next development version without -SNAPSHOT (e.g. 2.1.0)'
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
prepare:
|
|
||||||
name: Bump, Tag & Push
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Set up Temurin 21
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: 21
|
|
||||||
cache: maven
|
|
||||||
|
|
||||||
- name: Configure Git
|
|
||||||
run: |
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
|
|
||||||
- name: Set release version
|
|
||||||
run: mvn -B --settings .github/settings.xml versions:set -DnewVersion=${{ inputs.version }}
|
|
||||||
env:
|
|
||||||
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
|
||||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Commit release version
|
|
||||||
run: |
|
|
||||||
git add -A
|
|
||||||
git commit -m "chore(release): ${{ inputs.version }}"
|
|
||||||
|
|
||||||
- name: Tag release
|
|
||||||
run: git tag v${{ inputs.version }}
|
|
||||||
|
|
||||||
- name: Set next snapshot version
|
|
||||||
run: mvn -B --settings .github/settings.xml versions:set -DnewVersion=${{ inputs.next_version }}-SNAPSHOT
|
|
||||||
env:
|
|
||||||
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
|
||||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Commit next snapshot version
|
|
||||||
run: |
|
|
||||||
git add -A
|
|
||||||
git commit -m "chore(release): prepare ${{ inputs.next_version }}-SNAPSHOT"
|
|
||||||
|
|
||||||
- name: Push commits and tag
|
|
||||||
run: |
|
|
||||||
git push origin master
|
|
||||||
git push origin v${{ inputs.version }}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
name: Build, Sign, Deploy & Publish
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pages: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Extract version from tag
|
|
||||||
id: version
|
|
||||||
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Set up Temurin 21
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: 21
|
|
||||||
cache: maven
|
|
||||||
|
|
||||||
- name: Import GPG key
|
|
||||||
run: |
|
|
||||||
echo "${{ secrets.GPG_PRIVATE_KEY }}" | gpg --batch --import
|
|
||||||
GPG_KEY_ID=$(gpg --list-secret-keys --with-colons | grep '^sec' | cut -d: -f5 | head -1)
|
|
||||||
echo "GPG_KEY_ID=$GPG_KEY_ID" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Build, sign and deploy to releases
|
|
||||||
run: |
|
|
||||||
mvn -B --settings .github/settings.xml \
|
|
||||||
-DperformRelease=true \
|
|
||||||
-Dgpg.keyname=$GPG_KEY_ID \
|
|
||||||
clean deploy
|
|
||||||
env:
|
|
||||||
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
|
||||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
|
||||||
|
|
||||||
- name: 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 }}
|
|
||||||
generate_release_notes: true
|
|
||||||
draft: false
|
|
||||||
prerelease: false
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
name: Publish Snapshot
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
tags-ignore:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
snapshot:
|
|
||||||
name: Deploy Snapshot
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Temurin 21
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: 21
|
|
||||||
cache: maven
|
|
||||||
|
|
||||||
- name: Deploy snapshot
|
|
||||||
run: mvn -B --settings .github/settings.xml clean deploy -DskipTests
|
|
||||||
env:
|
|
||||||
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
|
||||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
|
||||||
-57
@@ -1,57 +0,0 @@
|
|||||||
target/
|
|
||||||
*.versionsBackup
|
|
||||||
.mvn/timing.properties
|
|
||||||
*.class
|
|
||||||
!.mvn/wrapper/maven-wrapper.jar
|
|
||||||
!**/src/main/**/target/
|
|
||||||
!**/src/test/**/target/
|
|
||||||
.kotlin
|
|
||||||
|
|
||||||
### IntelliJ IDEA ###
|
|
||||||
.idea/modules.xml
|
|
||||||
.idea/jarRepositories.xml
|
|
||||||
.idea/compiler.xml
|
|
||||||
.idea/libraries/
|
|
||||||
.idea/workspace.xml
|
|
||||||
.idea/inspectionProfiles/
|
|
||||||
*.iws
|
|
||||||
*.iml
|
|
||||||
*.ipr
|
|
||||||
|
|
||||||
### Eclipse ###
|
|
||||||
.apt_generated
|
|
||||||
.classpath
|
|
||||||
.factorypath
|
|
||||||
.project
|
|
||||||
.settings
|
|
||||||
.springBeans
|
|
||||||
.sts4-cache
|
|
||||||
|
|
||||||
### NetBeans ###
|
|
||||||
/nbproject/private/
|
|
||||||
/nbbuild/
|
|
||||||
/dist/
|
|
||||||
/nbdist/
|
|
||||||
/.nb-gradle/
|
|
||||||
build/
|
|
||||||
!**/src/main/**/build/
|
|
||||||
!**/src/test/**/build/
|
|
||||||
|
|
||||||
### VS Code ###
|
|
||||||
.vscode/
|
|
||||||
|
|
||||||
### Mac OS ###
|
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
CLAUDE.md
|
|
||||||
.claude/
|
|
||||||
|
|
||||||
### Local / scratch ###
|
|
||||||
flash/src/main/java/dev/relism/Main.java
|
|
||||||
flash-bench/
|
|
||||||
nuxt-shadcn-dashboard/
|
|
||||||
/dev/
|
|
||||||
/docs/
|
|
||||||
jmh-result.text
|
|
||||||
*.text
|
|
||||||
/flash-extensions/flash-ext-routeviewer/routeviewer-ui/node_modules/
|
|
||||||
Generated
-6
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="AgentMigrationStateService">
|
|
||||||
<option name="migrationStatus" value="COMPLETED" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
Generated
-6
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="AskMigrationStateService">
|
|
||||||
<option name="migrationStatus" value="COMPLETED" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
-6
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="Ask2AgentMigrationStateService">
|
|
||||||
<option name="migrationStatus" value="COMPLETED" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
Generated
-6
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="EditMigrationStateService">
|
|
||||||
<option name="migrationStatus" value="COMPLETED" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
Generated
-43
@@ -1,43 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="Encoding">
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-bench/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-bench/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-api/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-api/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/flash/src/main/resources" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
Generated
-6
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="JavaScriptLibraryMappings">
|
|
||||||
<excludedPredefinedLibrary name="Flash/nuxt-shadcn-dashboard/node_modules" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
Generated
-25
@@ -1,25 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="EntryPointsManager">
|
|
||||||
<writeAnnotations>
|
|
||||||
<writeAnnotation name="lombok.Getter" />
|
|
||||||
</writeAnnotations>
|
|
||||||
</component>
|
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
|
||||||
<component name="MavenProjectsManager">
|
|
||||||
<option name="originalFiles">
|
|
||||||
<list>
|
|
||||||
<option value="$PROJECT_DIR$/pom.xml" />
|
|
||||||
<option value="$PROJECT_DIR$/flash-bench/pom.xml" />
|
|
||||||
</list>
|
|
||||||
</option>
|
|
||||||
<option name="ignoredFiles">
|
|
||||||
<set>
|
|
||||||
<option value="$PROJECT_DIR$/flash-bench/pom.xml" />
|
|
||||||
</set>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK">
|
|
||||||
<output url="file://$PROJECT_DIR$/out" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
Generated
-6
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
Generated
-691
@@ -1,691 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="AutoImportSettings">
|
|
||||||
<option name="autoReloadType" value="SELECTIVE" />
|
|
||||||
</component>
|
|
||||||
<component name="ChangeListManager">
|
|
||||||
<list default="true" id="fc757130-fe3e-4ea9-8d69-c26ffb8545a4" name="Changes" comment="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" />
|
|
||||||
<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="CopilotPersistence">
|
|
||||||
<persistenceIdMap>
|
|
||||||
<entry key="_C:/Users/elorc/Documents/Coding/Java/practice/Flash" value="3Axc6mzLyNvh4TgGFvNSCSq83xw" />
|
|
||||||
<entry key="_C:/Users/elorc/Documents/Coding/Java/practice/FlashPractice" value="3AoiAx4zfxMcuO6fDI9oALOPcq1" />
|
|
||||||
<entry key="_C:/Users/relis/Documents/coding/personale/Java/Flash5" value="3CTWwiHEXMdUOOMmV3iz3FrRc6h" />
|
|
||||||
</persistenceIdMap>
|
|
||||||
</component>
|
|
||||||
<component name="EmbeddingIndexingInfo">
|
|
||||||
<option name="cachedIndexableFilesCount" value="407" />
|
|
||||||
<option name="fileBasedEmbeddingIndicesEnabled" value="true" />
|
|
||||||
</component>
|
|
||||||
<component name="FileTemplateManagerImpl">
|
|
||||||
<option name="RECENT_TEMPLATES">
|
|
||||||
<list>
|
|
||||||
<option value="Enum" />
|
|
||||||
<option value="Interface" />
|
|
||||||
<option value="Class" />
|
|
||||||
</list>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
<component name="Git.Settings">
|
|
||||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
|
||||||
<option name="ROOT_SYNC" value="DONT_SYNC" />
|
|
||||||
</component>
|
|
||||||
<component name="GitHubPullRequestSearchHistory">{
|
|
||||||
"lastFilter": {
|
|
||||||
"state": "OPEN",
|
|
||||||
"assignee": "Relism"
|
|
||||||
}
|
|
||||||
}</component>
|
|
||||||
<component name="GithubPullRequestsUISettings">{
|
|
||||||
"selectedUrlAndAccountId": {
|
|
||||||
"url": "https://github.com/Relism/Flash5.git",
|
|
||||||
"accountId": "86d8a39c-af27-4b79-8d5d-dc74375c3348"
|
|
||||||
}
|
|
||||||
}</component>
|
|
||||||
<component name="McpProjectServerCommands">
|
|
||||||
<commands />
|
|
||||||
<urls />
|
|
||||||
</component>
|
|
||||||
<component name="ProblemsViewState">
|
|
||||||
<option name="selectedTabId" value="ProjectErrors" />
|
|
||||||
</component>
|
|
||||||
<component name="ProjectColorInfo">{
|
|
||||||
"associatedIndex": 3
|
|
||||||
}</component>
|
|
||||||
<component name="ProjectId" id="3AoiAx4zfxMcuO6fDI9oALOPcq1" />
|
|
||||||
<component name="ProjectViewState">
|
|
||||||
<option name="hideEmptyMiddlePackages" value="true" />
|
|
||||||
<option name="showLibraryContents" value="true" />
|
|
||||||
</component>
|
|
||||||
<component name="PropertiesComponent">{
|
|
||||||
"keyToString": {
|
|
||||||
"Application.(dev) flash-bench.executor": "Run",
|
|
||||||
"Application.ExternalBenchmark (1).executor": "Run",
|
|
||||||
"Application.ExternalBenchmark.executor": "Run",
|
|
||||||
"Application.Main.executor": "Run",
|
|
||||||
"Application.MainAlt.executor": "Run",
|
|
||||||
"Application.dev.relism.bench.Main.executor": "Run",
|
|
||||||
"JUnit.RequestParserTest.executor": "Run",
|
|
||||||
"JUnit.RequestParserTest.headers_caseInsensitive.executor": "Debug",
|
|
||||||
"Maven.FlashPractice [test].executor": "Run",
|
|
||||||
"Maven.flash [compile].executor": "Run",
|
|
||||||
"Maven.flash [install].executor": "Run",
|
|
||||||
"Maven.flash [test].executor": "Run",
|
|
||||||
"Maven.flash [verify].executor": "Run",
|
|
||||||
"Maven.flash-bench [clean].executor": "Run",
|
|
||||||
"Maven.flash-bench [install].executor": "Run",
|
|
||||||
"Maven.flash-bench [package].executor": "Run",
|
|
||||||
"Maven.flash-bench [validate].executor": "Run",
|
|
||||||
"Maven.flash-ext-limiter [install].executor": "Run",
|
|
||||||
"Maven.flash-ext-limiter [package].executor": "Run",
|
|
||||||
"Maven.flash-ext-view [verify].executor": "Run",
|
|
||||||
"Maven.flash-parent [clean].executor": "Run",
|
|
||||||
"Maven.flash-parent [compile].executor": "Run",
|
|
||||||
"Maven.flash-parent [deploy].executor": "Run",
|
|
||||||
"Maven.flash-parent [install].executor": "Run",
|
|
||||||
"Maven.flash-parent [package].executor": "Run",
|
|
||||||
"Maven.flash-parent [test].executor": "Run",
|
|
||||||
"Maven.flash-parent [validate].executor": "Run",
|
|
||||||
"Maven.flash-parent [verify].executor": "Run",
|
|
||||||
"Maven.flash-web-bundler [test].executor": "Run",
|
|
||||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
|
||||||
"RunOnceActivity.MCP Project settings loaded": "true",
|
|
||||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
|
||||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
|
||||||
"RunOnceActivity.git.unshallow": "true",
|
|
||||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
|
||||||
"SHARE_PROJECT_CONFIGURATION_FILES": "true",
|
|
||||||
"codeWithMe.voiceChat.enabledByDefault": "false",
|
|
||||||
"git-widget-placeholder": "master",
|
|
||||||
"ignore.virus.scanning.warn.message": "true",
|
|
||||||
"kotlin-language-version-configured": "true",
|
|
||||||
"last_opened_file_path": "C:/Users/elorc/Documents/Coding/Java/practice/Flash",
|
|
||||||
"node.js.detected.package.eslint": "true",
|
|
||||||
"node.js.detected.package.tslint": "true",
|
|
||||||
"node.js.selected.package.eslint": "(autodetect)",
|
|
||||||
"node.js.selected.package.tslint": "(autodetect)",
|
|
||||||
"nodejs_package_manager_path": "npm",
|
|
||||||
"npm.build.executor": "Run",
|
|
||||||
"onboarding.tips.debug.path": "C:/Users/elorc/Documents/Coding/Java/practice/Flash/flash-extensions/flash-ext-data/src/main/java/dev/relism/Main.java",
|
|
||||||
"project.structure.last.edited": "Modules",
|
|
||||||
"project.structure.proportion": "0.15",
|
|
||||||
"project.structure.side.proportion": "0.1150748",
|
|
||||||
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings",
|
|
||||||
"ts.external.directory.path": "C:\\Users\\elorc\\Documents\\Coding\\Java\\practice\\Flash\\nuxt-shadcn-dashboard\\node_modules\\typescript\\lib",
|
|
||||||
"vue.rearranger.settings.migration": "true"
|
|
||||||
}
|
|
||||||
}</component>
|
|
||||||
<component name="RecentsManager">
|
|
||||||
<key name="MoveFile.RECENT_KEYS">
|
|
||||||
<recent name="C:\Users\elorc\Documents\Coding\Java\practice\Flash" />
|
|
||||||
</key>
|
|
||||||
<key name="MoveClassesOrPackagesDialog.RECENTS_KEY">
|
|
||||||
<recent name="dev.relism" />
|
|
||||||
</key>
|
|
||||||
</component>
|
|
||||||
<component name="RunManager" selected="Application.MainAlt">
|
|
||||||
<configuration name="ExternalBenchmark" type="Application" factoryName="Application" temporary="true" nameIsGenerated="true">
|
|
||||||
<option name="MAIN_CLASS_NAME" value="dev.relism.bench.ExternalBenchmark" />
|
|
||||||
<module name="flash" />
|
|
||||||
<extension name="coverage">
|
|
||||||
<pattern>
|
|
||||||
<option name="PATTERN" value="dev.relism.bench.*" />
|
|
||||||
<option name="ENABLED" value="true" />
|
|
||||||
</pattern>
|
|
||||||
</extension>
|
|
||||||
<method v="2">
|
|
||||||
<option name="Make" enabled="true" />
|
|
||||||
</method>
|
|
||||||
</configuration>
|
|
||||||
<configuration name="MainAlt" type="Application" factoryName="Application" temporary="true" nameIsGenerated="true">
|
|
||||||
<option name="MAIN_CLASS_NAME" value="dev.relism.bench.MainAlt" />
|
|
||||||
<module name="flash-bench" />
|
|
||||||
<option name="VM_PARAMETERS" value="-Dflash.env=dev" />
|
|
||||||
<extension name="coverage">
|
|
||||||
<pattern>
|
|
||||||
<option name="PATTERN" value="dev.relism.bench.*" />
|
|
||||||
<option name="ENABLED" value="true" />
|
|
||||||
</pattern>
|
|
||||||
</extension>
|
|
||||||
<method v="2">
|
|
||||||
<option name="Make" enabled="true" />
|
|
||||||
</method>
|
|
||||||
</configuration>
|
|
||||||
<configuration name="(dev) flash-bench" type="Application" factoryName="Application">
|
|
||||||
<option name="MAIN_CLASS_NAME" value="dev.relism.bench.Main" />
|
|
||||||
<module name="flash-bench" />
|
|
||||||
<option name="VM_PARAMETERS" value="-Dflash.env=dev" />
|
|
||||||
<extension name="coverage">
|
|
||||||
<pattern>
|
|
||||||
<option name="PATTERN" value="dev.relism.bench.*" />
|
|
||||||
<option name="ENABLED" value="true" />
|
|
||||||
</pattern>
|
|
||||||
</extension>
|
|
||||||
<method v="2">
|
|
||||||
<option name="Make" enabled="true" />
|
|
||||||
</method>
|
|
||||||
</configuration>
|
|
||||||
<list>
|
|
||||||
<item itemvalue="Application.(dev) flash-bench" />
|
|
||||||
<item itemvalue="Application.ExternalBenchmark" />
|
|
||||||
<item itemvalue="Application.MainAlt" />
|
|
||||||
</list>
|
|
||||||
<recent_temporary>
|
|
||||||
<list>
|
|
||||||
<item itemvalue="Application.MainAlt" />
|
|
||||||
</list>
|
|
||||||
</recent_temporary>
|
|
||||||
</component>
|
|
||||||
<component name="SharedIndexes">
|
|
||||||
<attachedChunks>
|
|
||||||
<set>
|
|
||||||
<option value="bundled-jdk-30f59d01ecdd-2fc7cc6b9a17-intellij.indexing.shared.core-IU-253.30387.90" />
|
|
||||||
<option value="bundled-js-predefined-d6986cc7102b-9b0f141eb926-JavaScript-IU-253.30387.90" />
|
|
||||||
</set>
|
|
||||||
</attachedChunks>
|
|
||||||
</component>
|
|
||||||
<component name="TaskManager">
|
|
||||||
<task active="true" id="Default" summary="Default task">
|
|
||||||
<changelist id="fc757130-fe3e-4ea9-8d69-c26ffb8545a4" name="Changes" comment="" />
|
|
||||||
<created>1773265186049</created>
|
|
||||||
<option name="number" value="Default" />
|
|
||||||
<option name="presentableId" value="Default" />
|
|
||||||
<updated>1773265186049</updated>
|
|
||||||
<workItem from="1773265187409" duration="5496000" />
|
|
||||||
<workItem from="1773313172181" duration="9617000" />
|
|
||||||
<workItem from="1773325837582" duration="3918000" />
|
|
||||||
<workItem from="1773400995673" duration="23805000" />
|
|
||||||
<workItem from="1773442006686" duration="2722000" />
|
|
||||||
<workItem from="1773491408276" duration="23095000" />
|
|
||||||
<workItem from="1773528595525" duration="7400000" />
|
|
||||||
<workItem from="1773536222450" duration="680000" />
|
|
||||||
<workItem from="1773536904928" duration="395000" />
|
|
||||||
<workItem from="1773537302622" duration="150000" />
|
|
||||||
<workItem from="1773537489622" duration="5048000" />
|
|
||||||
<workItem from="1773576241900" duration="22639000" />
|
|
||||||
<workItem from="1773613172976" duration="13157000" />
|
|
||||||
<workItem from="1773668722120" duration="389000" />
|
|
||||||
<workItem from="1773680650110" duration="4711000" />
|
|
||||||
<workItem from="1773701882230" duration="6975000" />
|
|
||||||
<workItem from="1773744806275" duration="48612000" />
|
|
||||||
<workItem from="1773831300464" duration="1538000" />
|
|
||||||
<workItem from="1773837116508" duration="4255000" />
|
|
||||||
<workItem from="1773847734658" duration="911000" />
|
|
||||||
<workItem from="1773875652058" duration="5481000" />
|
|
||||||
<workItem from="1773915392332" duration="36877000" />
|
|
||||||
<workItem from="1773996650761" duration="101000" />
|
|
||||||
<workItem from="1774011413085" duration="2519000" />
|
|
||||||
<workItem from="1774033964550" duration="5379000" />
|
|
||||||
<workItem from="1774116033963" duration="1364000" />
|
|
||||||
<workItem from="1774136286683" duration="3816000" />
|
|
||||||
<workItem from="1774187096932" duration="25000" />
|
|
||||||
<workItem from="1774458099414" duration="14351000" />
|
|
||||||
<workItem from="1774523863906" duration="9762000" />
|
|
||||||
<workItem from="1774555172612" duration="9776000" />
|
|
||||||
<workItem from="1774604979874" duration="16474000" />
|
|
||||||
<workItem from="1774628513273" duration="86000" />
|
|
||||||
<workItem from="1774638461244" duration="5718000" />
|
|
||||||
<workItem from="1774691772785" duration="3801000" />
|
|
||||||
<workItem from="1774703412987" duration="25271000" />
|
|
||||||
<workItem from="1774777423667" duration="2127000" />
|
|
||||||
<workItem from="1774790933314" duration="10099000" />
|
|
||||||
<workItem from="1774814271256" duration="6223000" />
|
|
||||||
<workItem from="1774872087063" duration="23188000" />
|
|
||||||
<workItem from="1774944423363" duration="2161000" />
|
|
||||||
<workItem from="1774953226940" duration="4167000" />
|
|
||||||
<workItem from="1775295922142" duration="5680000" />
|
|
||||||
<workItem from="1775312662640" duration="1858000" />
|
|
||||||
<workItem from="1775391482685" duration="1961000" />
|
|
||||||
<workItem from="1775495346638" duration="596000" />
|
|
||||||
<workItem from="1775747075572" duration="1237000" />
|
|
||||||
<workItem from="1776192895352" duration="3823000" />
|
|
||||||
<workItem from="1776198526994" duration="4187000" />
|
|
||||||
<workItem from="1776240695245" duration="14368000" />
|
|
||||||
<workItem from="1776410282788" duration="22340000" />
|
|
||||||
<workItem from="1776515579524" duration="1172000" />
|
|
||||||
<workItem from="1776626614705" duration="4066000" />
|
|
||||||
<workItem from="1776670293634" duration="9127000" />
|
|
||||||
<workItem from="1776716089200" duration="2010000" />
|
|
||||||
<workItem from="1776872699811" duration="4161000" />
|
|
||||||
<workItem from="1776931805422" duration="16122000" />
|
|
||||||
<workItem from="1777051880577" duration="2650000" />
|
|
||||||
<workItem from="1777150747725" duration="837000" />
|
|
||||||
<workItem from="1777198150359" duration="638000" />
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00001" summary="Initial">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1773536078762</created>
|
|
||||||
<option name="number" value="00001" />
|
|
||||||
<option name="presentableId" value="LOCAL-00001" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1773536078762</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00002" summary="pre-module refactor">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1773536678416</created>
|
|
||||||
<option name="number" value="00002" />
|
|
||||||
<option name="presentableId" value="LOCAL-00002" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1773536678416</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00003" summary="refactored, pre-buffer reuse">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1773581516834</created>
|
|
||||||
<option name="number" value="00003" />
|
|
||||||
<option name="presentableId" value="LOCAL-00003" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1773581516834</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00004" summary="optimized dynamic body size impl, decluttering javadocs/comments">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1773591259108</created>
|
|
||||||
<option name="number" value="00004" />
|
|
||||||
<option name="presentableId" value="LOCAL-00004" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1773591259108</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00005" summary="enhanced HTTP server configuration and response handling; added acceptorThreads, improved header management, and refined error page titles">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1773920675327</created>
|
|
||||||
<option name="number" value="00005" />
|
|
||||||
<option name="presentableId" value="LOCAL-00005" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1773920675327</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00006" summary="multipart parsing, request body access, and chunked input stream support">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1773920735659</created>
|
|
||||||
<option name="number" value="00006" />
|
|
||||||
<option name="presentableId" value="LOCAL-00006" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1773920735659</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00007" summary="enhanced router middleware support; added pre-fused middleware handling and improved handler registration">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1773952556190</created>
|
|
||||||
<option name="number" value="00007" />
|
|
||||||
<option name="presentableId" value="LOCAL-00007" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1773952556190</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00008" summary="pre-major refactoring + ext api.">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1774530802482</created>
|
|
||||||
<option name="number" value="00008" />
|
|
||||||
<option name="presentableId" value="LOCAL-00008" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1774530802482</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00009" summary="preparing for a conceptual refactoring...">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1774703474044</created>
|
|
||||||
<option name="number" value="00009" />
|
|
||||||
<option name="presentableId" value="LOCAL-00009" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1774703474044</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00010" summary="preparing for another refactoring...">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1774819003821</created>
|
|
||||||
<option name="number" value="00010" />
|
|
||||||
<option name="presentableId" value="LOCAL-00010" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1774819003821</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00011" summary="implement OpenAPI contributor integration for rate limiting and response headers">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1776634972916</created>
|
|
||||||
<option name="number" value="00011" />
|
|
||||||
<option name="presentableId" value="LOCAL-00011" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1776634972916</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00012" summary="add core view extension with JTE and Thymeleaf support">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1776724331443</created>
|
|
||||||
<option name="number" value="00012" />
|
|
||||||
<option name="presentableId" value="LOCAL-00012" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1776724331443</updated>
|
|
||||||
</task>
|
|
||||||
<option name="localTasksCounter" value="13" />
|
|
||||||
<servers />
|
|
||||||
</component>
|
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
|
||||||
<option name="version" value="3" />
|
|
||||||
</component>
|
|
||||||
<component name="Vcs.Log.Tabs.Properties">
|
|
||||||
<option name="TAB_STATES">
|
|
||||||
<map>
|
|
||||||
<entry key="MAIN">
|
|
||||||
<value>
|
|
||||||
<State>
|
|
||||||
<option name="FILTERS">
|
|
||||||
<map>
|
|
||||||
<entry key="branch">
|
|
||||||
<value>
|
|
||||||
<list>
|
|
||||||
<option value="claude/distracted-spence" />
|
|
||||||
</list>
|
|
||||||
</value>
|
|
||||||
</entry>
|
|
||||||
</map>
|
|
||||||
</option>
|
|
||||||
</State>
|
|
||||||
</value>
|
|
||||||
</entry>
|
|
||||||
</map>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
<component name="VcsManagerConfiguration">
|
|
||||||
<MESSAGE value="Initial" />
|
|
||||||
<MESSAGE value="pre-module refactor" />
|
|
||||||
<MESSAGE value="refactored, pre-buffer reuse" />
|
|
||||||
<MESSAGE value="optimized dynamic body size impl, decluttering javadocs/comments" />
|
|
||||||
<MESSAGE value="enhanced HTTP server configuration and response handling; added acceptorThreads, improved header management, and refined error page titles" />
|
|
||||||
<MESSAGE value="multipart parsing, request body access, and chunked input stream support" />
|
|
||||||
<MESSAGE value="enhanced router middleware support; added pre-fused middleware handling and improved handler registration" />
|
|
||||||
<MESSAGE value="pre-major refactoring + ext api." />
|
|
||||||
<MESSAGE value="preparing for a conceptual refactoring..." />
|
|
||||||
<MESSAGE value="preparing for another refactoring..." />
|
|
||||||
<MESSAGE value="implement OpenAPI contributor integration for rate limiting and response headers" />
|
|
||||||
<MESSAGE value="add core view extension with JTE and Thymeleaf support" />
|
|
||||||
<option name="LAST_COMMIT_MESSAGE" value="add core view extension with JTE and Thymeleaf support" />
|
|
||||||
</component>
|
|
||||||
<component name="XSLT-Support.FileAssociations.UIState">
|
|
||||||
<expand />
|
|
||||||
<select />
|
|
||||||
</component>
|
|
||||||
<component name="github-copilot-workspace">
|
|
||||||
<instructionFileLocations>
|
|
||||||
<option value=".github/instructions" />
|
|
||||||
</instructionFileLocations>
|
|
||||||
<promptFileLocations>
|
|
||||||
<option value=".github/prompts" />
|
|
||||||
</promptFileLocations>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
# Flash — Agent Guidelines
|
|
||||||
|
|
||||||
This document defines the conventions and rules that all agents (AI or human) must follow
|
|
||||||
when working on this repository. Read it entirely before making any change.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Git Workflow
|
|
||||||
|
|
||||||
### Branch Naming
|
|
||||||
|
|
||||||
| Type | Pattern | Example |
|
|
||||||
|------|---------|---------|
|
|
||||||
| New feature | `feature/<scope>/<short-description>` | `feature/ext-oidc/pkce-support` |
|
|
||||||
| Bug fix | `fix/<scope>/<short-description>` | `fix/core/router-npe` |
|
|
||||||
| Hotfix on released version | `hotfix/<version>/<short-description>` | `hotfix/2.0.1/auth-bypass` |
|
|
||||||
| CI/infra changes | `feature/ci/<short-description>` | `feature/ci/add-snapshot-workflow` |
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Always branch from `master`.
|
|
||||||
- Branch names are lowercase, words separated by `-`.
|
|
||||||
- Never push directly to `master`.
|
|
||||||
- Never create `develop`, `release/*`, or any other long-lived branch.
|
|
||||||
|
|
||||||
### Commit Messages — Conventional Commits
|
|
||||||
|
|
||||||
Format: `<type>(<scope>): <short description>`
|
|
||||||
|
|
||||||
| Type | When to use |
|
|
||||||
|------|-------------|
|
|
||||||
| `feat` | New feature |
|
|
||||||
| `fix` | Bug fix |
|
|
||||||
| `refactor` | Code change without feature/fix |
|
|
||||||
| `test` | Adding or updating tests |
|
|
||||||
| `docs` | Documentation only |
|
|
||||||
| `chore` | Build, deps, tooling — no production code |
|
|
||||||
| `ci` | Changes to GitHub Actions workflows |
|
|
||||||
|
|
||||||
Allowed scopes: `core`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`,
|
|
||||||
`ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-web-bundler`,
|
|
||||||
`ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`, `release`, `deps`, `ci`.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
```
|
|
||||||
feat(ext-oidc): add PKCE support
|
|
||||||
fix(core): fix NPE in RouteHandler when path is null
|
|
||||||
chore(deps): upgrade jackson to 2.18.0
|
|
||||||
ci: add timeout to snapshot workflow
|
|
||||||
chore(release): 2.1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pull Requests
|
|
||||||
|
|
||||||
- Every branch must be merged via PR, never with a direct push.
|
|
||||||
- PR title must follow Conventional Commits format.
|
|
||||||
- CI (`ci.yml`) must be green before merging.
|
|
||||||
- Squash merge is preferred for `feature/*` and `fix/*` to keep history clean.
|
|
||||||
- Merge commit is preferred for hotfixes (preserves the fix commit intact).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Versioning
|
|
||||||
|
|
||||||
- All modules share a single version defined in the root `pom.xml` (`flash-parent`).
|
|
||||||
- Never change the version in child POMs — always and only in the parent.
|
|
||||||
- Current scheme: `MAJOR.MINOR.PATCH`
|
|
||||||
- MAJOR: breaking API changes
|
|
||||||
- MINOR: new backward-compatible features
|
|
||||||
- PATCH: backward-compatible bug fixes on an already-released version
|
|
||||||
- During development, master always carries a `-SNAPSHOT` version.
|
|
||||||
- **Never manually edit the version** — versions are bumped exclusively by the
|
|
||||||
`prepare-release` GitHub Actions workflow.
|
|
||||||
|
|
||||||
### Release Process (for maintainers only)
|
|
||||||
|
|
||||||
1. Ensure `master` is green (CI passing).
|
|
||||||
2. Go to GitHub Actions → `Prepare Release` → `Run workflow`.
|
|
||||||
3. Input `version` (e.g. `2.1.0`) and `next_version` (e.g. `2.2.0`).
|
|
||||||
4. The workflow handles everything: bump, commit, tag, push.
|
|
||||||
5. The `release` workflow then triggers automatically on the tag.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Maven & Module Structure
|
|
||||||
|
|
||||||
- Root POM: `flash-parent` — defines all dependency versions and plugin config.
|
|
||||||
- `flash` module: the core framework JAR.
|
|
||||||
- `flash-extensions` POM: aggregator for all extension modules.
|
|
||||||
- Extensions live under `flash-extensions/flash-ext-*/`.
|
|
||||||
- When adding a new extension:
|
|
||||||
1. Add the module to `flash-extensions/pom.xml` `<modules>`.
|
|
||||||
2. Add the dependency to `flash-extensions/pom.xml` `<dependencyManagement>`.
|
|
||||||
3. Add the dependency to the root `pom.xml` `<dependencyManagement>`.
|
|
||||||
4. Do **not** declare a `<version>` in the new module's POM — it inherits from the parent.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CI/CD Pipelines
|
|
||||||
|
|
||||||
| Workflow | Trigger | What it does |
|
|
||||||
|----------|---------|--------------|
|
|
||||||
| `ci.yml` | Push to any branch, PR to master | Compile + test — required gate |
|
|
||||||
| `snapshot.yml` | Push to `master` | Deploy `-SNAPSHOT` to `maven.relism.dev/snapshots` |
|
|
||||||
| `prepare-release.yml` | Manual `workflow_dispatch` | Bump version, commit, tag, push |
|
|
||||||
| `release.yml` | Push of tag `v*` | GPG sign, deploy to `/releases`, JavaDoc to GitHub Pages, GitHub Release |
|
|
||||||
|
|
||||||
**Agents must never manually trigger `prepare-release` or modify version strings.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What Agents Must NOT Do
|
|
||||||
|
|
||||||
- Push directly to `master` or `gh-pages`.
|
|
||||||
- Manually edit `<version>` tags in any POM.
|
|
||||||
- Add new `<repositories>` or `<distributionManagement>` entries without explicit instruction.
|
|
||||||
- Modify `.github/workflows/*.yml` files without explicit instruction.
|
|
||||||
- Commit generated files (`target/`, `*.class`, `*.versionsBackup`).
|
|
||||||
- Use `git push --force` on any branch.
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
# Flash
|
|
||||||
|
|
||||||
A high-performance HTTP/1.1 server library for Java 21, built around virtual threads and a zero-allocation FSM router.
|
|
||||||
|
|
||||||
## Modules
|
|
||||||
|
|
||||||
| Module | Description |
|
|
||||||
|---|---|
|
|
||||||
| `flash` | Core server library — router, request parser, HTTP I/O transport |
|
|
||||||
| `flash-extensions/flash-ext-jackson` | Jackson JSON integration |
|
|
||||||
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI |
|
|
||||||
| `flash-extensions/flash-ext-oidc` | OIDC Authorization Code + PKCE flow |
|
|
||||||
| `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives |
|
|
||||||
| `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension |
|
|
||||||
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
|
|
||||||
| `flash-bench` | Demo harness (OIDC + OpenAPI + Jackson) |
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- Java 21+
|
|
||||||
- Maven 3.8+
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```java
|
|
||||||
FlashApp.create(8080)
|
|
||||||
.get("/ping", (req, res) -> "pong")
|
|
||||||
.start();
|
|
||||||
```
|
|
||||||
|
|
||||||
With full configuration:
|
|
||||||
|
|
||||||
```java
|
|
||||||
FlashApp.create(
|
|
||||||
FlashConfiguration.builder()
|
|
||||||
.port(8080)
|
|
||||||
.host("0.0.0.0")
|
|
||||||
.maxHeaderBufferSize(65536)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.get("/ping", (req, res) -> "pong")
|
|
||||||
.start();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Route registration
|
|
||||||
|
|
||||||
### Lambda routes
|
|
||||||
|
|
||||||
```java
|
|
||||||
FlashApp app = FlashApp.create(8080);
|
|
||||||
|
|
||||||
app.get("/hello", (req, res) -> "world");
|
|
||||||
|
|
||||||
app.post("/echo", (req, res) -> {
|
|
||||||
byte[] body = req.body().bytes();
|
|
||||||
return res.status(200).body(body);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/users/{id}", (req, res) -> {
|
|
||||||
String id = req.pathParam("id");
|
|
||||||
return "user:" + id;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Class-based handlers
|
|
||||||
|
|
||||||
Extend `RequestHandler` (or a subclass like `JacksonHandler`) and annotate with `@Route`:
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Route(method = HttpMethod.GET, path = "/api/users")
|
|
||||||
public class ListUsers extends JacksonHandler {
|
|
||||||
@Override
|
|
||||||
public Object handle(Request req, Response res) throws Exception {
|
|
||||||
return json(res, List.of("alice", "bob"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register:
|
|
||||||
app.register(new ListUsers());
|
|
||||||
```
|
|
||||||
|
|
||||||
### Middleware
|
|
||||||
|
|
||||||
Apply middleware via `.with()` on the `RouteHandle` returned by any registration call:
|
|
||||||
|
|
||||||
```java
|
|
||||||
Middleware authCheck = next -> (req, res) -> {
|
|
||||||
if (req.header("Authorization") == null)
|
|
||||||
return res.status(401).body("Unauthorized");
|
|
||||||
return next.handle(req, res);
|
|
||||||
};
|
|
||||||
|
|
||||||
app.get("/secure", (req, res) -> "secret data")
|
|
||||||
.with(authCheck);
|
|
||||||
```
|
|
||||||
|
|
||||||
Multiple middlewares are composed outermost-first (left-to-right in the call):
|
|
||||||
|
|
||||||
```java
|
|
||||||
app.get("/admin", handler).with(logging, auth, rateLimit);
|
|
||||||
// execution order: logging → auth → rateLimit → handler
|
|
||||||
```
|
|
||||||
|
|
||||||
### Classpath scan
|
|
||||||
|
|
||||||
Scans a package for classes that extend `RequestHandler` and carry `@Route`. Each is
|
|
||||||
instantiated via its public no-arg constructor:
|
|
||||||
|
|
||||||
```java
|
|
||||||
app.scan("dev.example.handlers");
|
|
||||||
```
|
|
||||||
|
|
||||||
### Namespace mounting
|
|
||||||
|
|
||||||
Mount a scoped sub-router under a prefix. All routes registered inside the scope get the
|
|
||||||
prefix prepended automatically. The scope inherits the parent's extension context (annotation
|
|
||||||
processors, services):
|
|
||||||
|
|
||||||
```java
|
|
||||||
app.mount("/api", scope -> {
|
|
||||||
scope.get("/health", (req, res) -> "ok"); // → GET /api/health
|
|
||||||
scope.register(new UserHandler()); // @Route(path="/users") → GET /api/users
|
|
||||||
scope.scan("dev.example.api");
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Extensions
|
|
||||||
|
|
||||||
Extensions are installed before route registration. Each extension receives the `FlashRegistrar`
|
|
||||||
and `FlashContext` — it can register routes, expose services, and register annotation processors.
|
|
||||||
|
|
||||||
```java
|
|
||||||
FlashApp.create(8080)
|
|
||||||
.install(new JacksonExtension())
|
|
||||||
.install(new OpenApiExtension("/openapi", "My API", "1.0.0"))
|
|
||||||
.install(new OidcExtension(oidcConfig))
|
|
||||||
.register(new MyHandler())
|
|
||||||
.start();
|
|
||||||
```
|
|
||||||
|
|
||||||
See extension-specific READMEs for full details:
|
|
||||||
- [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md)
|
|
||||||
- [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/README.md)
|
|
||||||
- [`flash-ext-oidc`](flash-extensions/flash-ext-oidc/README.md)
|
|
||||||
- [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/README.md)
|
|
||||||
- [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md)
|
|
||||||
|
|
||||||
## Error handlers
|
|
||||||
|
|
||||||
```java
|
|
||||||
app.onNotFound((req, res) -> res.status(404).body("Not found: " + req.path()));
|
|
||||||
|
|
||||||
app.onException((ex, req, res) -> {
|
|
||||||
if (ex instanceof IllegalArgumentException)
|
|
||||||
return res.status(400).body(ex.getMessage());
|
|
||||||
return res.status(500).body("Internal error");
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## FlashConfiguration
|
|
||||||
|
|
||||||
| Field | Default | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `port` | — | TCP port to bind |
|
|
||||||
| `host` | `"0.0.0.0"` | Bind address |
|
|
||||||
| `maxHeaderBufferSize` | `65536` | Max size of the header buffer (bytes) |
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
ServerSocket.accept()
|
|
||||||
→ RequestParser.parse() # zero-alloc header parsing, buffer reuse across keep-alive
|
|
||||||
→ GlobalRouter.route() # two-tier: mounted sub-routers (longest prefix) then FastPathRouterImpl
|
|
||||||
→ RequestHandler.handle() # user handler; return value sets body
|
|
||||||
→ Request.drain() # consume unread body for keep-alive
|
|
||||||
→ HttpServer writes response # status line, headers, then fixed or chunked body
|
|
||||||
→ loop or close socket # based on Connection header
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Virtual threads** — each accepted socket runs on a virtual thread (`Executors.newVirtualThreadPerTaskExecutor()`). Java 21 required.
|
|
||||||
- **Zero-allocation router** — `FastPathRouterImpl` uses `fpr-core`, a byte-level FSM that matches on `METHOD + path` bytes with no per-request allocation.
|
|
||||||
- **Keep-alive** — `RequestParser` reuses its header buffer across requests on the same connection.
|
|
||||||
- **Chunked transfer** — both chunked request bodies (decoded via `ChunkedInputStream`) and chunked response bodies are supported.
|
|
||||||
|
|
||||||
## Build & test
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build all modules (skip tests)
|
|
||||||
mvn clean package -DskipTests
|
|
||||||
|
|
||||||
# Run all tests
|
|
||||||
mvn test
|
|
||||||
|
|
||||||
# Run a single test class
|
|
||||||
mvn test -pl flash -Dtest=RequestParserTest
|
|
||||||
|
|
||||||
# Run the benchmark demo server
|
|
||||||
java -jar flash-bench/target/flash-bench-1.0-SNAPSHOT.jar
|
|
||||||
```
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
|
||||||
<modelVersion>4.0.0</modelVersion>
|
|
||||||
|
|
||||||
<parent>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash-extensions</artifactId>
|
|
||||||
<version>2.0.0</version>
|
|
||||||
</parent>
|
|
||||||
|
|
||||||
<artifactId>flash-ext-data-core</artifactId>
|
|
||||||
|
|
||||||
<dependencies>
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>jakarta.transaction</groupId>
|
|
||||||
<artifactId>jakarta.transaction-api</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>jakarta.annotation</groupId>
|
|
||||||
<artifactId>jakarta.annotation-api</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>jakarta.validation</groupId>
|
|
||||||
<artifactId>jakarta.validation-api</artifactId>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
|
|
||||||
<properties>
|
|
||||||
<maven.compiler.source>21</maven.compiler.source>
|
|
||||||
<maven.compiler.target>21</maven.compiler.target>
|
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
|
||||||
<jacoco.version>0.8.12</jacoco.version>
|
|
||||||
</properties>
|
|
||||||
|
|
||||||
<build>
|
|
||||||
<plugins>
|
|
||||||
<plugin>
|
|
||||||
<groupId>org.jacoco</groupId>
|
|
||||||
<artifactId>jacoco-maven-plugin</artifactId>
|
|
||||||
<version>${jacoco.version}</version>
|
|
||||||
<executions>
|
|
||||||
<execution>
|
|
||||||
<id>jacoco-initialize</id>
|
|
||||||
<goals>
|
|
||||||
<goal>prepare-agent</goal>
|
|
||||||
</goals>
|
|
||||||
</execution>
|
|
||||||
<execution>
|
|
||||||
<id>jacoco-site</id>
|
|
||||||
<phase>verify</phase>
|
|
||||||
<goals>
|
|
||||||
<goal>report</goal>
|
|
||||||
</goals>
|
|
||||||
<configuration>
|
|
||||||
<rules>
|
|
||||||
<rule>
|
|
||||||
<element>BUNDLE</element>
|
|
||||||
<limits>
|
|
||||||
<limit>
|
|
||||||
<counter>LINE</counter>
|
|
||||||
<value>COVEREDRATIO</value>
|
|
||||||
<minimum>0.80</minimum>
|
|
||||||
</limit>
|
|
||||||
</limits>
|
|
||||||
</rule>
|
|
||||||
</rules>
|
|
||||||
</configuration>
|
|
||||||
</execution>
|
|
||||||
</executions>
|
|
||||||
</plugin>
|
|
||||||
<plugin>
|
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
|
||||||
<artifactId>maven-surefire-plugin</artifactId>
|
|
||||||
<version>3.2.5</version>
|
|
||||||
</plugin>
|
|
||||||
</plugins>
|
|
||||||
</build>
|
|
||||||
</project>
|
|
||||||
-55
@@ -1,55 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data;
|
|
||||||
|
|
||||||
import dev.relism.flash.ext.data.core.Tx;
|
|
||||||
import dev.relism.flash.ext.data.core.TxDefinition;
|
|
||||||
import dev.relism.flash.ext.data.core.TxManager;
|
|
||||||
import dev.relism.flash.ext.data.core.TransactionPropagation;
|
|
||||||
import dev.relism.flash.extension.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;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
public final class DataExtension implements FlashExtension {
|
|
||||||
private final TxManager txManager;
|
|
||||||
|
|
||||||
public DataExtension(TxManager txManager) {
|
|
||||||
this.txManager = Objects.requireNonNull(txManager);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void provide(FlashContext ctx) {
|
|
||||||
Tx.init(txManager);
|
|
||||||
ctx.provide(TxManager.class, txManager);
|
|
||||||
ctx.addAnnotationProcessor(handlerClass -> {
|
|
||||||
Transactional ann = handlerClass.getAnnotation(Transactional.class);
|
|
||||||
if (ann == null) {
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
TxDefinition definition = TxDefinition.DEFAULTS
|
|
||||||
.withPropagation(mapTxType(ann.value()));
|
|
||||||
Middleware middleware = next -> (req, res) -> {
|
|
||||||
return Tx.call(definition, () -> next.handle(req, res));
|
|
||||||
};
|
|
||||||
return List.of(middleware);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int priority() {
|
|
||||||
return ExtensionPhase.EARLY.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private TransactionPropagation mapTxType(Transactional.TxType txType) {
|
|
||||||
return switch (txType) {
|
|
||||||
case REQUIRED, SUPPORTS -> TransactionPropagation.REQUIRED;
|
|
||||||
case REQUIRES_NEW -> TransactionPropagation.REQUIRES_NEW;
|
|
||||||
case MANDATORY -> TransactionPropagation.MANDATORY;
|
|
||||||
case NOT_SUPPORTED, NEVER -> TransactionPropagation.NOT_SUPPORTED;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-15
@@ -1,15 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public record Page<T>(
|
|
||||||
List<T> content,
|
|
||||||
int page,
|
|
||||||
int size,
|
|
||||||
long total
|
|
||||||
) {
|
|
||||||
public int totalPages() { return size == 0 ? 0 : (int) Math.ceil((double) total / size); }
|
|
||||||
public boolean hasNext() { return page + 1 < totalPages(); }
|
|
||||||
public boolean hasPrev() { return page > 0; }
|
|
||||||
public boolean isEmpty() { return content.isEmpty(); }
|
|
||||||
}
|
|
||||||
-112
@@ -1,112 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
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> {
|
|
||||||
|
|
||||||
private final TxDefinition required = TxDefinition.DEFAULTS
|
|
||||||
.withPropagation(TransactionPropagation.REQUIRED);
|
|
||||||
|
|
||||||
// ── CRUD ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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; });
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean existsById(ID id) {
|
|
||||||
return tx(() -> doExistsById(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
public long count() {
|
|
||||||
return tx(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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Abstract — implemented by HibernateRepository / JdbcRepository ────────
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
-63
@@ -1,63 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
public final class ResourceRegistry {
|
|
||||||
private static final ThreadLocal<Map<TxResourceKey, Object>> RESOURCES =
|
|
||||||
ThreadLocal.withInitial(HashMap::new);
|
|
||||||
private static final ThreadLocal<List<TxSynchronization>> SYNCHRONIZATIONS =
|
|
||||||
ThreadLocal.withInitial(ArrayList::new);
|
|
||||||
|
|
||||||
private ResourceRegistry() {}
|
|
||||||
|
|
||||||
public static void bind(TxResourceKey key, Object value) {
|
|
||||||
RESOURCES.get().put(key, Objects.requireNonNull(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static boolean isBound(TxResourceKey key) {
|
|
||||||
return RESOURCES.get().containsKey(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void unbind(TxResourceKey key) {
|
|
||||||
RESOURCES.get().remove(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void clear() {
|
|
||||||
RESOURCES.get().clear();
|
|
||||||
SYNCHRONIZATIONS.get().clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static <R> R get(TxResourceKey key, Class<R> type) {
|
|
||||||
Object value = RESOURCES.get().get(key);
|
|
||||||
if (value == null) {
|
|
||||||
throw new IllegalStateException("No resource bound for key: " + key);
|
|
||||||
}
|
|
||||||
return type.cast(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static <R> R getOrNull(TxResourceKey key, Class<R> type) {
|
|
||||||
Object value = RESOURCES.get().get(key);
|
|
||||||
return value == null ? null : type.cast(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void addSynchronization(TxSynchronization sync) {
|
|
||||||
SYNCHRONIZATIONS.get().add(Objects.requireNonNull(sync));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void fireSynchronizations(TxOutcome outcome) {
|
|
||||||
List<TxSynchronization> syncs = List.copyOf(SYNCHRONIZATIONS.get());
|
|
||||||
SYNCHRONIZATIONS.get().clear();
|
|
||||||
for (TxSynchronization sync : syncs) {
|
|
||||||
if (outcome == TxOutcome.COMMITTED) {
|
|
||||||
sync.afterCommit();
|
|
||||||
} else {
|
|
||||||
sync.afterRollback();
|
|
||||||
}
|
|
||||||
sync.afterCompletion(outcome);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public record Sort(List<Column> columns) {
|
|
||||||
|
|
||||||
public record Column(String column, boolean asc) {}
|
|
||||||
|
|
||||||
public static Sort by(String column) { return new Sort(List.of(new Column(column, true))); }
|
|
||||||
public static Sort desc(String column) { return new Sort(List.of(new Column(column, false))); }
|
|
||||||
public static Sort by(String col, boolean asc){ return new Sort(List.of(new Column(col, asc))); }
|
|
||||||
|
|
||||||
public Sort then(String column) { return thenBy(column, true); }
|
|
||||||
public Sort thenDesc(String column) { return thenBy(column, false); }
|
|
||||||
|
|
||||||
private Sort thenBy(String column, boolean asc) {
|
|
||||||
List<Column> next = new ArrayList<>(columns);
|
|
||||||
next.add(new Column(column, asc));
|
|
||||||
return new Sort(next);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
public enum TransactionIsolation {
|
|
||||||
DEFAULT(-1),
|
|
||||||
READ_UNCOMMITTED(1),
|
|
||||||
READ_COMMITTED(2),
|
|
||||||
REPEATABLE_READ(4),
|
|
||||||
SERIALIZABLE(8);
|
|
||||||
|
|
||||||
private final int level;
|
|
||||||
|
|
||||||
TransactionIsolation(int level) { this.level = level; }
|
|
||||||
public int level() { return level; }
|
|
||||||
}
|
|
||||||
-8
@@ -1,8 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
public enum TransactionPropagation {
|
|
||||||
REQUIRED,
|
|
||||||
REQUIRES_NEW,
|
|
||||||
NOT_SUPPORTED,
|
|
||||||
MANDATORY
|
|
||||||
}
|
|
||||||
-109
@@ -1,109 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
import java.util.ArrayDeque;
|
|
||||||
import java.util.Deque;
|
|
||||||
|
|
||||||
public final class Tx {
|
|
||||||
private static final ThreadLocal<Deque<TxStatus>> STATUS_STACK =
|
|
||||||
ThreadLocal.withInitial(ArrayDeque::new);
|
|
||||||
private static volatile TxManager manager;
|
|
||||||
|
|
||||||
private Tx() {}
|
|
||||||
|
|
||||||
public static void init(TxManager txManager) {
|
|
||||||
if (manager != null) {
|
|
||||||
throw new IllegalStateException("TxManager already initialized");
|
|
||||||
}
|
|
||||||
manager = txManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void run(TxRunnable work) {
|
|
||||||
run(TxDefinition.DEFAULTS, work);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void run(TxDefinition definition, TxRunnable work) {
|
|
||||||
call(definition, () -> {
|
|
||||||
work.run();
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public static <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);
|
|
||||||
pushStatus(status);
|
|
||||||
try {
|
|
||||||
T result = work.call();
|
|
||||||
if (status.isRollbackOnly()) {
|
|
||||||
manager().rollback(status);
|
|
||||||
} else {
|
|
||||||
manager().commit(status);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
} catch (Exception e) {
|
|
||||||
manager().rollback(status);
|
|
||||||
throw (e instanceof TxException txException) ? txException : new TxException(e);
|
|
||||||
} finally {
|
|
||||||
popStatus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static boolean isActive() {
|
|
||||||
return !STATUS_STACK.get().isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void setRollbackOnly() {
|
|
||||||
currentStatus().markRollbackOnly();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static <R> R resource(Class<R> type) {
|
|
||||||
return currentStatus().resource(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static TxDefinition requiresNew() {
|
|
||||||
return TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static 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() {
|
|
||||||
TxStatus status = STATUS_STACK.get().peek();
|
|
||||||
if (status == null) {
|
|
||||||
throw new IllegalStateException("No active transaction");
|
|
||||||
}
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void pushStatus(TxStatus status) {
|
|
||||||
STATUS_STACK.get().push(status);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void popStatus() {
|
|
||||||
Deque<TxStatus> stack = STATUS_STACK.get();
|
|
||||||
if (!stack.isEmpty()) {
|
|
||||||
stack.pop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@FunctionalInterface
|
|
||||||
public interface TxRunnable {
|
|
||||||
void run();
|
|
||||||
}
|
|
||||||
|
|
||||||
@FunctionalInterface
|
|
||||||
public interface TxCallable<T> {
|
|
||||||
T call() throws Exception;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-31
@@ -1,31 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
public record TxDefinition(
|
|
||||||
TransactionPropagation propagation,
|
|
||||||
TransactionIsolation isolation,
|
|
||||||
boolean readOnly,
|
|
||||||
String label
|
|
||||||
) {
|
|
||||||
public static final TxDefinition DEFAULTS = new TxDefinition(
|
|
||||||
TransactionPropagation.REQUIRED,
|
|
||||||
TransactionIsolation.DEFAULT,
|
|
||||||
false,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
public TxDefinition withPropagation(TransactionPropagation p) {
|
|
||||||
return new TxDefinition(p, isolation, readOnly, label);
|
|
||||||
}
|
|
||||||
|
|
||||||
public TxDefinition withIsolation(TransactionIsolation i) {
|
|
||||||
return new TxDefinition(propagation, i, readOnly, label);
|
|
||||||
}
|
|
||||||
|
|
||||||
public TxDefinition withReadOnly(boolean ro) {
|
|
||||||
return new TxDefinition(propagation, isolation, ro, label);
|
|
||||||
}
|
|
||||||
|
|
||||||
public TxDefinition asReadOnly() {
|
|
||||||
return new TxDefinition(propagation, isolation, true, label);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-6
@@ -1,6 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
public class TxException extends RuntimeException {
|
|
||||||
public TxException(String message) { super(message); }
|
|
||||||
public TxException(Throwable cause) { super(cause); }
|
|
||||||
}
|
|
||||||
-7
@@ -1,7 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
public interface TxManager {
|
|
||||||
TxStatus begin(TxDefinition definition);
|
|
||||||
void commit(TxStatus status);
|
|
||||||
void rollback(TxStatus status);
|
|
||||||
}
|
|
||||||
-6
@@ -1,6 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
public enum TxOutcome {
|
|
||||||
COMMITTED,
|
|
||||||
ROLLED_BACK
|
|
||||||
}
|
|
||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
public final class TxResourceKey {
|
|
||||||
private final String name;
|
|
||||||
|
|
||||||
private TxResourceKey(String name) {
|
|
||||||
this.name = Objects.requireNonNull(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static TxResourceKey of(String name) {
|
|
||||||
return new TxResourceKey(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean equals(Object o) {
|
|
||||||
if (this == o) return true;
|
|
||||||
if (!(o instanceof TxResourceKey that)) return false;
|
|
||||||
return name.equals(that.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int hashCode() {
|
|
||||||
return name.hashCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String toString() {
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-9
@@ -1,9 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
public interface TxStatus {
|
|
||||||
boolean isNewTransaction();
|
|
||||||
boolean isReadOnly();
|
|
||||||
boolean isRollbackOnly();
|
|
||||||
void markRollbackOnly();
|
|
||||||
<R> R resource(Class<R> type);
|
|
||||||
}
|
|
||||||
-8
@@ -1,8 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
|
||||||
|
|
||||||
public interface TxSynchronization {
|
|
||||||
default void beforeCommit(boolean readOnly) {}
|
|
||||||
default void afterCommit() {}
|
|
||||||
default void afterRollback() {}
|
|
||||||
default void afterCompletion(TxOutcome outcome) {}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
|
||||||
<modelVersion>4.0.0</modelVersion>
|
|
||||||
|
|
||||||
<parent>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash-extensions</artifactId>
|
|
||||||
<version>2.0.0</version>
|
|
||||||
</parent>
|
|
||||||
|
|
||||||
<artifactId>flash-ext-data-hibernate</artifactId>
|
|
||||||
|
|
||||||
<properties>
|
|
||||||
<jacoco.version>0.8.12</jacoco.version>
|
|
||||||
</properties>
|
|
||||||
|
|
||||||
<dependencies>
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash-ext-data-core</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.hibernate.orm</groupId>
|
|
||||||
<artifactId>hibernate-core</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.projectlombok</groupId>
|
|
||||||
<artifactId>lombok</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.junit.jupiter</groupId>
|
|
||||||
<artifactId>junit-jupiter</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.h2database</groupId>
|
|
||||||
<artifactId>h2</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
|
|
||||||
<build>
|
|
||||||
<plugins>
|
|
||||||
<plugin>
|
|
||||||
<groupId>org.jacoco</groupId>
|
|
||||||
<artifactId>jacoco-maven-plugin</artifactId>
|
|
||||||
<version>${jacoco.version}</version>
|
|
||||||
<configuration>
|
|
||||||
<rules>
|
|
||||||
<rule>
|
|
||||||
<element>BUNDLE</element>
|
|
||||||
<limits>
|
|
||||||
<limit>
|
|
||||||
<counter>LINE</counter>
|
|
||||||
<value>COVEREDRATIO</value>
|
|
||||||
<minimum>0.80</minimum>
|
|
||||||
</limit>
|
|
||||||
</limits>
|
|
||||||
</rule>
|
|
||||||
</rules>
|
|
||||||
</configuration>
|
|
||||||
</plugin>
|
|
||||||
</plugins>
|
|
||||||
</build>
|
|
||||||
</project>
|
|
||||||
-177
@@ -1,177 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.hibernate;
|
|
||||||
|
|
||||||
import dev.relism.flash.ext.data.core.*;
|
|
||||||
import org.hibernate.Session;
|
|
||||||
import org.hibernate.query.MutationQuery;
|
|
||||||
|
|
||||||
import jakarta.persistence.TypedQuery;
|
|
||||||
import java.io.Serializable;
|
|
||||||
import java.util.*;
|
|
||||||
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> {
|
|
||||||
|
|
||||||
private final Class<T> type;
|
|
||||||
|
|
||||||
protected HibernateRepository(Class<T> type) {
|
|
||||||
this.type = type;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Session — always safe, tx() wrapper guarantees active transaction ─────
|
|
||||||
|
|
||||||
protected Session session() {
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected List<T> doFindAll(int page, int size) {
|
|
||||||
return hql("from " + type.getSimpleName())
|
|
||||||
.setFirstResult(page * size)
|
|
||||||
.setMaxResults(size)
|
|
||||||
.getResultList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected List<T> doFindAll(Sort sort) {
|
|
||||||
return hql("from " + type.getSimpleName() + orderClause(sort))
|
|
||||||
.getResultList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected List<T> doFindAll(int page, int size, Sort sort) {
|
|
||||||
return hql("from " + type.getSimpleName() + orderClause(sort))
|
|
||||||
.setFirstResult(page * size)
|
|
||||||
.setMaxResults(size)
|
|
||||||
.getResultList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@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);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected T doSave(T entity) {
|
|
||||||
session().persist(entity);
|
|
||||||
return entity;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected T doUpdate(T entity) {
|
|
||||||
return session().merge(entity);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void doDelete(T entity) {
|
|
||||||
Session s = session();
|
|
||||||
s.remove(s.contains(entity) ? entity : s.merge(entity));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void doDeleteById(ID id) {
|
|
||||||
doFindById(id).ifPresent(this::doDelete);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected boolean doExistsById(ID id) {
|
|
||||||
return doFindById(id).isPresent();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected long doCount() {
|
|
||||||
return session()
|
|
||||||
.createQuery("select count(*) from " + type.getSimpleName(), Long.class)
|
|
||||||
.uniqueResultOptional()
|
|
||||||
.orElse(0L);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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);
|
|
||||||
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);
|
|
||||||
params.accept(q);
|
|
||||||
return q.setFirstResult(page * size).setMaxResults(size).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(() -> {
|
|
||||||
MutationQuery q = session().createMutationQuery(hql);
|
|
||||||
params.accept(q);
|
|
||||||
return q.executeUpdate();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected Class<T> entityType() {
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String orderClause(Sort sort) {
|
|
||||||
return " order by " + sort.columns().stream()
|
|
||||||
.map(c -> c.column() + (c.asc() ? " ASC" : " DESC"))
|
|
||||||
.collect(Collectors.joining(", "));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-114
@@ -1,114 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.hibernate;
|
|
||||||
|
|
||||||
import dev.relism.flash.ext.data.core.*;
|
|
||||||
import org.hibernate.Session;
|
|
||||||
import org.hibernate.SessionFactory;
|
|
||||||
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
public class HibernateTxManager implements TxManager {
|
|
||||||
private static final TxResourceKey HIBERNATE_STATUS_KEY = TxResourceKey.of("hibernate.tx.status");
|
|
||||||
|
|
||||||
private final SessionFactory sf;
|
|
||||||
|
|
||||||
public HibernateTxManager(SessionFactory sessionFactory) {
|
|
||||||
this.sf = Objects.requireNonNull(sessionFactory);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public TxStatus begin(TxDefinition definition) {
|
|
||||||
return switch (definition.propagation()) {
|
|
||||||
case REQUIRED -> ResourceRegistry.isBound(HIBERNATE_STATUS_KEY)
|
|
||||||
? joinExisting(definition)
|
|
||||||
: beginNew(definition);
|
|
||||||
case REQUIRES_NEW -> beginNew(definition);
|
|
||||||
case 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");
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private TxStatus beginNew(TxDefinition definition) {
|
|
||||||
HibernateTxStatus suspended = ResourceRegistry.getOrNull(HIBERNATE_STATUS_KEY, HibernateTxStatus.class);
|
|
||||||
if (suspended != null) {
|
|
||||||
ResourceRegistry.unbind(HIBERNATE_STATUS_KEY);
|
|
||||||
}
|
|
||||||
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()));
|
|
||||||
}
|
|
||||||
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) {
|
|
||||||
HibernateTxStatus existing = ResourceRegistry.get(HIBERNATE_STATUS_KEY, HibernateTxStatus.class);
|
|
||||||
if (definition.readOnly() && !existing.isReadOnly()) {
|
|
||||||
throw new TxException("Cannot join read-write tx as read-only");
|
|
||||||
}
|
|
||||||
return new HibernateTxStatus(
|
|
||||||
existing.session(),
|
|
||||||
false,
|
|
||||||
definition.readOnly(),
|
|
||||||
null,
|
|
||||||
existing.rollbackMarker()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void commit(TxStatus status) {
|
|
||||||
HibernateTxStatus s = (HibernateTxStatus) status;
|
|
||||||
if (!s.isNewTransaction()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (s.isRollbackOnly() && s.session().getTransaction().isActive()) {
|
|
||||||
s.session().getTransaction().rollback();
|
|
||||||
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK);
|
|
||||||
} else {
|
|
||||||
s.session().getTransaction().commit();
|
|
||||||
ResourceRegistry.fireSynchronizations(TxOutcome.COMMITTED);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
cleanupAndResume(s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void rollback(TxStatus status) {
|
|
||||||
HibernateTxStatus s = (HibernateTxStatus) status;
|
|
||||||
if (!s.isNewTransaction()) {
|
|
||||||
s.markRollbackOnly();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (s.session().getTransaction().isActive()) {
|
|
||||||
s.session().getTransaction().rollback();
|
|
||||||
}
|
|
||||||
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK);
|
|
||||||
} finally {
|
|
||||||
cleanupAndResume(s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void cleanupAndResume(HibernateTxStatus status) {
|
|
||||||
ResourceRegistry.unbind(HIBERNATE_STATUS_KEY);
|
|
||||||
status.session().close();
|
|
||||||
HibernateTxStatus suspended = status.suspended();
|
|
||||||
if (suspended != null) {
|
|
||||||
ResourceRegistry.bind(HIBERNATE_STATUS_KEY, suspended);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-44
@@ -1,44 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.hibernate;
|
|
||||||
|
|
||||||
import dev.relism.flash.ext.data.core.TxStatus;
|
|
||||||
import org.hibernate.Session;
|
|
||||||
|
|
||||||
class HibernateTxStatus implements TxStatus {
|
|
||||||
static final class RollbackMarker {
|
|
||||||
boolean rollbackOnly;
|
|
||||||
}
|
|
||||||
|
|
||||||
private final Session session;
|
|
||||||
private final boolean newTransaction;
|
|
||||||
private final boolean readOnly;
|
|
||||||
private final HibernateTxStatus suspended;
|
|
||||||
private final RollbackMarker rollbackMarker;
|
|
||||||
|
|
||||||
HibernateTxStatus(
|
|
||||||
Session session,
|
|
||||||
boolean newTransaction,
|
|
||||||
boolean readOnly,
|
|
||||||
HibernateTxStatus suspended,
|
|
||||||
RollbackMarker rollbackMarker
|
|
||||||
) {
|
|
||||||
this.session = session;
|
|
||||||
this.newTransaction = newTransaction;
|
|
||||||
this.readOnly = readOnly;
|
|
||||||
this.suspended = suspended;
|
|
||||||
this.rollbackMarker = rollbackMarker;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public boolean isNewTransaction() { return newTransaction; }
|
|
||||||
@Override public boolean isReadOnly() { return readOnly; }
|
|
||||||
@Override public boolean isRollbackOnly() { return rollbackMarker.rollbackOnly; }
|
|
||||||
@Override public void markRollbackOnly() { rollbackMarker.rollbackOnly = true; }
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public <R> R resource(Class<R> type) {
|
|
||||||
return type.cast(session);
|
|
||||||
}
|
|
||||||
|
|
||||||
Session session() { return session; }
|
|
||||||
HibernateTxStatus suspended() { return suspended; }
|
|
||||||
RollbackMarker rollbackMarker() { return rollbackMarker; }
|
|
||||||
}
|
|
||||||
-68
@@ -1,68 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.hibernate;
|
|
||||||
|
|
||||||
import dev.relism.flash.ext.data.core.*;
|
|
||||||
import org.hibernate.Session;
|
|
||||||
import org.hibernate.SessionFactory;
|
|
||||||
import org.junit.jupiter.api.AfterAll;
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
|
||||||
import org.junit.jupiter.api.BeforeAll;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
|
|
||||||
class HibernateTxManagerTest {
|
|
||||||
static SessionFactory sf;
|
|
||||||
static HibernateTxManager manager;
|
|
||||||
|
|
||||||
@BeforeAll
|
|
||||||
static void setup() {
|
|
||||||
sf = TestHelper.buildSessionFactory();
|
|
||||||
manager = new HibernateTxManager(sf);
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterAll
|
|
||||||
static void teardown() {
|
|
||||||
if (sf != null) {
|
|
||||||
sf.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
void cleanup() {
|
|
||||||
ResourceRegistry.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void required_starts_new_when_absent() {
|
|
||||||
TxStatus s = manager.begin(TxDefinition.DEFAULTS);
|
|
||||||
assertNotNull(s.resource(Session.class));
|
|
||||||
assertTrue(s.isNewTransaction());
|
|
||||||
assertDoesNotThrow(() -> manager.commit(s));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void required_joins_existing_when_present() {
|
|
||||||
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
|
||||||
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
|
|
||||||
assertSame(outer.resource(Session.class), inner.resource(Session.class));
|
|
||||||
manager.rollback(outer);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void requires_new_uses_separate_session() {
|
|
||||||
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
|
||||||
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW));
|
|
||||||
assertNotSame(outer.resource(Session.class), inner.resource(Session.class));
|
|
||||||
manager.commit(inner);
|
|
||||||
manager.rollback(outer);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void rollback_on_joined_marks_outer_rollback_only() {
|
|
||||||
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
|
||||||
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
|
|
||||||
manager.rollback(inner);
|
|
||||||
assertTrue(outer.isRollbackOnly());
|
|
||||||
manager.rollback(outer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-24
@@ -1,24 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.hibernate;
|
|
||||||
|
|
||||||
import org.hibernate.SessionFactory;
|
|
||||||
import org.hibernate.boot.MetadataSources;
|
|
||||||
import org.hibernate.boot.registry.StandardServiceRegistry;
|
|
||||||
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
|
|
||||||
|
|
||||||
public class TestHelper {
|
|
||||||
public static SessionFactory buildSessionFactory() {
|
|
||||||
final StandardServiceRegistry registry = new StandardServiceRegistryBuilder()
|
|
||||||
.applySetting("hibernate.connection.url", "jdbc:h2:mem:tx-hibernate;DB_CLOSE_DELAY=-1")
|
|
||||||
.applySetting("hibernate.connection.driver_class", "org.h2.Driver")
|
|
||||||
.applySetting("hibernate.dialect", "org.hibernate.dialect.H2Dialect")
|
|
||||||
.applySetting("hibernate.hbm2ddl.auto", "none")
|
|
||||||
.applySetting("hibernate.show_sql", "false")
|
|
||||||
.build();
|
|
||||||
try {
|
|
||||||
return new MetadataSources(registry).buildMetadata().buildSessionFactory();
|
|
||||||
} catch (Exception e) {
|
|
||||||
StandardServiceRegistryBuilder.destroy(registry);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
|
||||||
<modelVersion>4.0.0</modelVersion>
|
|
||||||
|
|
||||||
<parent>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash-extensions</artifactId>
|
|
||||||
<version>2.0.0</version>
|
|
||||||
</parent>
|
|
||||||
|
|
||||||
<artifactId>flash-ext-data-jdbc</artifactId>
|
|
||||||
|
|
||||||
<properties>
|
|
||||||
<jacoco.version>0.8.12</jacoco.version>
|
|
||||||
</properties>
|
|
||||||
|
|
||||||
<dependencies>
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash-ext-data-core</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.zaxxer</groupId>
|
|
||||||
<artifactId>HikariCP</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.projectlombok</groupId>
|
|
||||||
<artifactId>lombok</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.junit.jupiter</groupId>
|
|
||||||
<artifactId>junit-jupiter</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.h2database</groupId>
|
|
||||||
<artifactId>h2</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
|
|
||||||
<build>
|
|
||||||
<plugins>
|
|
||||||
<plugin>
|
|
||||||
<groupId>org.jacoco</groupId>
|
|
||||||
<artifactId>jacoco-maven-plugin</artifactId>
|
|
||||||
<version>${jacoco.version}</version>
|
|
||||||
<configuration>
|
|
||||||
<rules>
|
|
||||||
<rule>
|
|
||||||
<element>BUNDLE</element>
|
|
||||||
<limits>
|
|
||||||
<limit>
|
|
||||||
<counter>LINE</counter>
|
|
||||||
<value>COVEREDRATIO</value>
|
|
||||||
<minimum>0.80</minimum>
|
|
||||||
</limit>
|
|
||||||
</limits>
|
|
||||||
</rule>
|
|
||||||
</rules>
|
|
||||||
</configuration>
|
|
||||||
</plugin>
|
|
||||||
</plugins>
|
|
||||||
</build>
|
|
||||||
</project>
|
|
||||||
-169
@@ -1,169 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
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;
|
|
||||||
this.idColumn = idColumn;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected Connection connection() {
|
|
||||||
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 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));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected List<T> doFindAll() {
|
|
||||||
return queryMany("select * from " + table, ps -> {});
|
|
||||||
}
|
|
||||||
|
|
||||||
@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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected List<T> doFindAll(Sort sort) {
|
|
||||||
return queryMany("select * from " + table + orderClause(sort), ps -> {});
|
|
||||||
}
|
|
||||||
|
|
||||||
@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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@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);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected T doSave(T entity) {
|
|
||||||
try (PreparedStatement ps = connection().prepareStatement(
|
|
||||||
insertSql(), Statement.RETURN_GENERATED_KEYS)) {
|
|
||||||
bindInsert(ps, entity);
|
|
||||||
ps.executeUpdate();
|
|
||||||
applyGeneratedKey(ps, entity);
|
|
||||||
return entity;
|
|
||||||
} catch (SQLException e) { throw new TxException(e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected T doUpdate(T entity) {
|
|
||||||
try (PreparedStatement ps = connection().prepareStatement(updateSql())) {
|
|
||||||
bindUpdate(ps, entity);
|
|
||||||
ps.executeUpdate();
|
|
||||||
return entity;
|
|
||||||
} catch (SQLException e) { throw new TxException(e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void doDelete(T entity) {
|
|
||||||
throw new UnsupportedOperationException("Override doDelete() or use deleteById()");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void doDeleteById(ID id) {
|
|
||||||
mutate("delete from " + table + " where " + idColumn + " = ?",
|
|
||||||
ps -> ps.setObject(1, id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected boolean doExistsById(ID id) {
|
|
||||||
return queryOne("select 1 from " + table + " where " + idColumn + " = ?",
|
|
||||||
ps -> ps.setObject(1, id),
|
|
||||||
rs -> rs.getInt(1)).isPresent();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected long doCount() {
|
|
||||||
return queryOne("select count(*) from " + table, ps -> {},
|
|
||||||
rs -> rs.getLong(1)).orElse(0L);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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) {
|
|
||||||
try (PreparedStatement ps = connection().prepareStatement(sql)) {
|
|
||||||
params.bind(ps);
|
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
|
||||||
return rs.next() ? Optional.of(mapper.map(rs)) : Optional.empty();
|
|
||||||
}
|
|
||||||
} catch (SQLException e) { throw new TxException(e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
protected List<T> queryMany(String sql, SqlBinder params) {
|
|
||||||
try (PreparedStatement ps = connection().prepareStatement(sql)) {
|
|
||||||
params.bind(ps);
|
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
|
||||||
List<T> results = new ArrayList<>();
|
|
||||||
while (rs.next()) results.add(mapRow(rs));
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
} catch (SQLException e) { throw new TxException(e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
protected int mutate(String sql, SqlBinder params) {
|
|
||||||
try (PreparedStatement ps = connection().prepareStatement(sql)) {
|
|
||||||
params.bind(ps);
|
|
||||||
return ps.executeUpdate();
|
|
||||||
} catch (SQLException e) { throw new TxException(e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
protected void applyGeneratedKey(PreparedStatement ps, T entity) throws SQLException {
|
|
||||||
// override when entity has a generated PK
|
|
||||||
}
|
|
||||||
|
|
||||||
private String orderClause(Sort sort) {
|
|
||||||
return " order by " + sort.columns().stream()
|
|
||||||
.map(c -> c.column() + (c.asc() ? " ASC" : " DESC"))
|
|
||||||
.collect(Collectors.joining(", "));
|
|
||||||
}
|
|
||||||
|
|
||||||
@FunctionalInterface public interface SqlBinder { void bind(PreparedStatement ps) throws SQLException; }
|
|
||||||
@FunctionalInterface public interface SqlMapper<R> { R map(ResultSet rs) throws SQLException; }
|
|
||||||
}
|
|
||||||
-124
@@ -1,124 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.jdbc;
|
|
||||||
|
|
||||||
import dev.relism.flash.ext.data.core.*;
|
|
||||||
|
|
||||||
import javax.sql.DataSource;
|
|
||||||
import java.sql.Connection;
|
|
||||||
import java.sql.SQLException;
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
public class JdbcTxManager implements TxManager {
|
|
||||||
private static final TxResourceKey JDBC_STATUS_KEY = TxResourceKey.of("jdbc.tx.status");
|
|
||||||
|
|
||||||
private final DataSource ds;
|
|
||||||
|
|
||||||
public JdbcTxManager(DataSource ds) {
|
|
||||||
this.ds = Objects.requireNonNull(ds);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public TxStatus begin(TxDefinition definition) {
|
|
||||||
return switch (definition.propagation()) {
|
|
||||||
case REQUIRED -> ResourceRegistry.isBound(JDBC_STATUS_KEY)
|
|
||||||
? joinExisting(definition)
|
|
||||||
: beginNew(definition);
|
|
||||||
case REQUIRES_NEW -> beginNew(definition);
|
|
||||||
case 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");
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private TxStatus beginNew(TxDefinition definition) {
|
|
||||||
try {
|
|
||||||
JdbcTxStatus suspended = ResourceRegistry.getOrNull(JDBC_STATUS_KEY, JdbcTxStatus.class);
|
|
||||||
if (suspended != null) {
|
|
||||||
ResourceRegistry.unbind(JDBC_STATUS_KEY);
|
|
||||||
}
|
|
||||||
Connection conn = ds.getConnection();
|
|
||||||
conn.setAutoCommit(false);
|
|
||||||
if (definition.readOnly()) conn.setReadOnly(true);
|
|
||||||
if (definition.isolation() != TransactionIsolation.DEFAULT) {
|
|
||||||
conn.setTransactionIsolation(definition.isolation().level());
|
|
||||||
}
|
|
||||||
JdbcTxStatus status = new JdbcTxStatus(
|
|
||||||
conn,
|
|
||||||
true,
|
|
||||||
definition.readOnly(),
|
|
||||||
suspended,
|
|
||||||
new JdbcTxStatus.RollbackMarker()
|
|
||||||
);
|
|
||||||
ResourceRegistry.bind(JDBC_STATUS_KEY, status);
|
|
||||||
return status;
|
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new TxException(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private TxStatus joinExisting(TxDefinition definition) {
|
|
||||||
JdbcTxStatus existing = ResourceRegistry.get(JDBC_STATUS_KEY, JdbcTxStatus.class);
|
|
||||||
if (definition.readOnly() && !existing.isReadOnly()) {
|
|
||||||
throw new TxException("Cannot join read-write tx as read-only");
|
|
||||||
}
|
|
||||||
return new JdbcTxStatus(
|
|
||||||
existing.connection(),
|
|
||||||
false,
|
|
||||||
definition.readOnly(),
|
|
||||||
null,
|
|
||||||
existing.rollbackMarker()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void commit(TxStatus status) {
|
|
||||||
JdbcTxStatus s = (JdbcTxStatus) status;
|
|
||||||
if (!s.isNewTransaction()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (s.isRollbackOnly()) {
|
|
||||||
s.connection().rollback();
|
|
||||||
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
s.connection().commit();
|
|
||||||
ResourceRegistry.fireSynchronizations(TxOutcome.COMMITTED);
|
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new TxException(e);
|
|
||||||
} finally {
|
|
||||||
cleanupAndResume(s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void rollback(TxStatus status) {
|
|
||||||
JdbcTxStatus s = (JdbcTxStatus) status;
|
|
||||||
if (!s.isNewTransaction()) {
|
|
||||||
s.markRollbackOnly();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
s.connection().rollback();
|
|
||||||
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK);
|
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new TxException(e);
|
|
||||||
} finally {
|
|
||||||
cleanupAndResume(s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void cleanupAndResume(JdbcTxStatus status) {
|
|
||||||
ResourceRegistry.unbind(JDBC_STATUS_KEY);
|
|
||||||
try {
|
|
||||||
status.connection().close();
|
|
||||||
} catch (SQLException ignored) {
|
|
||||||
}
|
|
||||||
JdbcTxStatus suspended = status.suspended();
|
|
||||||
if (suspended != null) {
|
|
||||||
ResourceRegistry.bind(JDBC_STATUS_KEY, suspended);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-46
@@ -1,46 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.jdbc;
|
|
||||||
|
|
||||||
import dev.relism.flash.ext.data.core.TxStatus;
|
|
||||||
|
|
||||||
import java.sql.Connection;
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
class JdbcTxStatus implements TxStatus {
|
|
||||||
static final class RollbackMarker {
|
|
||||||
boolean rollbackOnly;
|
|
||||||
}
|
|
||||||
|
|
||||||
private final Connection connection;
|
|
||||||
private final boolean newTransaction;
|
|
||||||
private final boolean readOnly;
|
|
||||||
private final JdbcTxStatus suspended;
|
|
||||||
private final RollbackMarker rollbackMarker;
|
|
||||||
|
|
||||||
JdbcTxStatus(
|
|
||||||
Connection connection,
|
|
||||||
boolean newTransaction,
|
|
||||||
boolean readOnly,
|
|
||||||
JdbcTxStatus suspended,
|
|
||||||
RollbackMarker rollbackMarker
|
|
||||||
) {
|
|
||||||
this.connection = Objects.requireNonNull(connection);
|
|
||||||
this.newTransaction = newTransaction;
|
|
||||||
this.readOnly = readOnly;
|
|
||||||
this.suspended = suspended;
|
|
||||||
this.rollbackMarker = rollbackMarker;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public boolean isNewTransaction() { return newTransaction; }
|
|
||||||
@Override public boolean isReadOnly() { return readOnly; }
|
|
||||||
@Override public boolean isRollbackOnly() { return rollbackMarker.rollbackOnly; }
|
|
||||||
@Override public void markRollbackOnly() { rollbackMarker.rollbackOnly = true; }
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public <R> R resource(Class<R> type) {
|
|
||||||
return type.cast(connection);
|
|
||||||
}
|
|
||||||
|
|
||||||
Connection connection() { return connection; }
|
|
||||||
JdbcTxStatus suspended() { return suspended; }
|
|
||||||
RollbackMarker rollbackMarker() { return rollbackMarker; }
|
|
||||||
}
|
|
||||||
-104
@@ -1,104 +0,0 @@
|
|||||||
package dev.relism.flash.ext.data.jdbc;
|
|
||||||
|
|
||||||
import dev.relism.flash.ext.data.core.*;
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import javax.sql.DataSource;
|
|
||||||
import java.sql.Connection;
|
|
||||||
import java.sql.DriverManager;
|
|
||||||
import java.sql.SQLException;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
|
|
||||||
class JdbcTxManagerTest {
|
|
||||||
private final JdbcTxManager manager = new JdbcTxManager(dataSource());
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
void cleanup() {
|
|
||||||
ResourceRegistry.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void required_starts_new_when_absent() {
|
|
||||||
TxStatus s = manager.begin(TxDefinition.DEFAULTS);
|
|
||||||
assertTrue(s.isNewTransaction());
|
|
||||||
assertDoesNotThrow(() -> manager.commit(s));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void required_joins_existing_when_present() {
|
|
||||||
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
|
||||||
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
|
|
||||||
assertSame(outer.resource(Connection.class), inner.resource(Connection.class));
|
|
||||||
assertFalse(inner.isNewTransaction());
|
|
||||||
manager.rollback(outer);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void requires_new_creates_distinct_connection() {
|
|
||||||
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
|
||||||
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW));
|
|
||||||
assertNotSame(outer.resource(Connection.class), inner.resource(Connection.class));
|
|
||||||
manager.commit(inner);
|
|
||||||
manager.rollback(outer);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void rollback_on_joined_marks_outer_rollback_only() {
|
|
||||||
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
|
||||||
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
|
|
||||||
manager.rollback(inner);
|
|
||||||
assertTrue(outer.isRollbackOnly());
|
|
||||||
manager.rollback(outer);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DataSource dataSource() {
|
|
||||||
return new DataSource() {
|
|
||||||
@Override
|
|
||||||
public Connection getConnection() throws SQLException {
|
|
||||||
return DriverManager.getConnection("jdbc:h2:mem:tx-jdbc;DB_CLOSE_DELAY=-1");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Connection getConnection(String username, String password) throws SQLException {
|
|
||||||
return DriverManager.getConnection("jdbc:h2:mem:tx-jdbc;DB_CLOSE_DELAY=-1", username, password);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public <T> T unwrap(Class<T> iface) {
|
|
||||||
throw new UnsupportedOperationException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean isWrapperFor(Class<?> iface) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public java.io.PrintWriter getLogWriter() {
|
|
||||||
throw new UnsupportedOperationException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void setLogWriter(java.io.PrintWriter out) {
|
|
||||||
throw new UnsupportedOperationException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void setLoginTimeout(int seconds) {
|
|
||||||
throw new UnsupportedOperationException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int getLoginTimeout() {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public java.util.logging.Logger getParentLogger() {
|
|
||||||
throw new UnsupportedOperationException();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
# flash-ext-jackson
|
|
||||||
|
|
||||||
Jackson JSON integration for Flash with an opinionated auto-marshal middleware.
|
|
||||||
|
|
||||||
## What it provides
|
|
||||||
|
|
||||||
| Component | Description |
|
|
||||||
|---|---|
|
|
||||||
| `JacksonExtension` | Registers JSON services into `FlashContext` |
|
|
||||||
| `Json` | JSON read/write helper (`body`, `bodyFrom`, `write`, `writeView`) |
|
|
||||||
| `ObjectMapper` | Raw mapper escape hatch for advanced usage |
|
|
||||||
| `JacksonMiddleware` | `autoJson()` middleware for automatic outbound JSON marshalling |
|
|
||||||
|
|
||||||
Default mapper behavior (`new JacksonExtension()`):
|
|
||||||
|
|
||||||
- auto-discovers Jackson modules on classpath (`findAndAddModules()`)
|
|
||||||
- includes Java Time support (`jackson-datatype-jsr310`)
|
|
||||||
- writes date/time values as ISO-8601 strings (not numeric timestamps)
|
|
||||||
|
|
||||||
## Recommended default
|
|
||||||
|
|
||||||
Install the extension, then apply `autoJson()` once at app or scope level.
|
|
||||||
|
|
||||||
```java
|
|
||||||
JacksonExtension jackson = new JacksonExtension();
|
|
||||||
|
|
||||||
FlashApp app = FlashApp.create(8080)
|
|
||||||
.install(jackson)
|
|
||||||
.use(jackson.autoJson());
|
|
||||||
|
|
||||||
app.startAndBlock();
|
|
||||||
```
|
|
||||||
|
|
||||||
Behavior of `autoJson()`:
|
|
||||||
|
|
||||||
- pass-through: `null`, `Response`, `byte[]`, `String`, `CharSequence`
|
|
||||||
- any other return value: serialize to JSON `byte[]`
|
|
||||||
- sets `Content-Type: application/json` for marshalled responses
|
|
||||||
- serialization failures throw `IllegalStateException`
|
|
||||||
|
|
||||||
This keeps handlers concise while preserving Flash's direct byte write path.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash-ext-jackson</artifactId>
|
|
||||||
<version>1.1-indev2</version>
|
|
||||||
</dependency>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Json helper API
|
|
||||||
|
|
||||||
Use `Json` when you want explicit, local control in a handler.
|
|
||||||
|
|
||||||
```java
|
|
||||||
@POST("/users")
|
|
||||||
public final class CreateUser extends RequestHandler {
|
|
||||||
private Json json;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onInit() {
|
|
||||||
json = require(Json.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Object handle(Request req, Response res) throws Exception {
|
|
||||||
CreateUserBody body = json.body(req, CreateUserBody.class);
|
|
||||||
UserDto created = service.create(body);
|
|
||||||
res.status(201);
|
|
||||||
return json.write(res, created);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Methods:
|
|
||||||
|
|
||||||
- `body(req, Type.class)` -> parse from `req.body().bytes()`
|
|
||||||
- `bodyFrom(req, Type.class)` -> parse from `req.body().stream()`
|
|
||||||
- `write(res, obj)` -> writes JSON string and sets JSON content type
|
|
||||||
- `writeView(res, obj, View.class)` -> JSON with Jackson `@JsonView`
|
|
||||||
- `mapper()` -> raw `ObjectMapper`
|
|
||||||
|
|
||||||
## Custom mapper
|
|
||||||
|
|
||||||
```java
|
|
||||||
ObjectMapper mapper = JsonMapper.builder()
|
|
||||||
.addModule(new JavaTimeModule())
|
|
||||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
FlashApp.create(8080)
|
|
||||||
.install(new JacksonExtension(mapper));
|
|
||||||
```
|
|
||||||
|
|
||||||
## Scope usage
|
|
||||||
|
|
||||||
`autoJson()` works the same at scope level:
|
|
||||||
|
|
||||||
```java
|
|
||||||
JacksonExtension jackson = new JacksonExtension();
|
|
||||||
|
|
||||||
app.mount("/api", api -> {
|
|
||||||
api.use(jackson.autoJson());
|
|
||||||
api.get("/health", (req, res) -> Map.of("ok", true));
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
If you need to pull it from context, `JacksonMiddleware` is also provided as a service
|
|
||||||
after the app boots (same lifecycle model as other extension-provided services).
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Install order is irrelevant (Flash two-phase extension lifecycle).
|
|
||||||
- `autoJson()` and OpenAPI are intentionally decoupled.
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
|
||||||
<modelVersion>4.0.0</modelVersion>
|
|
||||||
|
|
||||||
<parent>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash-extensions</artifactId>
|
|
||||||
<version>2.0.0</version>
|
|
||||||
</parent>
|
|
||||||
|
|
||||||
<artifactId>flash-ext-jackson</artifactId>
|
|
||||||
|
|
||||||
<properties>
|
|
||||||
<jacoco.version>0.8.12</jacoco.version>
|
|
||||||
</properties>
|
|
||||||
|
|
||||||
<dependencies>
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.fasterxml.jackson.core</groupId>
|
|
||||||
<artifactId>jackson-databind</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
|
||||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.projectlombok</groupId>
|
|
||||||
<artifactId>lombok</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.junit.jupiter</groupId>
|
|
||||||
<artifactId>junit-jupiter</artifactId>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
|
|
||||||
<build>
|
|
||||||
<plugins>
|
|
||||||
<plugin>
|
|
||||||
<groupId>org.jacoco</groupId>
|
|
||||||
<artifactId>jacoco-maven-plugin</artifactId>
|
|
||||||
<version>${jacoco.version}</version>
|
|
||||||
<executions>
|
|
||||||
<execution>
|
|
||||||
<id>jacoco-prepare-agent</id>
|
|
||||||
<goals>
|
|
||||||
<goal>prepare-agent</goal>
|
|
||||||
</goals>
|
|
||||||
</execution>
|
|
||||||
<execution>
|
|
||||||
<id>jacoco-report-and-check</id>
|
|
||||||
<phase>verify</phase>
|
|
||||||
<goals>
|
|
||||||
<goal>report</goal>
|
|
||||||
<goal>check</goal>
|
|
||||||
</goals>
|
|
||||||
<configuration>
|
|
||||||
<rules>
|
|
||||||
<rule>
|
|
||||||
<element>BUNDLE</element>
|
|
||||||
<limits>
|
|
||||||
<limit>
|
|
||||||
<counter>LINE</counter>
|
|
||||||
<value>COVEREDRATIO</value>
|
|
||||||
<minimum>0.80</minimum>
|
|
||||||
</limit>
|
|
||||||
</limits>
|
|
||||||
</rule>
|
|
||||||
</rules>
|
|
||||||
</configuration>
|
|
||||||
</execution>
|
|
||||||
</executions>
|
|
||||||
</plugin>
|
|
||||||
</plugins>
|
|
||||||
</build>
|
|
||||||
|
|
||||||
</project>
|
|
||||||
-92
@@ -1,92 +0,0 @@
|
|||||||
package dev.relism.flash.ext.jackson;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
|
||||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
|
||||||
import dev.relism.flash.extension.FlashContext;
|
|
||||||
import dev.relism.flash.extension.FlashExtension;
|
|
||||||
import dev.relism.flash.routing.Middleware;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers JSON support into the Flash extension layer.
|
|
||||||
*
|
|
||||||
* <p>Exposes a {@link Json} utility instance in the {@link FlashContext} under
|
|
||||||
* {@code Json.class}. Any handler or extension can retrieve it via {@code ctx.require(Json.class)}
|
|
||||||
* inside {@code onInit()} (class-based) or inside {@link FlashExtension#routes} (extensions).
|
|
||||||
*
|
|
||||||
* <p>The raw {@link ObjectMapper} is also registered under {@code ObjectMapper.class}
|
|
||||||
* for extensions that need direct mapper access (e.g. OpenAPI schema generation).
|
|
||||||
*
|
|
||||||
* <p>{@link JacksonMiddleware} is provided under {@code JacksonMiddleware.class} and
|
|
||||||
* exposes opinionated JSON auto-marshalling middleware via {@link JacksonMiddleware#autoJson()}.
|
|
||||||
*
|
|
||||||
* <h3>Usage — composition (preferred)</h3>
|
|
||||||
* <pre>{@code
|
|
||||||
* public class MyHandler extends RequestHandler {
|
|
||||||
* private Json json;
|
|
||||||
*
|
|
||||||
* @Override protected void onInit() {
|
|
||||||
* json = require(Json.class);
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* public Object handle(Request req, Response res) throws Exception {
|
|
||||||
* MyDto dto = json.body(req, MyDto.class);
|
|
||||||
* return json.write(res, 201, dto);
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
* }</pre>
|
|
||||||
*
|
|
||||||
* <h3>Custom mapper</h3>
|
|
||||||
* <pre>{@code
|
|
||||||
* ObjectMapper mapper = JsonMapper.builder()
|
|
||||||
* .addModule(new JavaTimeModule())
|
|
||||||
* .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
|
||||||
* .build();
|
|
||||||
*
|
|
||||||
* FlashApp.create(8080)
|
|
||||||
* .install(new JacksonExtension(mapper));
|
|
||||||
* }</pre>
|
|
||||||
*/
|
|
||||||
public class JacksonExtension implements FlashExtension {
|
|
||||||
|
|
||||||
private final ObjectMapper mapper;
|
|
||||||
private final JacksonMiddleware middleware;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Installs with an opinionated default {@link JsonMapper}:
|
|
||||||
* auto-discovers modules on classpath (e.g. Java Time) and writes dates as ISO strings.
|
|
||||||
*/
|
|
||||||
public JacksonExtension() {
|
|
||||||
this(JsonMapper.builder()
|
|
||||||
.findAndAddModules()
|
|
||||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
|
||||||
.build());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Installs with a fully configured custom {@link ObjectMapper}. */
|
|
||||||
public JacksonExtension(ObjectMapper mapper) {
|
|
||||||
this.mapper = mapper;
|
|
||||||
this.middleware = new JacksonMiddleware(mapper);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opinionated outbound JSON middleware factory.
|
|
||||||
*
|
|
||||||
* <p>Use for app/scope-level registration:
|
|
||||||
* <pre>{@code
|
|
||||||
* JacksonExtension jackson = new JacksonExtension();
|
|
||||||
* app.install(jackson).use(jackson.autoJson());
|
|
||||||
* }</pre>
|
|
||||||
*/
|
|
||||||
public Middleware autoJson() {
|
|
||||||
return middleware.autoJson();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void provide(FlashContext ctx) {
|
|
||||||
Json json = new Json(mapper);
|
|
||||||
ctx.provide(Json.class, json);
|
|
||||||
ctx.provide(ObjectMapper.class, mapper);
|
|
||||||
ctx.provide(JacksonMiddleware.class, middleware);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-62
@@ -1,62 +0,0 @@
|
|||||||
package dev.relism.flash.ext.jackson;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import dev.relism.flash.http.ContentType;
|
|
||||||
import dev.relism.flash.models.Response;
|
|
||||||
import dev.relism.flash.routing.Middleware;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Outbound JSON marshalling middleware for class-based and lambda routes.
|
|
||||||
*
|
|
||||||
* <p>{@link #autoJson()} marshals any non-body-native return value to JSON bytes,
|
|
||||||
* writes {@code Content-Type: application/json}, and returns {@code byte[]} so
|
|
||||||
* the Flash write path stays direct.
|
|
||||||
*
|
|
||||||
* <p>Pass-through return types:
|
|
||||||
* <ul>
|
|
||||||
* <li>{@code null}</li>
|
|
||||||
* <li>{@link Response}</li>
|
|
||||||
* <li>{@code byte[]}</li>
|
|
||||||
* <li>{@link String}</li>
|
|
||||||
* <li>{@link CharSequence}</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
public final class JacksonMiddleware {
|
|
||||||
|
|
||||||
private final ObjectMapper mapper;
|
|
||||||
|
|
||||||
JacksonMiddleware(ObjectMapper mapper) {
|
|
||||||
this.mapper = mapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Automatic JSON marshalling policy.
|
|
||||||
*
|
|
||||||
* <p>For non-pass-through return values, serializes with Jackson directly to
|
|
||||||
* {@code byte[]} and sets response content type to JSON.
|
|
||||||
*
|
|
||||||
* @throws IllegalStateException when serialization fails
|
|
||||||
*/
|
|
||||||
public Middleware autoJson() {
|
|
||||||
return next -> (req, res) -> {
|
|
||||||
Object out = next.handle(req, res);
|
|
||||||
if (isPassThrough(out)) return out;
|
|
||||||
|
|
||||||
res.type(ContentType.JSON);
|
|
||||||
try {
|
|
||||||
return mapper.writeValueAsBytes(out);
|
|
||||||
} catch (JsonProcessingException e) {
|
|
||||||
throw new IllegalStateException(
|
|
||||||
"Failed to serialize handler result as JSON: " + out.getClass().getName(), e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isPassThrough(Object out) {
|
|
||||||
return out == null
|
|
||||||
|| out instanceof Response
|
|
||||||
|| out instanceof byte[]
|
|
||||||
|| out instanceof CharSequence;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-117
@@ -1,117 +0,0 @@
|
|||||||
package dev.relism.flash.ext.jackson;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import dev.relism.flash.exceptions.HttpException;
|
|
||||||
import dev.relism.flash.http.ContentType;
|
|
||||||
import dev.relism.flash.models.Request;
|
|
||||||
import dev.relism.flash.models.Response;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thread-safe JSON toolbox. Single point of access for all JSON I/O operations
|
|
||||||
* within a Flash application.
|
|
||||||
*
|
|
||||||
* <p>Retrieve once at boot time via {@code require(Json.class)} inside
|
|
||||||
* {@code onInit()}, cache in a private field, and call on the hot path
|
|
||||||
* with zero lookup or allocation overhead:
|
|
||||||
*
|
|
||||||
* <pre>{@code
|
|
||||||
* @Route(method = HttpMethod.POST, path = "/api/items")
|
|
||||||
* public class CreateItemHandler extends RequestHandler {
|
|
||||||
*
|
|
||||||
* private Json json;
|
|
||||||
*
|
|
||||||
* @Override
|
|
||||||
* protected void onInit() {
|
|
||||||
* json = require(Json.class);
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* public Object handle(Request req, Response res) throws Exception {
|
|
||||||
* CreateItemRequest body = json.body(req, CreateItemRequest.class);
|
|
||||||
* return json.write(res, itemService.create(body));
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
* }</pre>
|
|
||||||
*
|
|
||||||
* <p>The underlying {@link ObjectMapper} is shared across all handlers in the same
|
|
||||||
* scope (one instance per app / per child scope). Jackson's {@code ObjectMapper}
|
|
||||||
* is fully thread-safe after configuration — no synchronization is needed.
|
|
||||||
*
|
|
||||||
* <p>Install via {@link JacksonExtension} before calling {@code scan()} or
|
|
||||||
* {@code register()}.
|
|
||||||
*/
|
|
||||||
public final class Json {
|
|
||||||
|
|
||||||
private final ObjectMapper mapper;
|
|
||||||
|
|
||||||
/** Package-private — constructed exclusively by {@link JacksonExtension}. */
|
|
||||||
Json(ObjectMapper mapper) {
|
|
||||||
this.mapper = mapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Input ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deserializes the full request body into an instance of {@code type}.
|
|
||||||
*
|
|
||||||
* <p>Reads {@code req.body().bytes()} in one shot. For streaming bodies
|
|
||||||
* use {@link #bodyFrom(Request, Class)} instead.
|
|
||||||
*
|
|
||||||
* @throws HttpException 400 if the body cannot be parsed as {@code type}
|
|
||||||
*/
|
|
||||||
public <T> T body(Request req, Class<T> type) throws Exception {
|
|
||||||
try {
|
|
||||||
return mapper.readValue(req.body().bytes(), type);
|
|
||||||
} catch (JsonProcessingException e) {
|
|
||||||
throw HttpException.badRequest("Invalid request body: " + e.getOriginalMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deserializes the request body via the raw {@link java.io.InputStream},
|
|
||||||
* avoiding the intermediate {@code byte[]} allocation. Prefer this for
|
|
||||||
* large bodies or when allocation budget is tight.
|
|
||||||
*
|
|
||||||
* @throws HttpException 400 on parse failure
|
|
||||||
*/
|
|
||||||
public <T> T bodyFrom(Request req, Class<T> type) throws Exception {
|
|
||||||
try {
|
|
||||||
return mapper.readValue(req.body().stream(), type);
|
|
||||||
} catch (JsonProcessingException e) {
|
|
||||||
throw HttpException.badRequest("Invalid request body: " + e.getOriginalMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Output ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serializes {@code obj} to a JSON string and sets
|
|
||||||
* {@code Content-Type: application/json} on the response.
|
|
||||||
*
|
|
||||||
* <p>The returned string is used as the response body by the Flash runtime.
|
|
||||||
*/
|
|
||||||
public String write(Response res, Object obj) throws Exception {
|
|
||||||
res.type(ContentType.JSON);
|
|
||||||
return mapper.writeValueAsString(obj);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Like {@link #write} but applies a Jackson {@code @JsonView} filter,
|
|
||||||
* restricting serialization to fields visible under {@code view}.
|
|
||||||
*/
|
|
||||||
public String writeView(Response res, Object obj, Class<?> view) throws Exception {
|
|
||||||
res.type(ContentType.JSON);
|
|
||||||
return mapper.writerWithView(view).writeValueAsString(obj);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Escape hatch ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the underlying {@link ObjectMapper} for advanced operations
|
|
||||||
* (custom serialization, schema generation, etc.) not covered by the
|
|
||||||
* methods above.
|
|
||||||
*/
|
|
||||||
public ObjectMapper mapper() {
|
|
||||||
return mapper;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-61
@@ -1,61 +0,0 @@
|
|||||||
package dev.relism.flash.ext.jackson;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import dev.relism.flash.extension.FlashContext;
|
|
||||||
import dev.relism.flash.models.SimpleHandler;
|
|
||||||
import dev.relism.flash.http.ContentType;
|
|
||||||
import dev.relism.flash.models.Request;
|
|
||||||
import dev.relism.flash.models.RequestHandler;
|
|
||||||
import dev.relism.flash.models.Response;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
class JacksonExtensionTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void provide_registers_json_mapper_and_middleware() {
|
|
||||||
FlashContext ctx = new FlashContext();
|
|
||||||
ObjectMapper mapper = new ObjectMapper();
|
|
||||||
JacksonExtension ext = new JacksonExtension(mapper);
|
|
||||||
|
|
||||||
ext.provide(ctx);
|
|
||||||
|
|
||||||
assertNotNull(ctx.require(Json.class));
|
|
||||||
assertNotNull(ctx.require(JacksonMiddleware.class));
|
|
||||||
assertSame(mapper, ctx.require(ObjectMapper.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void autoJson_factory_delegates_to_middleware_policy() throws Exception {
|
|
||||||
ObjectMapper mapper = new ObjectMapper();
|
|
||||||
JacksonExtension ext = new JacksonExtension(mapper);
|
|
||||||
RequestHandler next = new RequestHandler() {
|
|
||||||
@Override
|
|
||||||
public Object handle(Request request, Response response) {
|
|
||||||
return new Payload("ok");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
RequestHandler wrapped = new RequestHandler() {
|
|
||||||
private final SimpleHandler.FunctionalHandler delegate = ext.autoJson().wrap(next);
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Object handle(Request request, Response response) throws Exception {
|
|
||||||
return delegate.handle(request, response);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
|
||||||
Object out = wrapped.handle(null, res);
|
|
||||||
|
|
||||||
assertTrue(out instanceof byte[]);
|
|
||||||
assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
|
|
||||||
}
|
|
||||||
|
|
||||||
private record Payload(String status) {}
|
|
||||||
}
|
|
||||||
-90
@@ -1,90 +0,0 @@
|
|||||||
package dev.relism.flash.ext.jackson;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import dev.relism.flash.models.SimpleHandler;
|
|
||||||
import dev.relism.flash.http.ContentType;
|
|
||||||
import dev.relism.flash.models.Request;
|
|
||||||
import dev.relism.flash.models.RequestHandler;
|
|
||||||
import dev.relism.flash.models.Response;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
class JacksonMiddlewareTest {
|
|
||||||
|
|
||||||
private static final Request REQ = null;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void autoJson_marshalsPojo_toJsonBytes_and_setsJsonContentType() throws Exception {
|
|
||||||
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
|
|
||||||
RequestHandler wrapped = wrap(mw, new UserDto("u1", "alice"));
|
|
||||||
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
|
||||||
|
|
||||||
Object out = wrapped.handle(REQ, res);
|
|
||||||
|
|
||||||
assertInstanceOf(byte[].class, out);
|
|
||||||
assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
|
|
||||||
assertEquals("{\"id\":\"u1\",\"name\":\"alice\"}", new String((byte[]) out, StandardCharsets.UTF_8));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void autoJson_passThrough_for_response_string_charSequence_bytes_and_null() throws Exception {
|
|
||||||
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
|
|
||||||
|
|
||||||
Response payloadResponse = new Response(201, ContentType.TEXT_PLAIN).body("ok");
|
|
||||||
RequestHandler wrappedResponse = wrap(mw, payloadResponse);
|
|
||||||
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
|
||||||
assertSame(payloadResponse, wrappedResponse.handle(REQ, res));
|
|
||||||
|
|
||||||
String s = "hello";
|
|
||||||
assertSame(s, wrap(mw, s).handle(REQ, res));
|
|
||||||
|
|
||||||
CharSequence cs = new StringBuilder("hello-cs");
|
|
||||||
assertSame(cs, wrap(mw, cs).handle(REQ, res));
|
|
||||||
|
|
||||||
byte[] bytes = new byte[]{1, 2, 3};
|
|
||||||
assertSame(bytes, wrap(mw, bytes).handle(REQ, res));
|
|
||||||
|
|
||||||
assertSame(null, wrap(mw, null).handle(REQ, res));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void autoJson_wraps_serialization_errors_as_illegal_state() {
|
|
||||||
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
|
|
||||||
RequestHandler wrapped = wrap(mw, new CyclicDto());
|
|
||||||
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
|
||||||
|
|
||||||
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> wrapped.handle(REQ, res));
|
|
||||||
assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
|
|
||||||
assertTrue(ex.getMessage().startsWith("Failed to serialize handler result as JSON:"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static RequestHandler wrap(JacksonMiddleware mw, Object fixedReturn) {
|
|
||||||
RequestHandler next = new RequestHandler() {
|
|
||||||
@Override
|
|
||||||
public Object handle(Request request, Response response) {
|
|
||||||
return fixedReturn;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return new RequestHandler() {
|
|
||||||
private final SimpleHandler.FunctionalHandler delegate = mw.autoJson().wrap(next);
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Object handle(Request request, Response response) throws Exception {
|
|
||||||
return delegate.handle(request, response);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private record UserDto(String id, String name) {}
|
|
||||||
|
|
||||||
private static final class CyclicDto {
|
|
||||||
CyclicDto self = this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-116
@@ -1,116 +0,0 @@
|
|||||||
package dev.relism.flash.ext.jackson;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonView;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import dev.relism.flash.exceptions.HttpException;
|
|
||||||
import dev.relism.flash.http.ContentType;
|
|
||||||
import dev.relism.flash.http.HttpMethod;
|
|
||||||
import dev.relism.flash.models.HeaderMap;
|
|
||||||
import dev.relism.flash.models.Request;
|
|
||||||
import dev.relism.flash.models.RequestLine;
|
|
||||||
import dev.relism.flash.models.Response;
|
|
||||||
import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
class JsonTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void body_parses_valid_json_and_maps_bad_payload_to_http_400() throws Exception {
|
|
||||||
Json json = new Json(new ObjectMapper());
|
|
||||||
|
|
||||||
Request ok = request("{\"id\":\"u1\",\"name\":\"alice\"}");
|
|
||||||
UserDto dto = json.body(ok, UserDto.class);
|
|
||||||
assertEquals("u1", dto.id);
|
|
||||||
assertEquals("alice", dto.name);
|
|
||||||
|
|
||||||
Request bad = request("not-json");
|
|
||||||
HttpException ex = assertThrows(HttpException.class, () -> json.body(bad, UserDto.class));
|
|
||||||
assertEquals(400, ex.status());
|
|
||||||
assertTrue(ex.getMessage().startsWith("Invalid request body:"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void bodyFrom_parses_stream_and_maps_bad_payload_to_http_400() throws Exception {
|
|
||||||
Json json = new Json(new ObjectMapper());
|
|
||||||
|
|
||||||
Request ok = request("{\"id\":\"u2\",\"name\":\"bob\"}");
|
|
||||||
UserDto dto = json.bodyFrom(ok, UserDto.class);
|
|
||||||
assertEquals("u2", dto.id);
|
|
||||||
assertEquals("bob", dto.name);
|
|
||||||
|
|
||||||
Request bad = request("[");
|
|
||||||
HttpException ex = assertThrows(HttpException.class, () -> json.bodyFrom(bad, UserDto.class));
|
|
||||||
assertEquals(400, ex.status());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void write_and_writeView_set_content_type_and_render_expected_payload() throws Exception {
|
|
||||||
Json json = new Json(new ObjectMapper());
|
|
||||||
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
|
||||||
|
|
||||||
String payload = json.write(res, new UserDto("u3", "carol"));
|
|
||||||
assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
|
|
||||||
assertEquals("{\"id\":\"u3\",\"name\":\"carol\"}", payload);
|
|
||||||
|
|
||||||
Response viewRes = new Response(200, ContentType.TEXT_PLAIN);
|
|
||||||
String viewed = json.writeView(viewRes, new ViewDto("u4", "hidden"), PublicView.class);
|
|
||||||
assertEquals("application/json", new String(viewRes.getContentType(), StandardCharsets.UTF_8));
|
|
||||||
assertEquals("{\"id\":\"u4\"}", viewed);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void mapper_returns_underlying_object_mapper_instance() {
|
|
||||||
ObjectMapper mapper = new ObjectMapper();
|
|
||||||
Json json = new Json(mapper);
|
|
||||||
assertSame(mapper, json.mapper());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Request request(String body) {
|
|
||||||
return request(body.getBytes(StandardCharsets.UTF_8));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Request request(byte[] body) {
|
|
||||||
RequestLine line = new RequestLine(
|
|
||||||
HttpMethod.POST,
|
|
||||||
new FastPathViews.StringByteView("/json"),
|
|
||||||
null,
|
|
||||||
new FastPathViews.StringByteView("HTTP/1.1"),
|
|
||||||
new HeaderMap()
|
|
||||||
);
|
|
||||||
return new Request(line, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class UserDto {
|
|
||||||
public String id;
|
|
||||||
public String name;
|
|
||||||
|
|
||||||
public UserDto() {}
|
|
||||||
|
|
||||||
private UserDto(String id, String name) {
|
|
||||||
this.id = id;
|
|
||||||
this.name = name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private interface PublicView {}
|
|
||||||
private interface InternalView {}
|
|
||||||
|
|
||||||
private static final class ViewDto {
|
|
||||||
@JsonView(PublicView.class)
|
|
||||||
public String id;
|
|
||||||
@JsonView(InternalView.class)
|
|
||||||
public String secret;
|
|
||||||
|
|
||||||
private ViewDto(String id, String secret) {
|
|
||||||
this.id = id;
|
|
||||||
this.secret = secret;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
# flash-ext-limiter
|
|
||||||
|
|
||||||
Rate limiting for the Flash HTTP server. Zero-allocation hot-path, lock-free counters,
|
|
||||||
pluggable key resolvers, and two built-in algorithms.
|
|
||||||
|
|
||||||
## What it provides
|
|
||||||
|
|
||||||
| Component | Description |
|
|
||||||
|---|---|
|
|
||||||
| `@Limit` | Annotation for class-based handlers — processed once at boot |
|
|
||||||
| `Guard` | Programmatic middleware factory for lambda routes |
|
|
||||||
| `LimiterConfig` | Resolver registry — map string names to key-extraction lambdas |
|
|
||||||
| `FIXED_WINDOW` | Clock-aligned counter reset; minimal memory |
|
|
||||||
| `TOKEN_BUCKET` | Continuous refill; absorbs bursts smoothly |
|
|
||||||
|
|
||||||
## Dependency
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash-ext-limiter</artifactId>
|
|
||||||
<version>1.0-SNAPSHOT</version>
|
|
||||||
</dependency>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```java
|
|
||||||
// Default install — only the built-in "ip" resolver available
|
|
||||||
FlashApp.create(8080)
|
|
||||||
.install(new LimiterExtension())
|
|
||||||
.scan("com.example.handlers");
|
|
||||||
```
|
|
||||||
|
|
||||||
```java
|
|
||||||
// With custom resolvers
|
|
||||||
LimiterConfig conf = new LimiterConfig()
|
|
||||||
.registerResolver("auth_user", req ->
|
|
||||||
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous");
|
|
||||||
|
|
||||||
FlashApp.create(8080)
|
|
||||||
.install(new LimiterExtension(conf))
|
|
||||||
.scan("com.example.handlers");
|
|
||||||
```
|
|
||||||
|
|
||||||
## Installation order
|
|
||||||
|
|
||||||
Install `LimiterExtension` **before** authentication extensions. Rate-limit checks
|
|
||||||
then short-circuit over-limit requests before expensive token validation runs.
|
|
||||||
|
|
||||||
```java
|
|
||||||
app.install(new LimiterExtension(conf)) // ← first
|
|
||||||
.install(new OidcExtension(oidcConf)) // ← second
|
|
||||||
.scan("com.example");
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docs
|
|
||||||
|
|
||||||
| File | Contents |
|
|
||||||
|---|---|
|
|
||||||
| [key-resolvers.md](key-resolvers.md) | Resolver registration, built-in defaults, custom logic |
|
|
||||||
| [annotation.md](annotation.md) | `@Limit` reference — all fields and examples |
|
|
||||||
| [guard.md](guard.md) | `Guard` for lambda routes — all overloads |
|
|
||||||
| [strategies.md](strategies.md) | `FIXED_WINDOW` vs `TOKEN_BUCKET` — algorithm reference |
|
|
||||||
| [http-headers.md](http-headers.md) | HTTP compliance — headers and 429 response |
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
# @Limit annotation
|
|
||||||
|
|
||||||
Applies a rate limit to a **class-based** `RequestHandler`. The annotation is read once
|
|
||||||
per handler class at boot by the `LimiterExtension` annotation processor — zero overhead
|
|
||||||
at request time.
|
|
||||||
|
|
||||||
## Declaration
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Target(ElementType.TYPE)
|
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
|
||||||
public @interface Limit {
|
|
||||||
String key() default "ip";
|
|
||||||
int requests();
|
|
||||||
long window();
|
|
||||||
TimeUnit windowUnit() default TimeUnit.SECONDS;
|
|
||||||
LimitStrategy strategy() default LimitStrategy.FIXED_WINDOW;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Fields
|
|
||||||
|
|
||||||
| Field | Type | Default | Description |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `key` | `String` | `"ip"` | Name of the key resolver registered in `LimiterConfig` |
|
|
||||||
| `requests` | `int` | — | Maximum requests allowed per window (required) |
|
|
||||||
| `window` | `long` | — | Window duration in `windowUnit` units (required) |
|
|
||||||
| `windowUnit` | `TimeUnit` | `SECONDS` | Time unit for `window` |
|
|
||||||
| `strategy` | `LimitStrategy` | `FIXED_WINDOW` | Rate-limit algorithm |
|
|
||||||
|
|
||||||
## Basic usage
|
|
||||||
|
|
||||||
### 100 requests per second per IP (default)
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Route(method = HttpMethod.GET, path = "/api/search")
|
|
||||||
@Limit(requests = 100, window = 1)
|
|
||||||
public class SearchHandler extends RequestHandler {
|
|
||||||
public Object handle(Request req, Response res) {
|
|
||||||
return searchService.query(req.query("q"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 20 requests per minute per authenticated user
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Route(method = HttpMethod.POST, path = "/api/report")
|
|
||||||
@Limit(key = "auth_user", requests = 20, window = 1, windowUnit = TimeUnit.MINUTES)
|
|
||||||
@Authenticated
|
|
||||||
public class ReportHandler extends RequestHandler {
|
|
||||||
public Object handle(Request req, Response res) { ... }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Order of annotation processors: register `LimiterExtension` before `OidcExtension`
|
|
||||||
so the rate-limit middleware wraps the outer layer of the chain and fires before auth.
|
|
||||||
|
|
||||||
### Token bucket — absorb bursts
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Route(method = HttpMethod.POST, path = "/api/upload")
|
|
||||||
@Limit(
|
|
||||||
key = "api_key",
|
|
||||||
requests = 50,
|
|
||||||
window = 1,
|
|
||||||
windowUnit = TimeUnit.MINUTES,
|
|
||||||
strategy = LimitStrategy.TOKEN_BUCKET
|
|
||||||
)
|
|
||||||
public class UploadHandler extends RequestHandler { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Large window — 1000 requests per hour
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Route(method = HttpMethod.GET, path = "/api/export")
|
|
||||||
@Limit(requests = 1000, window = 1, windowUnit = TimeUnit.HOURS)
|
|
||||||
public class ExportHandler extends RequestHandler { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Strict per-second limit on a public endpoint
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Route(method = HttpMethod.GET, path = "/api/prices")
|
|
||||||
@Limit(requests = 10, window = 1, windowUnit = TimeUnit.SECONDS)
|
|
||||||
public class PriceHandler extends RequestHandler { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Combining @Limit with other annotations
|
|
||||||
|
|
||||||
`@Limit` composes naturally with `@Authenticated`, `@RolesAllowed`, and `@ApiOperation`.
|
|
||||||
Each annotation is processed by its own processor; Flash collects all middleware and
|
|
||||||
composes them in processor registration order.
|
|
||||||
|
|
||||||
When `flash-ext-openapi` is installed, `@Limit` also contributes OpenAPI response
|
|
||||||
headers (`X-RateLimit-*`) and `Retry-After` on `429` automatically.
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Route(method = HttpMethod.DELETE, path = "/admin/users/{id}")
|
|
||||||
@Limit(key = "auth_user", requests = 5, window = 1, windowUnit = TimeUnit.MINUTES)
|
|
||||||
@RolesAllowed("admin")
|
|
||||||
@ApiOperation(summary = "Delete a user", tags = "admin")
|
|
||||||
public class DeleteUserHandler extends RequestHandler { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
Execution chain (outermost → handler):
|
|
||||||
`LimiterMiddleware → OidcRolesMiddleware → DeleteUserHandler`
|
|
||||||
|
|
||||||
## Fail-fast at boot
|
|
||||||
|
|
||||||
If `key` names a resolver not registered in `LimiterConfig`, the server refuses to start:
|
|
||||||
|
|
||||||
```
|
|
||||||
dev.relism.exceptions.InitializationException:
|
|
||||||
Rate-limit resolver "auth_user" is not registered.
|
|
||||||
Call LimiterConfig.registerResolver("auth_user", req -> ...) before install.
|
|
||||||
```
|
|
||||||
|
|
||||||
There is no silent fallback — a misconfigured rate limit is treated as a hard error.
|
|
||||||
|
|
||||||
## What happens on violation
|
|
||||||
|
|
||||||
```
|
|
||||||
HTTP/1.1 429 Too Many Requests
|
|
||||||
X-RateLimit-Limit: 100
|
|
||||||
X-RateLimit-Remaining: 0
|
|
||||||
X-RateLimit-Reset: 1711750860
|
|
||||||
Retry-After: 1
|
|
||||||
Content-Type: text/plain
|
|
||||||
|
|
||||||
Too Many Requests
|
|
||||||
```
|
|
||||||
|
|
||||||
The handler body is never invoked. See [http-headers.md](http-headers.md) for the full
|
|
||||||
header reference.
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
# Guard — programmatic rate limiting for lambda routes
|
|
||||||
|
|
||||||
`Guard` is the rate-limit API for lambda (inline) route registrations. It produces
|
|
||||||
a `Middleware` that is composed once at route-wiring time — the resolver lambda is
|
|
||||||
captured directly into the closure, with no map lookup on the request hot-path.
|
|
||||||
|
|
||||||
## Obtaining Guard
|
|
||||||
|
|
||||||
`Guard` is provided in the `FlashContext` after `LimiterExtension` is installed:
|
|
||||||
|
|
||||||
```java
|
|
||||||
Guard guard = app.ctx().require(Guard.class);
|
|
||||||
```
|
|
||||||
|
|
||||||
Or inside another extension:
|
|
||||||
|
|
||||||
```java
|
|
||||||
public void install(FlashRegistrar app, FlashContext ctx) {
|
|
||||||
Guard guard = ctx.require(Guard.class);
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
```java
|
|
||||||
// Fixed window (default strategy)
|
|
||||||
Middleware limit(String resolverKey, int requests, long window, TimeUnit unit)
|
|
||||||
|
|
||||||
// Explicit strategy
|
|
||||||
Middleware limit(String resolverKey, int requests, long window, TimeUnit unit, LimitStrategy strategy)
|
|
||||||
```
|
|
||||||
|
|
||||||
Both overloads:
|
|
||||||
- Resolve the named key lambda **once** at call time (fail-fast if unknown).
|
|
||||||
- Return a stateless `Middleware` whose closure captures the lambda and `LimitConfig` directly.
|
|
||||||
- Share the `BucketStore` with all other rules registered through this `LimiterExtension` instance.
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Simple per-IP limit on a lambda route
|
|
||||||
|
|
||||||
```java
|
|
||||||
Guard guard = app.ctx().require(Guard.class);
|
|
||||||
|
|
||||||
app.get("/api/search", (req, res) -> searchService.query(req.query("q")))
|
|
||||||
.with(guard.limit("ip", 100, 1, TimeUnit.SECONDS));
|
|
||||||
```
|
|
||||||
|
|
||||||
### Per authenticated user — token bucket
|
|
||||||
|
|
||||||
```java
|
|
||||||
app.post("/api/export", (req, res) -> exportService.run(req))
|
|
||||||
.with(guard.limit("auth_user", 10, 1, TimeUnit.MINUTES, LimitStrategy.TOKEN_BUCKET));
|
|
||||||
```
|
|
||||||
|
|
||||||
### Chaining with other middleware
|
|
||||||
|
|
||||||
`Guard.limit(...)` returns a plain `Middleware`, so it composes with `Middleware.of()`
|
|
||||||
and `.andThen()` exactly like any other middleware:
|
|
||||||
|
|
||||||
```java
|
|
||||||
Middleware secured = Middleware.of(
|
|
||||||
guard.limit("ip", 200, 1, TimeUnit.SECONDS), // ← outermost: runs first
|
|
||||||
oidc.protect()
|
|
||||||
);
|
|
||||||
|
|
||||||
app.get("/dashboard", handler).with(secured);
|
|
||||||
```
|
|
||||||
|
|
||||||
Or with `.andThen()` for two middlewares:
|
|
||||||
|
|
||||||
```java
|
|
||||||
app.get("/dashboard", handler)
|
|
||||||
.with(guard.limit("ip", 200, 1, TimeUnit.SECONDS).andThen(oidc.protect()));
|
|
||||||
```
|
|
||||||
|
|
||||||
### Different limits on the same path by method
|
|
||||||
|
|
||||||
```java
|
|
||||||
// Read: 500/s; Write: 20/s
|
|
||||||
app.get("/api/items", readHandler) .with(guard.limit("ip", 500, 1, TimeUnit.SECONDS));
|
|
||||||
app.post("/api/items", writeHandler).with(guard.limit("ip", 20, 1, TimeUnit.SECONDS));
|
|
||||||
```
|
|
||||||
|
|
||||||
Each `.with(guard.limit(...))` call creates an independent bucket store key namespace —
|
|
||||||
GET and POST requests to `/api/items` share the same IP bucket only if you share the same
|
|
||||||
`Middleware` instance. Using two `guard.limit(...)` calls creates **two independent buckets**.
|
|
||||||
|
|
||||||
### Reusing a middleware instance across routes
|
|
||||||
|
|
||||||
To share a single bucket pool across multiple routes (treating them as one combined limit):
|
|
||||||
|
|
||||||
```java
|
|
||||||
Middleware sharedIpLimit = guard.limit("ip", 1000, 1, TimeUnit.MINUTES);
|
|
||||||
|
|
||||||
app.get("/api/items", handler1).with(sharedIpLimit);
|
|
||||||
app.get("/api/items/{id}", handler2).with(sharedIpLimit);
|
|
||||||
app.post("/api/items", handler3).with(sharedIpLimit);
|
|
||||||
```
|
|
||||||
|
|
||||||
All three routes now draw from the same per-IP bucket — 1000 combined requests per minute.
|
|
||||||
|
|
||||||
### Inside an extension
|
|
||||||
|
|
||||||
```java
|
|
||||||
public class MyApiExtension implements FlashExtension {
|
|
||||||
public void install(FlashRegistrar app, FlashContext ctx) {
|
|
||||||
Guard guard = ctx.require(Guard.class); // LimiterExtension must be installed first
|
|
||||||
|
|
||||||
Middleware ipLimit = guard.limit("ip", 60, 1, TimeUnit.SECONDS);
|
|
||||||
|
|
||||||
app.get("/api/status", statusHandler) .with(ipLimit);
|
|
||||||
app.get("/api/metrics", metricsHandler).with(ipLimit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Large window
|
|
||||||
|
|
||||||
```java
|
|
||||||
app.get("/api/export", exportHandler)
|
|
||||||
.with(guard.limit("api_key", 50, 24, TimeUnit.HOURS));
|
|
||||||
```
|
|
||||||
|
|
||||||
## Fail-fast
|
|
||||||
|
|
||||||
If the resolver name is not registered, `guard.limit(...)` throws immediately
|
|
||||||
(at wiring time, not at request time):
|
|
||||||
|
|
||||||
```
|
|
||||||
InitializationException: Rate-limit resolver "auth_user" is not registered.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Comparison: Guard vs @Limit
|
|
||||||
|
|
||||||
| | `@Limit` | `Guard.limit(...)` |
|
|
||||||
|---|---|---|
|
|
||||||
| Route style | Class-based `RequestHandler` | Lambda `(req, res) -> ...` |
|
|
||||||
| Configuration | Annotation fields | Method arguments |
|
|
||||||
| Where resolved | `AnnotationProcessor` at `scan()` | `guard.limit(...)` call at wiring |
|
|
||||||
| Hot-path overhead | Zero | Zero |
|
|
||||||
| Fail-fast | Yes | Yes |
|
|
||||||
| Composable with `Middleware.of()` | Via annotation processor order | Yes, directly |
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
# HTTP headers and 429 response
|
|
||||||
|
|
||||||
The extension injects standard rate-limit headers on **every** request — both allowed
|
|
||||||
and rejected. Clients can use these headers to implement back-off logic without waiting
|
|
||||||
for a 429.
|
|
||||||
|
|
||||||
## Response headers
|
|
||||||
|
|
||||||
| Header | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `X-RateLimit-Limit` | integer | Maximum requests allowed in the current window |
|
|
||||||
| `X-RateLimit-Remaining` | integer | Requests remaining in the current window (≥ 0) |
|
|
||||||
| `X-RateLimit-Reset` | Unix timestamp (s) | When the quota resets or the next token arrives |
|
|
||||||
| `Retry-After` | seconds | **Only on 429** — how long to wait before retrying (≥ 1) |
|
|
||||||
|
|
||||||
### Example — allowed request
|
|
||||||
|
|
||||||
```
|
|
||||||
HTTP/1.1 200 OK
|
|
||||||
X-RateLimit-Limit: 100
|
|
||||||
X-RateLimit-Remaining: 73
|
|
||||||
X-RateLimit-Reset: 1711750860
|
|
||||||
Content-Type: application/json
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example — rejected request (429)
|
|
||||||
|
|
||||||
```
|
|
||||||
HTTP/1.1 429 Too Many Requests
|
|
||||||
X-RateLimit-Limit: 100
|
|
||||||
X-RateLimit-Remaining: 0
|
|
||||||
X-RateLimit-Reset: 1711750860
|
|
||||||
Retry-After: 1
|
|
||||||
Content-Type: text/plain
|
|
||||||
|
|
||||||
Too Many Requests
|
|
||||||
```
|
|
||||||
|
|
||||||
## Header semantics by strategy
|
|
||||||
|
|
||||||
### FIXED_WINDOW
|
|
||||||
|
|
||||||
| Header | Value |
|
|
||||||
|---|---|
|
|
||||||
| `X-RateLimit-Reset` | Unix timestamp of the **next window start** (aligned to clock) |
|
|
||||||
| `Retry-After` | Seconds until `X-RateLimit-Reset` (minimum 1) |
|
|
||||||
|
|
||||||
At a 1-second window boundary `Retry-After` will typically be `1`.
|
|
||||||
|
|
||||||
### TOKEN_BUCKET
|
|
||||||
|
|
||||||
| Header | Value |
|
|
||||||
|---|---|
|
|
||||||
| `X-RateLimit-Remaining` | Current token count (may increase between requests due to refill) |
|
|
||||||
| `X-RateLimit-Reset` | Estimated Unix timestamp when the **next token arrives** |
|
|
||||||
| `Retry-After` | Milliseconds-precise estimate converted to seconds (minimum 1) |
|
|
||||||
|
|
||||||
Because the token bucket refills continuously, `X-RateLimit-Reset` is a near-future
|
|
||||||
timestamp rather than an aligned window boundary.
|
|
||||||
|
|
||||||
## Retry-After precision
|
|
||||||
|
|
||||||
`Retry-After` is computed as:
|
|
||||||
|
|
||||||
```
|
|
||||||
retryAfter = max(1, X-RateLimit-Reset - currentTimeSeconds)
|
|
||||||
```
|
|
||||||
|
|
||||||
The minimum value is always `1` second — RFC 7231 discourages `Retry-After: 0` as it
|
|
||||||
encourages instant retry loops.
|
|
||||||
|
|
||||||
## Client-side back-off example (Java)
|
|
||||||
|
|
||||||
```java
|
|
||||||
HttpResponse<String> res = client.send(request, BodyHandlers.ofString());
|
|
||||||
|
|
||||||
if (res.statusCode() == 429) {
|
|
||||||
String retryAfter = res.headers().firstValue("Retry-After").orElse("1");
|
|
||||||
long waitMs = Long.parseLong(retryAfter) * 1000L;
|
|
||||||
Thread.sleep(waitMs);
|
|
||||||
// retry...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Client-side back-off example (JavaScript fetch)
|
|
||||||
|
|
||||||
```js
|
|
||||||
const res = await fetch('/api/search?q=flash');
|
|
||||||
|
|
||||||
if (res.status === 429) {
|
|
||||||
const retryAfter = parseInt(res.headers.get('Retry-After') ?? '1', 10);
|
|
||||||
await new Promise(r => setTimeout(r, retryAfter * 1000));
|
|
||||||
// retry...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Monitoring / alerting
|
|
||||||
|
|
||||||
`X-RateLimit-Remaining` can be scraped by a metrics agent to track approaching limits
|
|
||||||
before they hit 429:
|
|
||||||
|
|
||||||
- `remaining / limit < 0.1` → warning (less than 10% quota left)
|
|
||||||
- `status == 429` → rate-limit violation counter increment
|
|
||||||
|
|
||||||
If `flash-ext-limiter` is used together with a future metrics extension, the 429 rate
|
|
||||||
per resolver key is a natural signal for abuse detection or auto-scaling.
|
|
||||||
|
|
||||||
## Header injection timing
|
|
||||||
|
|
||||||
Headers are injected **before** calling `next.handle(req, res)` on allowed requests,
|
|
||||||
and **instead of** calling it on rejected requests. This means:
|
|
||||||
|
|
||||||
- Handlers cannot accidentally overwrite `X-RateLimit-*` headers (they are set first,
|
|
||||||
but handlers that call `res.header(...)` with the same name will add a second value —
|
|
||||||
avoid this by not setting these headers manually).
|
|
||||||
- On 429, the handler body is never executed — no side effects occur.
|
|
||||||
|
|
||||||
## Integration with Swagger UI (flash-ext-openapi)
|
|
||||||
|
|
||||||
When `flash-ext-openapi` is installed, handlers annotated with `@Limit` automatically
|
|
||||||
contribute rate-limit response headers to generated OpenAPI responses:
|
|
||||||
|
|
||||||
- `X-RateLimit-Limit`
|
|
||||||
- `X-RateLimit-Remaining`
|
|
||||||
- `X-RateLimit-Reset`
|
|
||||||
- `Retry-After` on `429`
|
|
||||||
|
|
||||||
If `429` is not manually declared, OpenAPI auto-adds `429 Too Many Requests`.
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
# Key Resolvers
|
|
||||||
|
|
||||||
A **key resolver** is a lambda `Request → String` that extracts the partition key used
|
|
||||||
to identify who a rate limit applies to. Each unique key value gets its own independent
|
|
||||||
bucket — so `"ip"` limits per client address, `"auth_user"` limits per logged-in user, etc.
|
|
||||||
|
|
||||||
## Built-in resolver: `"ip"`
|
|
||||||
|
|
||||||
Always present. Cannot be removed; can be overridden with `registerResolver("ip", ...)`.
|
|
||||||
|
|
||||||
Resolution order:
|
|
||||||
1. `X-Forwarded-For` header — first address in the comma-separated list (client behind proxy)
|
|
||||||
2. `X-Real-IP` header — single forwarded IP (nginx `proxy_set_header X-Real-IP`)
|
|
||||||
3. `req.remoteAddress().getAddress().getHostAddress()` — direct socket address, zero allocation
|
|
||||||
(the `InetSocketAddress` already exists from `ServerSocket.accept()`; only `getHostAddress()`
|
|
||||||
allocates a String, and only when the first two headers are absent)
|
|
||||||
4. `"unknown"` — only if `remoteAddress()` is null (test-constructed requests)
|
|
||||||
|
|
||||||
```java
|
|
||||||
// Override the built-in "ip" resolver to trust only the last hop in X-Forwarded-For
|
|
||||||
conf.registerResolver("ip", req -> {
|
|
||||||
String xff = req.header("X-Forwarded-For");
|
|
||||||
if (xff != null) {
|
|
||||||
String[] parts = xff.split(",");
|
|
||||||
return parts[parts.length - 1].strip(); // last = most recent proxy
|
|
||||||
}
|
|
||||||
return req.header("X-Real-IP") != null ? req.header("X-Real-IP").strip() : "unknown";
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## Registering custom resolvers
|
|
||||||
|
|
||||||
```java
|
|
||||||
LimiterConfig conf = new LimiterConfig();
|
|
||||||
```
|
|
||||||
|
|
||||||
### By authenticated user (OIDC / ClaimsHolder)
|
|
||||||
|
|
||||||
```java
|
|
||||||
conf.registerResolver("auth_user", req ->
|
|
||||||
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous");
|
|
||||||
```
|
|
||||||
|
|
||||||
Requests from unauthenticated users share the `"anonymous"` bucket. If you want
|
|
||||||
unauthenticated requests to be unlimited, pair this resolver with `@Limit` only on
|
|
||||||
handlers that are already protected by `@Authenticated`.
|
|
||||||
|
|
||||||
### By API key header
|
|
||||||
|
|
||||||
```java
|
|
||||||
conf.registerResolver("api_key", req -> {
|
|
||||||
String key = req.header("X-Api-Key");
|
|
||||||
return key != null ? key : "none";
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### By tenant (multi-tenant SaaS)
|
|
||||||
|
|
||||||
```java
|
|
||||||
conf.registerResolver("tenant", req -> {
|
|
||||||
// Extract from subdomain: acme.api.example.com → "acme"
|
|
||||||
String host = req.header("Host");
|
|
||||||
if (host == null) return "unknown";
|
|
||||||
int dot = host.indexOf('.');
|
|
||||||
return dot > 0 ? host.substring(0, dot) : host;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### By IP + path (per-endpoint per-IP)
|
|
||||||
|
|
||||||
Combines two dimensions into a single key string:
|
|
||||||
|
|
||||||
```java
|
|
||||||
conf.registerResolver("ip_path", req -> {
|
|
||||||
String ip = req.header("X-Forwarded-For");
|
|
||||||
if (ip == null) ip = "unknown";
|
|
||||||
int comma = ip.indexOf(',');
|
|
||||||
if (comma > 0) ip = ip.substring(0, comma).strip();
|
|
||||||
return ip + "|" + req.path();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Composite: role-based bucket size
|
|
||||||
|
|
||||||
One resolver, two different `@Limit` thresholds on two handler classes. The resolver
|
|
||||||
returns the same key for the same user regardless of endpoint; the limit is set per handler.
|
|
||||||
|
|
||||||
```java
|
|
||||||
conf.registerResolver("auth_user", req ->
|
|
||||||
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anon");
|
|
||||||
```
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Limit(key = "auth_user", requests = 1000, window = 1) // privileged endpoint
|
|
||||||
public class AdminReportHandler extends RequestHandler { ... }
|
|
||||||
|
|
||||||
@Limit(key = "auth_user", requests = 20, window = 1) // public endpoint
|
|
||||||
public class PublicSearchHandler extends RequestHandler { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
The two handlers maintain **independent buckets** for the same user — each `@Limit`
|
|
||||||
annotation gets its own `BucketStore`.
|
|
||||||
|
|
||||||
## Resolver contract
|
|
||||||
|
|
||||||
```java
|
|
||||||
@FunctionalInterface
|
|
||||||
public interface KeyResolver {
|
|
||||||
String resolve(Request req); // must never return null; return "unknown" as fallback
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- Must not return `null` — a null key will throw `NullPointerException` inside `ConcurrentHashMap`.
|
|
||||||
- Must be **thread-safe** — called concurrently from virtual threads.
|
|
||||||
- Should be **fast** — it runs on every request for every rate-limited route.
|
|
||||||
- No state should be mutated — treat `Request` as read-only.
|
|
||||||
|
|
||||||
## Fail-fast validation
|
|
||||||
|
|
||||||
If a `@Limit` annotation or `guard.limit(...)` call references a resolver name that was never
|
|
||||||
registered, the server **refuses to start** with `InitializationException`:
|
|
||||||
|
|
||||||
```
|
|
||||||
InitializationException: Rate-limit resolver "auth_user" is not registered.
|
|
||||||
Call LimiterConfig.registerResolver("auth_user", req -> ...) before install.
|
|
||||||
```
|
|
||||||
|
|
||||||
This check happens at boot time (annotation processor / Guard wiring), not at request time.
|
|
||||||
|
|
||||||
## Registration API
|
|
||||||
|
|
||||||
```java
|
|
||||||
LimiterConfig conf = new LimiterConfig()
|
|
||||||
.registerResolver("auth_user", req -> ...)
|
|
||||||
.registerResolver("tenant", req -> ...)
|
|
||||||
.registerResolver("api_key", req -> ...);
|
|
||||||
|
|
||||||
app.install(new LimiterExtension(conf));
|
|
||||||
```
|
|
||||||
|
|
||||||
`registerResolver` returns `this` for fluent chaining. Calling it with an existing name
|
|
||||||
**replaces** the previous resolver — this is how you override the built-in `"ip"` resolver.
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
# Rate-limit strategies
|
|
||||||
|
|
||||||
Two algorithms are built in. Both are lock-free (CAS-only), operate on pre-allocated
|
|
||||||
`Bucket` state, and write results into a caller-supplied `long[2]` — zero per-request allocation.
|
|
||||||
|
|
||||||
## FIXED_WINDOW
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Limit(strategy = LimitStrategy.FIXED_WINDOW, ...) // default, can be omitted
|
|
||||||
guard.limit("ip", 100, 1, TimeUnit.SECONDS) // default
|
|
||||||
```
|
|
||||||
|
|
||||||
### How it works
|
|
||||||
|
|
||||||
The request counter resets to zero at each clock-aligned window boundary.
|
|
||||||
|
|
||||||
```
|
|
||||||
window 1 window 2 window 3
|
|
||||||
|────────────────|────────────────|────────────────|
|
|
||||||
cnt: 0 1 2 … N cnt: 0 1 2 … N cnt: 0 1 2 … N
|
|
||||||
```
|
|
||||||
|
|
||||||
With `requests = 100, window = 1s`:
|
|
||||||
- Requests 1–100 in a given second → allowed
|
|
||||||
- Request 101+ in that second → 429, allowed again at second +1
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
|
|
||||||
All state is packed into a single `AtomicLong` (`Bucket.slot0`):
|
|
||||||
|
|
||||||
```
|
|
||||||
high 32 bits = reduced epoch = (currentTimeMs / windowMs) & 0xFFFFFFFF
|
|
||||||
low 32 bits = request count in the current window
|
|
||||||
```
|
|
||||||
|
|
||||||
One CAS operation per request. At a window boundary the same CAS atomically resets the
|
|
||||||
counter to 1. No locks, no additional fields.
|
|
||||||
|
|
||||||
### Burst behaviour
|
|
||||||
|
|
||||||
Because the window is fixed to the clock, a burst can occur at the boundary:
|
|
||||||
up to `N` requests at the end of window 1 followed immediately by `N` requests at the
|
|
||||||
start of window 2 → `2N` requests in a short interval.
|
|
||||||
|
|
||||||
```
|
|
||||||
window 1 │ window 2
|
|
||||||
────────────┼────────────
|
|
||||||
99 100 101 │ 1 2 3 4
|
|
||||||
↑ reset: 101 → 429, then 1 is allowed
|
|
||||||
```
|
|
||||||
|
|
||||||
If burst tolerance is unacceptable, use `TOKEN_BUCKET`.
|
|
||||||
|
|
||||||
### When to use
|
|
||||||
|
|
||||||
- Simple API rate limiting where occasional boundary bursts are acceptable.
|
|
||||||
- Scenarios where a hard "N requests per clock second/minute" guarantee matters.
|
|
||||||
- When you want minimal per-bucket memory (one `AtomicLong`, `Bucket.slot1` unused).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TOKEN_BUCKET
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Limit(strategy = LimitStrategy.TOKEN_BUCKET, ...)
|
|
||||||
guard.limit("ip", 100, 1, TimeUnit.SECONDS, LimitStrategy.TOKEN_BUCKET)
|
|
||||||
```
|
|
||||||
|
|
||||||
### How it works
|
|
||||||
|
|
||||||
The bucket holds up to `requests` tokens and refills at a continuous rate of
|
|
||||||
`requests / window` tokens per millisecond. Each request consumes one token.
|
|
||||||
A client that was idle accumulates tokens and can fire a burst, but sustained
|
|
||||||
excess traffic drains the bucket and triggers 429s.
|
|
||||||
|
|
||||||
```
|
|
||||||
tokens
|
|
||||||
N ─┐ ┌──── refill slope ────┐
|
|
||||||
│ │ │
|
|
||||||
0 └───────────┘ ←─ burst consumed ──→│
|
|
||||||
burst here 429s during drain recovery
|
|
||||||
```
|
|
||||||
|
|
||||||
### Refill rate
|
|
||||||
|
|
||||||
`refillPerMs = (requests × 1000) / windowMs` (integer, minimum 1)
|
|
||||||
|
|
||||||
For `requests = 100, window = 1s`:
|
|
||||||
- Refill rate: 100 tokens/s = 1 token/10 ms
|
|
||||||
- Max capacity: 100 tokens
|
|
||||||
- A client idle for 500 ms accumulates 50 tokens and can fire 50 requests instantly.
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
|
|
||||||
- `Bucket.slot0` — current tokens × 1000 (fixed-point, avoids floating-point math)
|
|
||||||
- `Bucket.slot1` — last-refill timestamp in ms (0 = uninitialised → bucket starts full)
|
|
||||||
|
|
||||||
One CAS loop on `slot0` per request; `slot1` updated best-effort after CAS success.
|
|
||||||
The bounded inaccuracy from the non-atomic dual update is at most a few nanoseconds —
|
|
||||||
negligible and self-correcting for rate limiting.
|
|
||||||
|
|
||||||
### Bucket starts full
|
|
||||||
|
|
||||||
On the very first request, `slot1 == 0`. The strategy treats this as "one full window
|
|
||||||
elapsed" → `currentTokens = max`. The bucket starts at capacity; no warm-up needed.
|
|
||||||
|
|
||||||
### When to use
|
|
||||||
|
|
||||||
- APIs where clients legitimately batch requests (analytics, bulk imports).
|
|
||||||
- Endpoints where smooth throughput matters more than hard per-second guarantees.
|
|
||||||
- Any scenario where `FIXED_WINDOW` boundary bursts would be problematic.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Comparison
|
|
||||||
|
|
||||||
| | `FIXED_WINDOW` | `TOKEN_BUCKET` |
|
|
||||||
|---|---|---|
|
|
||||||
| Algorithm | Aligned counter reset | Continuous token refill |
|
|
||||||
| Burst handling | Allows 2× limit at boundaries | Absorbs bursts up to bucket capacity |
|
|
||||||
| Memory per bucket | 1 × `AtomicLong` used | 2 × `AtomicLong` used |
|
|
||||||
| Clock alignment | Yes (predictable resets) | No (smooth) |
|
|
||||||
| Typical use case | Simple request quotas | APIs with legitimate burst patterns |
|
|
||||||
| CAS operations per request | 1 (usually) | 1 (usually) |
|
|
||||||
|
|
||||||
Both strategies use the same `Bucket` type. Both are lock-free and allocation-free after
|
|
||||||
the bucket is first created.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Adding a custom strategy
|
|
||||||
|
|
||||||
Implement `RateLimitStrategy` and wrap it in a `LimitStrategy` enum constant:
|
|
||||||
|
|
||||||
```java
|
|
||||||
// 1. Implement the strategy
|
|
||||||
public final class SlidingWindowStrategy implements RateLimitStrategy {
|
|
||||||
@Override
|
|
||||||
public boolean check(Bucket bucket, LimitConfig cfg, long[] out) {
|
|
||||||
// ... lock-free implementation using bucket.slot0 / slot1
|
|
||||||
return allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Add to the enum
|
|
||||||
public enum LimitStrategy {
|
|
||||||
FIXED_WINDOW { ... },
|
|
||||||
TOKEN_BUCKET { ... },
|
|
||||||
SLIDING_WINDOW {
|
|
||||||
@Override
|
|
||||||
public RateLimitStrategy create() { return new SlidingWindowStrategy(); }
|
|
||||||
};
|
|
||||||
public abstract RateLimitStrategy create();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The new strategy is immediately available to `@Limit(strategy = LimitStrategy.SLIDING_WINDOW)`
|
|
||||||
and `guard.limit("ip", 100, 1, SECONDS, LimitStrategy.SLIDING_WINDOW)`.
|
|
||||||
|
|
||||||
### Strategy contract
|
|
||||||
|
|
||||||
```java
|
|
||||||
public interface RateLimitStrategy {
|
|
||||||
/**
|
|
||||||
* @param bucket pre-allocated per-key state (never null)
|
|
||||||
* @param cfg immutable rule config (limit, windowMs)
|
|
||||||
* @param out out[0] = remaining, out[1] = reset epoch-seconds
|
|
||||||
* @return true = allowed, false = rejected (429)
|
|
||||||
*/
|
|
||||||
boolean check(Bucket bucket, LimitConfig cfg, long[] out);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Requirements for custom implementations:
|
|
||||||
- **Lock-free** — use `AtomicLong.compareAndSet`; no `synchronized` or `ReentrantLock`.
|
|
||||||
- **Stateless** — all mutable state must live in `Bucket.slot0` / `Bucket.slot1`.
|
|
||||||
- **No allocation** — `out[]` is the only output channel; do not create objects on the hot path.
|
|
||||||
- **Thread-safe** — called concurrently from many virtual threads.
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
|
||||||
<modelVersion>4.0.0</modelVersion>
|
|
||||||
|
|
||||||
<parent>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash-extensions</artifactId>
|
|
||||||
<version>2.0.0</version>
|
|
||||||
</parent>
|
|
||||||
|
|
||||||
<artifactId>flash-ext-limiter</artifactId>
|
|
||||||
|
|
||||||
<dependencies>
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash-ext-openapi</artifactId>
|
|
||||||
<optional>true</optional>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.projectlombok</groupId>
|
|
||||||
<artifactId>lombok</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.junit.jupiter</groupId>
|
|
||||||
<artifactId>junit-jupiter</artifactId>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
|
|
||||||
</project>
|
|
||||||
-24
@@ -1,24 +0,0 @@
|
|||||||
package dev.relism.flash.ext.limiter;
|
|
||||||
|
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pre-allocated per-key rate limit state. Holds two {@link AtomicLong} slots whose
|
|
||||||
* semantics are strategy-specific:
|
|
||||||
*
|
|
||||||
* <ul>
|
|
||||||
* <li><b>FIXED_WINDOW</b>: {@code slot0} = packed {@code (epoch << 32 | count)},
|
|
||||||
* {@code slot1} unused.</li>
|
|
||||||
* <li><b>SLIDING_WINDOW</b>: {@code slot0} = packed {@code (epoch << 32 | count)},
|
|
||||||
* {@code slot1} = request count from the immediately preceding epoch.</li>
|
|
||||||
* <li><b>TOKEN_BUCKET</b>: {@code slot0} = tokens × 1000 (scaled),
|
|
||||||
* {@code slot1} = last-refill timestamp (ms since epoch).</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <p>Buckets are created once per unique key (via {@link BucketStore}) and reused
|
|
||||||
* for the lifetime of the server — zero allocation on the warm path.
|
|
||||||
*/
|
|
||||||
public final class Bucket {
|
|
||||||
public final AtomicLong slot0 = new AtomicLong(0L);
|
|
||||||
public final AtomicLong slot1 = new AtomicLong(0L);
|
|
||||||
}
|
|
||||||
-28
@@ -1,28 +0,0 @@
|
|||||||
package dev.relism.flash.ext.limiter;
|
|
||||||
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thread-safe store of pre-allocated {@link Bucket} instances keyed by partition key.
|
|
||||||
*
|
|
||||||
* <p>On the warm path (key already seen), {@link #get} performs a single
|
|
||||||
* {@link ConcurrentHashMap} lookup — no allocation. On the cold path (new key),
|
|
||||||
* {@code computeIfAbsent} allocates exactly one {@link Bucket} and inserts it.
|
|
||||||
*
|
|
||||||
* <p>Buckets accumulate indefinitely; for workloads with unbounded unique keys
|
|
||||||
* (e.g. one-shot crawlers), consider periodic store replacement or a bounded
|
|
||||||
* LRU map implementation.
|
|
||||||
*/
|
|
||||||
public final class BucketStore {
|
|
||||||
|
|
||||||
private final ConcurrentHashMap<String, Bucket> map = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the bucket for {@code key}, creating one if absent.
|
|
||||||
* Two threads racing on the same new key are guaranteed to receive the same bucket instance.
|
|
||||||
*/
|
|
||||||
public Bucket get(String key) {
|
|
||||||
Bucket b = map.get(key);
|
|
||||||
return b != null ? b : map.computeIfAbsent(key, k -> new Bucket());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-69
@@ -1,69 +0,0 @@
|
|||||||
package dev.relism.flash.ext.limiter;
|
|
||||||
|
|
||||||
import dev.relism.flash.exceptions.InitializationException;
|
|
||||||
import dev.relism.flash.extension.FlashContext;
|
|
||||||
import dev.relism.flash.routing.Middleware;
|
|
||||||
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manual rate-limit guard for lambda routes.
|
|
||||||
*
|
|
||||||
* <p>Available via {@link FlashContext}:
|
|
||||||
* <pre>{@code
|
|
||||||
* Guard guard = ctx.require(Guard.class);
|
|
||||||
* }</pre>
|
|
||||||
*
|
|
||||||
* <p>{@link #limit} creates a {@link Middleware} that is composed once at route registration
|
|
||||||
* time — the resolver lambda is captured directly from the registry (no runtime map lookup):
|
|
||||||
* <pre>{@code
|
|
||||||
* // 50 req/s per IP — fixed window (default)
|
|
||||||
* app.get("/api/search", handler, guard.limit("ip", 50, 1, TimeUnit.SECONDS));
|
|
||||||
*
|
|
||||||
* // 10 req/min per authenticated user — token bucket
|
|
||||||
* app.post("/api/export", handler, guard.limit("auth_user", 10, 1, TimeUnit.MINUTES, LimitStrategy.TOKEN_BUCKET));
|
|
||||||
* }</pre>
|
|
||||||
*
|
|
||||||
* <p>The resolver name is looked up once here (at wiring time, not on each request).
|
|
||||||
* If the name is not registered, {@link InitializationException}
|
|
||||||
* is thrown immediately.
|
|
||||||
*/
|
|
||||||
public final class Guard {
|
|
||||||
|
|
||||||
private final LimiterConfig config;
|
|
||||||
private final BucketStore store;
|
|
||||||
|
|
||||||
Guard(LimiterConfig config, BucketStore store) {
|
|
||||||
this.config = config;
|
|
||||||
this.store = store;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a {@link Middleware} that enforces the given rate limit using
|
|
||||||
* {@link LimitStrategy#FIXED_WINDOW}.
|
|
||||||
*
|
|
||||||
* @param resolverKey name registered via {@link LimiterConfig#registerResolver}
|
|
||||||
* @param requests maximum requests allowed per window
|
|
||||||
* @param window window duration in {@code unit}
|
|
||||||
* @param unit time unit for {@code window}
|
|
||||||
*/
|
|
||||||
public Middleware limit(String resolverKey, int requests, long window, TimeUnit unit) {
|
|
||||||
return limit(resolverKey, requests, window, unit, LimitStrategy.FIXED_WINDOW);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a {@link Middleware} that enforces the given rate limit with the specified strategy.
|
|
||||||
*
|
|
||||||
* @param resolverKey name registered via {@link LimiterConfig#registerResolver}
|
|
||||||
* @param requests maximum requests allowed per window
|
|
||||||
* @param window window duration in {@code unit}
|
|
||||||
* @param unit time unit for {@code window}
|
|
||||||
* @param strategy rate-limit algorithm
|
|
||||||
*/
|
|
||||||
public Middleware limit(String resolverKey, int requests, long window, TimeUnit unit, LimitStrategy strategy) {
|
|
||||||
// Fail-fast: resolve the lambda at wiring time, not at request time.
|
|
||||||
KeyResolver resolver = config.requireResolver(resolverKey);
|
|
||||||
LimitConfig cfg = new LimitConfig(requests, unit.toMillis(window), strategy.create());
|
|
||||||
return LimiterExtension.buildMiddleware(resolver, cfg, store);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-20
@@ -1,20 +0,0 @@
|
|||||||
package dev.relism.flash.ext.limiter;
|
|
||||||
|
|
||||||
import dev.relism.flash.models.Request;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts a partition key from an incoming request.
|
|
||||||
*
|
|
||||||
* <p>The resolved key identifies who the rate limit applies to — an IP address,
|
|
||||||
* an authenticated user ID, an API key, etc. Implementations are captured once
|
|
||||||
* at route registration time and called directly (no registry lookup) on every request.
|
|
||||||
*
|
|
||||||
* <pre>{@code
|
|
||||||
* conf.registerResolver("ip", req -> req.header("X-Forwarded-For"));
|
|
||||||
* conf.registerResolver("auth_user", req -> ClaimsHolder.user().sub());
|
|
||||||
* }</pre>
|
|
||||||
*/
|
|
||||||
@FunctionalInterface
|
|
||||||
public interface KeyResolver {
|
|
||||||
String resolve(Request req);
|
|
||||||
}
|
|
||||||
-48
@@ -1,48 +0,0 @@
|
|||||||
package dev.relism.flash.ext.limiter;
|
|
||||||
|
|
||||||
import dev.relism.flash.exceptions.InitializationException;
|
|
||||||
import dev.relism.flash.models.RequestHandler;
|
|
||||||
|
|
||||||
import java.lang.annotation.ElementType;
|
|
||||||
import java.lang.annotation.Retention;
|
|
||||||
import java.lang.annotation.RetentionPolicy;
|
|
||||||
import java.lang.annotation.Target;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies a rate limit to a class-based {@link RequestHandler}.
|
|
||||||
*
|
|
||||||
* <p>The annotation is processed at boot time by the {@link LimiterExtension} annotation
|
|
||||||
* processor. If {@link #key()} names a resolver that was never registered,
|
|
||||||
* startup fails immediately with {@link InitializationException}.
|
|
||||||
*
|
|
||||||
* <pre>{@code
|
|
||||||
* // 100 req/s per client IP — fixed window
|
|
||||||
* @Limit(requests = 100, window = 1)
|
|
||||||
* public class SearchHandler extends RequestHandler { ... }
|
|
||||||
*
|
|
||||||
* // 20 req/min per authenticated user — token bucket
|
|
||||||
* @Limit(key = "auth_user", requests = 20, window = 1, windowUnit = TimeUnit.MINUTES,
|
|
||||||
* strategy = LimitStrategy.TOKEN_BUCKET)
|
|
||||||
* public class ExpensiveHandler extends RequestHandler { ... }
|
|
||||||
* }</pre>
|
|
||||||
*/
|
|
||||||
@Target(ElementType.TYPE)
|
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
|
||||||
public @interface Limit {
|
|
||||||
|
|
||||||
/** Name of the resolver registered via {@link LimiterConfig#registerResolver}. Default: {@code "ip"}. */
|
|
||||||
String key() default "ip";
|
|
||||||
|
|
||||||
/** Maximum number of requests allowed per {@link #window()}. */
|
|
||||||
int requests();
|
|
||||||
|
|
||||||
/** Window duration in {@link #windowUnit()} units. */
|
|
||||||
long window();
|
|
||||||
|
|
||||||
/** Unit for {@link #window()}. Default: {@link TimeUnit#SECONDS}. */
|
|
||||||
TimeUnit windowUnit() default TimeUnit.SECONDS;
|
|
||||||
|
|
||||||
/** Rate-limit algorithm. Default: {@link LimitStrategy#FIXED_WINDOW}. */
|
|
||||||
LimitStrategy strategy() default LimitStrategy.FIXED_WINDOW;
|
|
||||||
}
|
|
||||||
-11
@@ -1,11 +0,0 @@
|
|||||||
package dev.relism.flash.ext.limiter;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Immutable configuration snapshot for a single rate-limit rule.
|
|
||||||
* Created once at boot time and captured directly in the middleware closure.
|
|
||||||
*
|
|
||||||
* @param limit Maximum allowed requests per window.
|
|
||||||
* @param windowMs Window duration in milliseconds.
|
|
||||||
* @param strategy Strategy instance bound to this rule (one per rule, not shared).
|
|
||||||
*/
|
|
||||||
public record LimitConfig(int limit, long windowMs, RateLimitStrategy strategy) {}
|
|
||||||
-58
@@ -1,58 +0,0 @@
|
|||||||
package dev.relism.flash.ext.limiter;
|
|
||||||
|
|
||||||
import dev.relism.flash.ext.limiter.strategy.FixedWindowStrategy;
|
|
||||||
import dev.relism.flash.ext.limiter.strategy.SlidingWindowStrategy;
|
|
||||||
import dev.relism.flash.ext.limiter.strategy.TokenBucketStrategy;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enumeration of built-in rate-limit algorithms. Each constant is a factory
|
|
||||||
* for its corresponding {@link RateLimitStrategy} implementation.
|
|
||||||
*
|
|
||||||
* <p>New algorithms can be added here without touching the rest of the extension.
|
|
||||||
* The enum value is referenced by {@link Limit#strategy()} so user code refers
|
|
||||||
* to the algorithm by name ({@code LimitStrategy.FIXED_WINDOW}) rather than
|
|
||||||
* instantiating strategy objects directly.
|
|
||||||
*
|
|
||||||
* <pre>{@code
|
|
||||||
* @Limit(key = "ip", requests = 100, window = 1, strategy = LimitStrategy.TOKEN_BUCKET)
|
|
||||||
* public class SearchHandler extends RequestHandler { ... }
|
|
||||||
* }</pre>
|
|
||||||
*/
|
|
||||||
public enum LimitStrategy {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fixed-window counter: resets to zero at each clock-aligned window boundary.
|
|
||||||
* Simple, minimal memory, but allows up to 2× the limit in bursts that straddle
|
|
||||||
* two windows.
|
|
||||||
*/
|
|
||||||
FIXED_WINDOW {
|
|
||||||
@Override
|
|
||||||
public RateLimitStrategy create() { return new FixedWindowStrategy(); }
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Token-bucket: tokens refill continuously. Smooth burst absorption — a client
|
|
||||||
* that was idle accumulates tokens and can fire a short burst, but sustained
|
|
||||||
* excess traffic is rejected. Preferred for API endpoints where occasional bursts
|
|
||||||
* are legitimate.
|
|
||||||
*/
|
|
||||||
TOKEN_BUCKET {
|
|
||||||
@Override
|
|
||||||
public RateLimitStrategy create() { return new TokenBucketStrategy(); }
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sliding-window counter: interpolates between the previous window's count and the
|
|
||||||
* current window's count weighted by how far into the current window we are.
|
|
||||||
* Eliminates the boundary burst of {@link #FIXED_WINDOW} while remaining O(1)
|
|
||||||
* memory and lock-free. Slight approximation — worst-case error ≈ a few percent at
|
|
||||||
* window boundaries.
|
|
||||||
*/
|
|
||||||
SLIDING_WINDOW {
|
|
||||||
@Override
|
|
||||||
public RateLimitStrategy create() { return new SlidingWindowStrategy(); }
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Creates a fresh, stateless {@link RateLimitStrategy} instance for this algorithm. */
|
|
||||||
public abstract RateLimitStrategy create();
|
|
||||||
}
|
|
||||||
-82
@@ -1,82 +0,0 @@
|
|||||||
package dev.relism.flash.ext.limiter;
|
|
||||||
|
|
||||||
import dev.relism.flash.exceptions.InitializationException;
|
|
||||||
|
|
||||||
import java.net.InetSocketAddress;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extension configuration: holds the named {@link KeyResolver} registry.
|
|
||||||
*
|
|
||||||
* <p>Resolvers are registered during the <em>config phase</em> (before {@code install}).
|
|
||||||
* After {@link LimiterExtension#install} is called, the registry is consulted once per
|
|
||||||
* route/handler at boot to capture the resolver lambda directly into the middleware closure.
|
|
||||||
* There is no map lookup on the request hot-path.
|
|
||||||
*
|
|
||||||
* <p>The built-in {@code "ip"} resolver is always present and extracts the client IP from
|
|
||||||
* {@code X-Forwarded-For} (first address) or {@code X-Real-IP}. Override it with
|
|
||||||
* {@code registerResolver("ip", ...)} if needed.
|
|
||||||
*
|
|
||||||
* <pre>{@code
|
|
||||||
* LimiterConfig conf = new LimiterConfig()
|
|
||||||
* .registerResolver("auth_user", req -> {
|
|
||||||
* // custom logic — e.g. extract sub from ClaimsHolder
|
|
||||||
* return ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous";
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* app.install(new LimiterExtension(conf));
|
|
||||||
* }</pre>
|
|
||||||
*/
|
|
||||||
public final class LimiterConfig {
|
|
||||||
|
|
||||||
private final Map<String, KeyResolver> resolvers = new LinkedHashMap<>();
|
|
||||||
|
|
||||||
public LimiterConfig() {
|
|
||||||
// Built-in mandatory "ip" resolver.
|
|
||||||
// Resolution order (standard reverse-proxy chain):
|
|
||||||
// 1. X-Forwarded-For — first address (client behind one or more proxies)
|
|
||||||
// 2. X-Real-IP — single forwarded IP (nginx proxy_set_header X-Real-IP)
|
|
||||||
// 3. Socket address — direct connection, no proxy headers (zero alloc: the
|
|
||||||
// InetSocketAddress already exists from accept(); only
|
|
||||||
// getHostAddress() allocates a String, and only when reached)
|
|
||||||
resolvers.put("ip", req -> {
|
|
||||||
String xff = req.header("X-Forwarded-For");
|
|
||||||
if (xff != null) {
|
|
||||||
int comma = xff.indexOf(',');
|
|
||||||
return comma > 0 ? xff.substring(0, comma).strip() : xff.strip();
|
|
||||||
}
|
|
||||||
String xri = req.header("X-Real-IP");
|
|
||||||
if (xri != null) return xri.strip();
|
|
||||||
InetSocketAddress addr = req.remoteAddress();
|
|
||||||
return addr != null ? addr.getAddress().getHostAddress() : "unknown";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers (or replaces) a named key resolver. Returns {@code this} for fluent chaining.
|
|
||||||
*
|
|
||||||
* @param name identifier referenced by {@link Limit#key()} and {@link Guard#limit}
|
|
||||||
* @param resolver lambda that extracts the partition key from a request
|
|
||||||
*/
|
|
||||||
public LimiterConfig registerResolver(String name, KeyResolver resolver) {
|
|
||||||
if (name == null || name.isBlank()) throw new IllegalArgumentException("Resolver name must not be blank");
|
|
||||||
if (resolver == null) throw new IllegalArgumentException("Resolver must not be null");
|
|
||||||
resolvers.put(name, resolver);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the resolver for {@code name}.
|
|
||||||
*
|
|
||||||
* @throws InitializationException if no resolver with that name has been registered —
|
|
||||||
* checked at boot time so misconfigurations surface immediately.
|
|
||||||
*/
|
|
||||||
KeyResolver requireResolver(String name) {
|
|
||||||
KeyResolver r = resolvers.get(name);
|
|
||||||
if (r == null) throw new InitializationException(
|
|
||||||
"Rate-limit resolver \"" + name + "\" is not registered. " +
|
|
||||||
"Call LimiterConfig.registerResolver(\"" + name + "\", req -> ...) before install.");
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-198
@@ -1,198 +0,0 @@
|
|||||||
package dev.relism.flash.ext.limiter;
|
|
||||||
|
|
||||||
import dev.relism.flash.ext.openapi.OpenApiContributor;
|
|
||||||
import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
|
|
||||||
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
|
|
||||||
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
|
|
||||||
import dev.relism.flash.extension.AnnotationProcessor;
|
|
||||||
import dev.relism.flash.extension.ExtensionPhase;
|
|
||||||
import dev.relism.flash.extension.FlashContext;
|
|
||||||
import dev.relism.flash.extension.FlashExtension;
|
|
||||||
import dev.relism.flash.extension.FlashRegistrar;
|
|
||||||
import dev.relism.flash.http.HttpStatus;
|
|
||||||
import dev.relism.flash.routing.Middleware;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rate-limiting extension for Flash.
|
|
||||||
*
|
|
||||||
* <p>At {@link #provide}:
|
|
||||||
* <ol>
|
|
||||||
* <li>Creates a single {@link BucketStore} shared by all rules in this extension instance.</li>
|
|
||||||
* <li>Provides a {@link Guard} in the {@link FlashContext} for manual use on lambda routes.</li>
|
|
||||||
* <li>Registers an {@link AnnotationProcessor} for {@link Limit}:
|
|
||||||
* reads the annotation once per handler class at boot, resolves the key lambda
|
|
||||||
* fail-fast, then returns a pre-compiled middleware — zero map lookups at request time.</li>
|
|
||||||
* </ol>
|
|
||||||
*
|
|
||||||
* <h3>Annotation-based (class handlers)</h3>
|
|
||||||
* <pre>{@code
|
|
||||||
* @Limit(requests = 100, window = 1) // 100 req/s per IP
|
|
||||||
* public class SearchHandler extends RequestHandler { ... }
|
|
||||||
*
|
|
||||||
* @Limit(key = "auth_user", requests = 20, window = 1,
|
|
||||||
* windowUnit = TimeUnit.MINUTES,
|
|
||||||
* strategy = LimitStrategy.TOKEN_BUCKET)
|
|
||||||
* public class ReportHandler extends RequestHandler { ... }
|
|
||||||
* }</pre>
|
|
||||||
*
|
|
||||||
* <h3>Lambda routes (via Guard)</h3>
|
|
||||||
* <pre>{@code
|
|
||||||
* app.install(new LimiterExtension(
|
|
||||||
* new LimiterConfig().registerResolver("auth_user", req -> ClaimsHolder.user().sub())));
|
|
||||||
*
|
|
||||||
* // inside FlashExtension.routes() or after install():
|
|
||||||
* Guard guard = ctx.require(Guard.class);
|
|
||||||
* app.get("/api/search", handler, guard.limit("ip", 50, 1, TimeUnit.SECONDS));
|
|
||||||
* }</pre>
|
|
||||||
*/
|
|
||||||
public final class LimiterExtension implements FlashExtension {
|
|
||||||
|
|
||||||
private final LimiterConfig config;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rate limiting runs before authentication — cheaper check rejects over-limit
|
|
||||||
* requests before any token validation occurs.
|
|
||||||
*/
|
|
||||||
@Override public int priority() { return ExtensionPhase.EARLY.value; }
|
|
||||||
|
|
||||||
/** Installs with default config (only the built-in {@code "ip"} resolver). */
|
|
||||||
public LimiterExtension() {
|
|
||||||
this(new LimiterConfig());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Installs with a custom {@link LimiterConfig} (custom resolvers, etc.). */
|
|
||||||
public LimiterExtension(LimiterConfig config) {
|
|
||||||
this.config = config;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void provide(FlashContext ctx) {
|
|
||||||
BucketStore store = new BucketStore();
|
|
||||||
Guard guard = new Guard(config, store);
|
|
||||||
|
|
||||||
ctx.provide(Guard.class, guard);
|
|
||||||
ctx.provide(LimiterConfig.class, config);
|
|
||||||
|
|
||||||
// Annotation processor: runs once per class-based handler at boot.
|
|
||||||
ctx.addAnnotationProcessor(handlerClass -> {
|
|
||||||
Limit ann = handlerClass.getAnnotation(Limit.class);
|
|
||||||
if (ann == null) return List.of();
|
|
||||||
|
|
||||||
// Fail-fast: if the key is unknown the server refuses to start.
|
|
||||||
KeyResolver resolver = config.requireResolver(ann.key());
|
|
||||||
LimitConfig cfg = new LimitConfig(
|
|
||||||
ann.requests(),
|
|
||||||
ann.windowUnit().toMillis(ann.window()),
|
|
||||||
ann.strategy().create()
|
|
||||||
);
|
|
||||||
|
|
||||||
return List.of(buildMiddleware(resolver, cfg, store));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
|
|
||||||
try {
|
|
||||||
OpenApiIntegration.register(ctx);
|
|
||||||
} catch (NoClassDefFoundError ignored) {
|
|
||||||
// flash-ext-openapi not available — OpenAPI integration disabled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Package-private helper — shared with Guard ────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the rate-limit {@link Middleware} from an already-resolved resolver lambda.
|
|
||||||
*
|
|
||||||
* <p>Hot-path design:
|
|
||||||
* <ul>
|
|
||||||
* <li>{@code resolver} is captured directly in the closure — no registry lookup per request.</li>
|
|
||||||
* <li>{@code resultBuf} is a per-{@link Middleware}-instance ThreadLocal {@code long[2]}.
|
|
||||||
* Allocated once per thread, reused forever — zero per-request allocation.</li>
|
|
||||||
* <li>The static {@code X-RateLimit-Limit} header is pre-encoded at boot — zero-alloc.</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
static Middleware buildMiddleware(KeyResolver resolver, LimitConfig cfg, BucketStore store) {
|
|
||||||
ThreadLocal<long[]> resultBuf = ThreadLocal.withInitial(() -> new long[2]);
|
|
||||||
byte[] limitHeader = ("X-RateLimit-Limit: " + cfg.limit() + "\r\n")
|
|
||||||
.getBytes(StandardCharsets.UTF_8);
|
|
||||||
|
|
||||||
return next -> (req, res) -> {
|
|
||||||
String key = resolver.resolve(req);
|
|
||||||
Bucket bucket = store.get(key);
|
|
||||||
long[] out = resultBuf.get();
|
|
||||||
|
|
||||||
boolean allowed = cfg.strategy().check(bucket, cfg, out);
|
|
||||||
|
|
||||||
res.header(limitHeader);
|
|
||||||
res.header("X-RateLimit-Remaining", String.valueOf(out[0]));
|
|
||||||
res.header("X-RateLimit-Reset", String.valueOf(out[1]));
|
|
||||||
|
|
||||||
if (!allowed) {
|
|
||||||
long retryAfter = Math.max(1L, out[1] - System.currentTimeMillis() / 1000L);
|
|
||||||
res.status(HttpStatus.TOO_MANY_REQUESTS)
|
|
||||||
.header("Retry-After", String.valueOf(retryAfter));
|
|
||||||
return "Too Many Requests";
|
|
||||||
}
|
|
||||||
|
|
||||||
return next.handle(req, res);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class OpenApiIntegration {
|
|
||||||
private static final Map<String, Object> INTEGER_SCHEMA = Map.of("type", "integer");
|
|
||||||
private static final Map<String, Object> LIMIT_HEADER = Map.of(
|
|
||||||
"description", "Maximum requests allowed in current window",
|
|
||||||
"schema", INTEGER_SCHEMA
|
|
||||||
);
|
|
||||||
private static final Map<String, Object> REMAINING_HEADER = Map.of(
|
|
||||||
"description", "Requests remaining in current window",
|
|
||||||
"schema", INTEGER_SCHEMA
|
|
||||||
);
|
|
||||||
private static final Map<String, Object> RESET_HEADER = Map.of(
|
|
||||||
"description", "Unix epoch seconds when quota resets or next token arrives",
|
|
||||||
"schema", INTEGER_SCHEMA
|
|
||||||
);
|
|
||||||
private static final Map<String, Object> RETRY_AFTER_HEADER = Map.of(
|
|
||||||
"description", "Seconds to wait before retrying",
|
|
||||||
"schema", INTEGER_SCHEMA
|
|
||||||
);
|
|
||||||
|
|
||||||
static void register(FlashContext ctx) {
|
|
||||||
ctx.find(OpenApiContributorRegistry.class)
|
|
||||||
.ifPresent(registry -> registry.add(new OpenApiContributor() {
|
|
||||||
@Override
|
|
||||||
public OpenApiOperationContribution operationFor(Class<?> handlerClass) {
|
|
||||||
if (handlerClass.getAnnotation(Limit.class) == null) {
|
|
||||||
return OpenApiOperationContribution.builder().build();
|
|
||||||
}
|
|
||||||
|
|
||||||
OpenApiResponseContribution common =
|
|
||||||
OpenApiResponseContribution.builder()
|
|
||||||
.header("X-RateLimit-Limit", LIMIT_HEADER)
|
|
||||||
.header("X-RateLimit-Remaining", REMAINING_HEADER)
|
|
||||||
.header("X-RateLimit-Reset", RESET_HEADER)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
OpenApiResponseContribution tooManyRequests =
|
|
||||||
OpenApiResponseContribution.builder()
|
|
||||||
.description("Too Many Requests")
|
|
||||||
.header("X-RateLimit-Limit", LIMIT_HEADER)
|
|
||||||
.header("X-RateLimit-Remaining", REMAINING_HEADER)
|
|
||||||
.header("X-RateLimit-Reset", RESET_HEADER)
|
|
||||||
.header("Retry-After", RETRY_AFTER_HEADER)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
return OpenApiOperationContribution.builder()
|
|
||||||
.allResponses(common)
|
|
||||||
.response(429, tooManyRequests)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-39
@@ -1,39 +0,0 @@
|
|||||||
package dev.relism.flash.ext.limiter;
|
|
||||||
|
|
||||||
import dev.relism.flash.ext.limiter.strategy.FixedWindowStrategy;
|
|
||||||
import dev.relism.flash.ext.limiter.strategy.SlidingWindowStrategy;
|
|
||||||
import dev.relism.flash.ext.limiter.strategy.TokenBucketStrategy;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Contract for a rate-limit algorithm. Implementations must be:
|
|
||||||
* <ul>
|
|
||||||
* <li><b>Lock-free</b> — rely only on {@link java.util.concurrent.atomic.AtomicLong} CAS operations.</li>
|
|
||||||
* <li><b>Stateless</b> — all mutable state lives in the {@link Bucket}; the strategy itself
|
|
||||||
* holds no instance fields so the same object can be shared across threads and rules.</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <p>Called on every request — must not allocate on the hot path.
|
|
||||||
*
|
|
||||||
* @see FixedWindowStrategy
|
|
||||||
* @see SlidingWindowStrategy
|
|
||||||
* @see TokenBucketStrategy
|
|
||||||
*/
|
|
||||||
public interface RateLimitStrategy {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether this request is within the limit and updates the bucket atomically.
|
|
||||||
*
|
|
||||||
* <p>On return, {@code out} contains:
|
|
||||||
* <ul>
|
|
||||||
* <li>{@code out[0]} — remaining allowed requests in the current window (≥ 0).</li>
|
|
||||||
* <li>{@code out[1]} — Unix epoch seconds at which the quota resets (for {@code X-RateLimit-Reset}
|
|
||||||
* and {@code Retry-After} headers).</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* @param bucket per-key state carrier (pre-allocated, never null)
|
|
||||||
* @param cfg immutable rule configuration
|
|
||||||
* @param out caller-supplied two-element array; values are overwritten on every call
|
|
||||||
* @return {@code true} if the request is within the limit and should proceed
|
|
||||||
*/
|
|
||||||
boolean check(Bucket bucket, LimitConfig cfg, long[] out);
|
|
||||||
}
|
|
||||||
-54
@@ -1,54 +0,0 @@
|
|||||||
package dev.relism.flash.ext.limiter.strategy;
|
|
||||||
|
|
||||||
import dev.relism.flash.ext.limiter.Bucket;
|
|
||||||
import dev.relism.flash.ext.limiter.LimitConfig;
|
|
||||||
import dev.relism.flash.ext.limiter.RateLimitStrategy;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fixed-window rate limit: allows up to {@link LimitConfig#limit()} requests per window of
|
|
||||||
* {@link LimitConfig#windowMs()} milliseconds. The window is aligned to clock time
|
|
||||||
* (e.g. 10:00:00 – 10:00:59 for a 60-second window), not sliding.
|
|
||||||
*
|
|
||||||
* <h3>Implementation</h3>
|
|
||||||
* The entire state fits in a single {@link java.util.concurrent.atomic.AtomicLong}
|
|
||||||
* ({@link Bucket#slot0}), packed as:
|
|
||||||
* <pre>
|
|
||||||
* high 32 bits = reduced epoch (currentTimeMs / windowMs) & 0xFFFFFFFFL
|
|
||||||
* low 32 bits = request count in the current window
|
|
||||||
* </pre>
|
|
||||||
* Each request performs a single CAS loop — no locks, no allocations.
|
|
||||||
* At a window boundary the CAS atomically resets the counter to 1.
|
|
||||||
*
|
|
||||||
* <p>The reduced epoch wraps every {@code 2^32 × windowMs} milliseconds
|
|
||||||
* (~13,000 years for a 100 ms window) — collision-free in practice.
|
|
||||||
*/
|
|
||||||
public final class FixedWindowStrategy implements RateLimitStrategy {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean check(Bucket bucket, LimitConfig cfg, long[] out) {
|
|
||||||
long now = System.currentTimeMillis();
|
|
||||||
long absEpoch = now / cfg.windowMs();
|
|
||||||
int epoch = (int)(absEpoch & 0xFFFFFFFFL); // reduced epoch, collision-safe
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
long packed = bucket.slot0.get();
|
|
||||||
int storedEpoch = (int)(packed >>> 32);
|
|
||||||
int count = (int)(packed & 0xFFFFFFFFL);
|
|
||||||
|
|
||||||
// Same window: increment; new window: reset to 1.
|
|
||||||
// Cap at limit+1 to guard against int overflow on extreme traffic.
|
|
||||||
int newCount = (storedEpoch == epoch)
|
|
||||||
? Math.min(count + 1, cfg.limit() + 1)
|
|
||||||
: 1;
|
|
||||||
|
|
||||||
long newPacked = ((long) epoch << 32) | (newCount & 0xFFFFFFFFL);
|
|
||||||
|
|
||||||
if (bucket.slot0.compareAndSet(packed, newPacked)) {
|
|
||||||
out[0] = Math.max(0L, cfg.limit() - newCount);
|
|
||||||
out[1] = (absEpoch + 1) * cfg.windowMs() / 1000L;
|
|
||||||
return newCount <= cfg.limit();
|
|
||||||
}
|
|
||||||
// CAS lost — contention; re-read and retry.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-86
@@ -1,86 +0,0 @@
|
|||||||
package dev.relism.flash.ext.limiter.strategy;
|
|
||||||
|
|
||||||
import dev.relism.flash.ext.limiter.Bucket;
|
|
||||||
import dev.relism.flash.ext.limiter.LimitConfig;
|
|
||||||
import dev.relism.flash.ext.limiter.RateLimitStrategy;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sliding-window counter rate limit: approximates a true sliding window by interpolating
|
|
||||||
* between the previous fixed window's count and the current window's count.
|
|
||||||
*
|
|
||||||
* <pre>
|
|
||||||
* estimate = prevCount × (1 − elapsed / windowMs) + currentCount
|
|
||||||
* </pre>
|
|
||||||
*
|
|
||||||
* <p>This is the same approximation used by Redis. It eliminates the boundary burst
|
|
||||||
* problem of {@link FixedWindowStrategy} while staying O(1) memory and lock-free.
|
|
||||||
* The error is bounded: in the worst case the true rate at the boundary can exceed
|
|
||||||
* the limit by at most {@code limit × (1 − elapsed/windowMs)} — typically a few percent.
|
|
||||||
*
|
|
||||||
* <h3>Slot layout</h3>
|
|
||||||
* <ul>
|
|
||||||
* <li>{@link Bucket#slot0} — packed {@code (reducedEpoch << 32 | currentCount)}</li>
|
|
||||||
* <li>{@link Bucket#slot1} — count from the immediately preceding epoch (0 = none)</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <p>On a window transition the thread that wins the {@code slot0} CAS also writes
|
|
||||||
* {@code slot1}. A concurrent thread that reads {@code slot0} after the transition but
|
|
||||||
* before {@code slot1} is written sees a slightly stale previous count — acceptable for
|
|
||||||
* an approximation algorithm.
|
|
||||||
*/
|
|
||||||
public final class SlidingWindowStrategy implements RateLimitStrategy {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean check(Bucket bucket, LimitConfig cfg, long[] out) {
|
|
||||||
long now = System.currentTimeMillis();
|
|
||||||
long windowMs = cfg.windowMs();
|
|
||||||
long absEpoch = now / windowMs;
|
|
||||||
int epoch = (int)(absEpoch & 0xFFFFFFFFL);
|
|
||||||
long elapsed = now % windowMs; // ms elapsed inside the current window
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
long packed = bucket.slot0.get();
|
|
||||||
int storedEpoch = (int)(packed >>> 32);
|
|
||||||
int count = (int)(packed & 0xFFFFFFFFL);
|
|
||||||
|
|
||||||
if (storedEpoch == epoch) {
|
|
||||||
// ── Same window ──────────────────────────────────────────────────
|
|
||||||
long prevCount = bucket.slot1.get();
|
|
||||||
// Integer interpolation — no floating-point on hot path.
|
|
||||||
long estimate = (prevCount * (windowMs - elapsed)) / windowMs + count + 1;
|
|
||||||
|
|
||||||
if (estimate > cfg.limit()) {
|
|
||||||
out[0] = 0L;
|
|
||||||
out[1] = (absEpoch + 1) * windowMs / 1000L;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
int newCount = Math.min(count + 1, cfg.limit() + 1);
|
|
||||||
long newPacked = ((long) epoch << 32) | (newCount & 0xFFFFFFFFL);
|
|
||||||
if (!bucket.slot0.compareAndSet(packed, newPacked)) continue; // CAS lost, retry
|
|
||||||
|
|
||||||
out[0] = Math.max(0L, cfg.limit() - estimate);
|
|
||||||
out[1] = (absEpoch + 1) * windowMs / 1000L;
|
|
||||||
return true;
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// ── Window transition ────────────────────────────────────────────
|
|
||||||
// If the stored epoch is exactly the one before ours, carry its count forward.
|
|
||||||
// If it's older (gap ≥ 2 windows), the previous window is effectively empty.
|
|
||||||
int prevEpoch = (int)((absEpoch - 1) & 0xFFFFFFFFL);
|
|
||||||
long oldCount = (storedEpoch == prevEpoch) ? count : 0L;
|
|
||||||
|
|
||||||
long newPacked = ((long) epoch << 32) | 1L;
|
|
||||||
if (!bucket.slot0.compareAndSet(packed, newPacked)) continue; // CAS lost, retry
|
|
||||||
|
|
||||||
// Won the transition: publish old count so the same-window branch can read it.
|
|
||||||
bucket.slot1.set(oldCount);
|
|
||||||
|
|
||||||
long estimate = (oldCount * (windowMs - elapsed)) / windowMs + 1;
|
|
||||||
out[0] = Math.max(0L, cfg.limit() - estimate);
|
|
||||||
out[1] = (absEpoch + 1) * windowMs / 1000L;
|
|
||||||
return estimate <= cfg.limit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-68
@@ -1,68 +0,0 @@
|
|||||||
package dev.relism.flash.ext.limiter.strategy;
|
|
||||||
|
|
||||||
import dev.relism.flash.ext.limiter.Bucket;
|
|
||||||
import dev.relism.flash.ext.limiter.LimitConfig;
|
|
||||||
import dev.relism.flash.ext.limiter.RateLimitStrategy;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Token-bucket rate limit: tokens refill continuously at a rate of
|
|
||||||
* {@code limit / windowMs} tokens per millisecond, up to a maximum of {@code limit} tokens.
|
|
||||||
* Each request consumes one token. Burst traffic is absorbed until the bucket empties.
|
|
||||||
*
|
|
||||||
* <h3>Implementation</h3>
|
|
||||||
* <ul>
|
|
||||||
* <li>{@link Bucket#slot0} — current token count scaled by {@value #SCALE}
|
|
||||||
* (allows sub-token precision without floating-point). Starts at 0; treated as
|
|
||||||
* {@code maxScaled} when {@link Bucket#slot1} is 0 (first call → bucket starts full).</li>
|
|
||||||
* <li>{@link Bucket#slot1} — last-refill timestamp in ms. 0 = not yet initialised.</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <p>Each request CAS-loops on {@code slot0}; {@code slot1} is advanced monotonically via CAS
|
|
||||||
* after a successful token consumption — never regresses to an older timestamp under concurrent load.
|
|
||||||
*/
|
|
||||||
public final class TokenBucketStrategy implements RateLimitStrategy {
|
|
||||||
|
|
||||||
/** Fixed-point scale factor. Stored tokens = actual tokens × SCALE. */
|
|
||||||
static final long SCALE = 1_000L;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean check(Bucket bucket, LimitConfig cfg, long[] out) {
|
|
||||||
long now = System.currentTimeMillis();
|
|
||||||
long maxScaled = (long) cfg.limit() * SCALE;
|
|
||||||
// Refill rate: limit tokens per windowMs → (limit * SCALE) / windowMs scaled-tokens per ms.
|
|
||||||
// Minimum 1 to ensure progress even for very large windows.
|
|
||||||
long rfPerMs = Math.max(1L, maxScaled / cfg.windowMs());
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
long lastMs = bucket.slot1.get();
|
|
||||||
long rawTokens = bucket.slot0.get();
|
|
||||||
|
|
||||||
// When slot1 == 0 the bucket has never been used: treat as one full window elapsed
|
|
||||||
// so the bucket starts completely full.
|
|
||||||
long elapsed = (lastMs == 0L) ? cfg.windowMs() : Math.max(0L, now - lastMs);
|
|
||||||
long currentTokens = Math.min(maxScaled, rawTokens + elapsed * rfPerMs);
|
|
||||||
|
|
||||||
if (currentTokens < SCALE) {
|
|
||||||
// Not enough for one token — compute when the next token arrives.
|
|
||||||
long needed = SCALE - currentTokens;
|
|
||||||
long msToNext = (needed + rfPerMs - 1) / rfPerMs; // ceiling division
|
|
||||||
out[0] = 0L;
|
|
||||||
out[1] = (now + msToNext) / 1000L;
|
|
||||||
// Best-effort: advance the refill baseline so the next call gets a fresh elapsed.
|
|
||||||
bucket.slot0.compareAndSet(rawTokens, currentTokens);
|
|
||||||
bucket.slot1.compareAndSet(lastMs, now);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
long newTokens = currentTokens - SCALE;
|
|
||||||
if (bucket.slot0.compareAndSet(rawTokens, newTokens)) {
|
|
||||||
// Advance refill baseline: CAS ensures we never regress to an older timestamp.
|
|
||||||
if (lastMs < now) bucket.slot1.compareAndSet(lastMs, now);
|
|
||||||
out[0] = newTokens / SCALE;
|
|
||||||
out[1] = now / 1000L;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// CAS lost — another thread consumed a token concurrently; re-read and retry.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-84
@@ -1,84 +0,0 @@
|
|||||||
package dev.relism.flash.ext.limiter;
|
|
||||||
|
|
||||||
import dev.relism.flash.ext.openapi.OpenApiContributor;
|
|
||||||
import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
|
|
||||||
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
|
|
||||||
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
|
|
||||||
import dev.relism.flash.extension.FlashContext;
|
|
||||||
import dev.relism.flash.models.Request;
|
|
||||||
import dev.relism.flash.models.RequestHandler;
|
|
||||||
import dev.relism.flash.models.Response;
|
|
||||||
import dev.relism.flash.routing.GET;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
class LimiterOpenApiInteropTest {
|
|
||||||
|
|
||||||
@GET("/limited")
|
|
||||||
@Limit(requests = 10, window = 1)
|
|
||||||
static class LimitedHandler extends RequestHandler {
|
|
||||||
@Override
|
|
||||||
public Object handle(Request request, Response response) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@GET("/plain")
|
|
||||||
static class PlainHandler extends RequestHandler {
|
|
||||||
@Override
|
|
||||||
public Object handle(Request request, Response response) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void registersContributor_whenOpenApiRegistryExists() {
|
|
||||||
FlashContext ctx = new FlashContext();
|
|
||||||
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
|
||||||
ctx.provide(OpenApiContributorRegistry.class, registry);
|
|
||||||
|
|
||||||
new LimiterExtension().routes(null, ctx);
|
|
||||||
|
|
||||||
assertEquals(1, registry.contributors().size());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void limitedHandler_contributesHeadersAnd429() {
|
|
||||||
FlashContext ctx = new FlashContext();
|
|
||||||
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
|
||||||
ctx.provide(OpenApiContributorRegistry.class, registry);
|
|
||||||
new LimiterExtension().routes(null, ctx);
|
|
||||||
|
|
||||||
OpenApiContributor contributor = registry.contributors().getFirst();
|
|
||||||
OpenApiOperationContribution operation = contributor.operationFor(LimitedHandler.class);
|
|
||||||
|
|
||||||
OpenApiResponseContribution all = operation.allResponses();
|
|
||||||
assertNotNull(all);
|
|
||||||
assertTrue(all.headers().containsKey("X-RateLimit-Limit"));
|
|
||||||
assertTrue(all.headers().containsKey("X-RateLimit-Remaining"));
|
|
||||||
assertTrue(all.headers().containsKey("X-RateLimit-Reset"));
|
|
||||||
|
|
||||||
OpenApiResponseContribution tooMany = operation.responses().get(429);
|
|
||||||
assertNotNull(tooMany);
|
|
||||||
assertEquals("Too Many Requests", tooMany.description());
|
|
||||||
assertTrue(tooMany.headers().containsKey("Retry-After"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void plainHandler_hasNoContribution() {
|
|
||||||
FlashContext ctx = new FlashContext();
|
|
||||||
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
|
||||||
ctx.provide(OpenApiContributorRegistry.class, registry);
|
|
||||||
new LimiterExtension().routes(null, ctx);
|
|
||||||
|
|
||||||
OpenApiContributor contributor = registry.contributors().getFirst();
|
|
||||||
OpenApiOperationContribution operation = contributor.operationFor(PlainHandler.class);
|
|
||||||
|
|
||||||
assertTrue(operation.isEmpty());
|
|
||||||
assertFalse(operation.responses().containsKey(429));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,462 +0,0 @@
|
|||||||
# flash-ext-oidc
|
|
||||||
|
|
||||||
Full OIDC Authorization Code + PKCE flow for the Flash HTTP server.
|
|
||||||
Supports Keycloak, Authelia, Auth0, Google, and any RFC 8414-compliant provider.
|
|
||||||
|
|
||||||
Standards alignment focuses on OIDC Core + OAuth2 bearer APIs while preserving Flash's
|
|
||||||
hot-path model (middleware compiled at mount time, no heavy runtime work).
|
|
||||||
|
|
||||||
## What it provides
|
|
||||||
|
|
||||||
| Component | Description |
|
|
||||||
|---|---|
|
|
||||||
| `GET {prefix}/login` | Starts the OIDC flow: builds the authorization URL with PKCE + state, redirects |
|
|
||||||
| `GET {prefix}/callback` | Exchanges the code, validates the ID token, creates a session, redirects |
|
|
||||||
| `POST {prefix}/logout` | Invalidates the session, redirects to the provider's `end_session_endpoint` |
|
|
||||||
| `@Authenticated` | Annotation: protects a class-based handler (redirects browsers, 401 for API clients) |
|
|
||||||
| `@RolesAllowed(...)` | Annotation: protects with role check (OR semantics) |
|
|
||||||
| `@ScopesAllowed(...)` | Annotation: protects with scope check (`ALL` default, `ANY` optional) |
|
|
||||||
| `OidcMiddleware` | Programmatic middleware for lambda routes |
|
|
||||||
| `ClaimsHolder` / `OidcUser` | Thread-local user info accessible from any protected handler |
|
|
||||||
| `JwtValidator` | JWKS-backed JWT validator (PKCE + key rotation + caching) |
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash-ext-oidc</artifactId>
|
|
||||||
<version>1.0-SNAPSHOT</version>
|
|
||||||
</dependency>
|
|
||||||
```
|
|
||||||
|
|
||||||
Transitive: `nimbus-jose-jwt`, `json-smart`.
|
|
||||||
Optional: `flash-ext-openapi` — if present, OIDC security schemes are added to the OpenAPI spec automatically.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```java
|
|
||||||
FlashApp.create(8080)
|
|
||||||
.install(new JacksonExtension())
|
|
||||||
.install(new OpenApiExtension(...)) // optional — enables Swagger security
|
|
||||||
.install(new OidcExtension(
|
|
||||||
OidcConfig.builder(
|
|
||||||
"https://idp.example.com",
|
|
||||||
"my-client", "my-secret", "/auth/callback")
|
|
||||||
.build()
|
|
||||||
))
|
|
||||||
.start();
|
|
||||||
```
|
|
||||||
|
|
||||||
Install order is irrelevant. The two-phase extension model guarantees all services
|
|
||||||
(including `OpenApiSecurityRegistry` from `flash-ext-openapi`) are registered before
|
|
||||||
any extension's routes phase runs.
|
|
||||||
|
|
||||||
### Keycloak shortcut
|
|
||||||
|
|
||||||
```java
|
|
||||||
OidcConfig.keycloak(
|
|
||||||
"https://keycloak.example.com", // server URL (no realm)
|
|
||||||
"myrealm", // realm
|
|
||||||
"my-client", "my-secret", // client credentials
|
|
||||||
"/auth/callback") // redirect URI (server-relative)
|
|
||||||
.https() // behind TLS
|
|
||||||
.build()
|
|
||||||
```
|
|
||||||
|
|
||||||
`keycloak()` pre-sets `rolesClaimPath("realm_access.roles")` and constructs the issuer as
|
|
||||||
`{serverUrl}/realms/{realm}`.
|
|
||||||
|
|
||||||
### Authelia / generic IdP
|
|
||||||
|
|
||||||
```java
|
|
||||||
OidcConfig.builder("https://auth.example.com", "my-client", "secret", "/auth/callback")
|
|
||||||
.rolesClaimPath("groups")
|
|
||||||
.build()
|
|
||||||
```
|
|
||||||
|
|
||||||
## OidcConfig reference
|
|
||||||
|
|
||||||
### Required fields
|
|
||||||
|
|
||||||
| Field | Description |
|
|
||||||
|---|---|
|
|
||||||
| `issuer` | Provider base URL — also used for OIDC discovery |
|
|
||||||
| `clientId` | OAuth2 client ID |
|
|
||||||
| `clientSecret` | OAuth2 client secret |
|
|
||||||
| `redirectUri` | Callback URI; server-relative paths (starting with `/`) are resolved at request time |
|
|
||||||
|
|
||||||
### Builder options
|
|
||||||
|
|
||||||
| Method | Default | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `.scopes("openid profile email")` | `"openid profile email"` | Space-separated requested scopes |
|
|
||||||
| `.routePrefix("/auth")` | `"/auth"` | Prefix for login/callback/logout routes |
|
|
||||||
| `.selfScheme("http")` | `"http"` | Scheme used when resolving server-relative redirect URIs |
|
|
||||||
| `.https()` | — | Shorthand for `.selfScheme("https")` |
|
|
||||||
| `.rolesClaimPath("realm_access.roles")` | `"realm_access.roles"` | Dot-path to the roles array in JWT claims |
|
|
||||||
| `.scopeClaimPaths("scope,scp")` | `"scope,scp"` | Comma-separated claim paths used to resolve OAuth scopes |
|
|
||||||
| `.algorithm("RS256")` | `"RS256"` | JWS algorithm for token validation |
|
|
||||||
| `.postLogoutRedirectUri("/")` | `"/"` | Where to redirect after logout |
|
|
||||||
| `.sessionStore(store)` | `InMemoryOidcSessionStore` | Custom session store (see below) |
|
|
||||||
| `.clientAuthMethod(ClientAuthMethod.POST)` | `POST` | `POST` = credentials in body; `BASIC` = `Authorization: Basic` |
|
|
||||||
| `.insecureTls()` | `false` | Disables TLS certificate verification — **development only** |
|
|
||||||
| `.schemeName("myscheme")` | derived from issuer | OpenAPI security scheme name |
|
|
||||||
|
|
||||||
### Environment variables (`OidcConfig.fromEnv()`)
|
|
||||||
|
|
||||||
```
|
|
||||||
OIDC_ISSUER required
|
|
||||||
OIDC_CLIENT_ID required
|
|
||||||
OIDC_CLIENT_SECRET required
|
|
||||||
OIDC_REDIRECT_URI required e.g. /auth/callback
|
|
||||||
OIDC_SCOPES default: openid profile email
|
|
||||||
OIDC_ROUTE_PREFIX default: /auth
|
|
||||||
OIDC_SELF_SCHEME default: http
|
|
||||||
OIDC_ROLES_CLAIM default: realm_access.roles
|
|
||||||
OIDC_SCOPE_CLAIMS default: scope,scp
|
|
||||||
OIDC_ALGORITHM default: RS256
|
|
||||||
OIDC_POST_LOGOUT_REDIRECT default: /
|
|
||||||
OIDC_CLIENT_AUTH_METHOD default: POST
|
|
||||||
```
|
|
||||||
|
|
||||||
## Protecting routes
|
|
||||||
|
|
||||||
### Class-based handlers (annotations)
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Route(method = HttpMethod.GET, path = "/me")
|
|
||||||
@Authenticated
|
|
||||||
public class MePage extends JacksonHandler {
|
|
||||||
@Override
|
|
||||||
public Object handle(Request req, Response res) {
|
|
||||||
OidcUser u = ClaimsHolder.user();
|
|
||||||
return json(res, Map.of("sub", u.sub(), "email", u.email()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Route(method = HttpMethod.GET, path = "/admin")
|
|
||||||
@RolesAllowed("admin") // OR semantics: "admin" OR "superuser"
|
|
||||||
// @RolesAllowed({"admin", "superuser"})
|
|
||||||
public class AdminPage extends JacksonHandler { ... }
|
|
||||||
|
|
||||||
@Route(method = HttpMethod.POST, path = "/orders")
|
|
||||||
@ScopesAllowed("orders:write") // default = ALL semantics
|
|
||||||
public class CreateOrder extends JacksonHandler { ... }
|
|
||||||
|
|
||||||
@Route(method = HttpMethod.POST, path = "/payments")
|
|
||||||
@ScopesAllowed(value = {"payments:write", "payments:admin"}, match = ScopesAllowed.Match.ANY)
|
|
||||||
public class PayOrder extends JacksonHandler { ... }
|
|
||||||
|
|
||||||
@Route(method = HttpMethod.DELETE, path = "/admin/users/{id}")
|
|
||||||
@RolesAllowed("admin")
|
|
||||||
@ScopesAllowed("users:delete") // combined with AND semantics
|
|
||||||
public class DeleteUser extends JacksonHandler { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
The middleware is injected automatically by the annotation processor — no manual wiring needed.
|
|
||||||
|
|
||||||
Annotation composition rules:
|
|
||||||
|
|
||||||
- `@Authenticated` requires auth only
|
|
||||||
- `@RolesAllowed` implies authentication + role OR-check
|
|
||||||
- `@ScopesAllowed` implies authentication + scope check (`ALL`/`ANY`)
|
|
||||||
- combining `@RolesAllowed` + `@ScopesAllowed` uses AND semantics
|
|
||||||
- `@Authenticated(optional = true)` cannot be combined with role/scope constraints
|
|
||||||
|
|
||||||
### Lambda routes (manual middleware)
|
|
||||||
|
|
||||||
For lambda routes, pass the middleware as a varargs argument. Retrieve `OidcMiddleware`
|
|
||||||
from the context inside another extension's `routes()` phase, or after `start()`:
|
|
||||||
|
|
||||||
```java
|
|
||||||
OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class);
|
|
||||||
|
|
||||||
// Authentication only
|
|
||||||
app.get("/api/me", (req, res) -> {
|
|
||||||
OidcUser u = ClaimsHolder.user(); // never null here
|
|
||||||
return Map.of("sub", u.sub(), "email", u.email());
|
|
||||||
}, oidc.protect());
|
|
||||||
|
|
||||||
// Authentication + role check
|
|
||||||
app.delete("/api/admin/users/{id}", (req, res) -> {
|
|
||||||
OidcUser u = ClaimsHolder.user();
|
|
||||||
// ...
|
|
||||||
}, oidc.requireRole("admin"));
|
|
||||||
|
|
||||||
// Multiple roles (OR): passes if user holds any one of them
|
|
||||||
app.get("/api/reports", (req, res) -> { ... }, oidc.requireRole("admin", "reports-viewer"));
|
|
||||||
|
|
||||||
// Require all listed scopes
|
|
||||||
app.post("/api/orders", (req, res) -> { ... }, oidc.requireScopes("orders:write", "payments:write"));
|
|
||||||
|
|
||||||
// Require at least one listed scope
|
|
||||||
app.post("/api/payments", (req, res) -> { ... }, oidc.requireAnyScope("payments:write", "payments:admin"));
|
|
||||||
```
|
|
||||||
|
|
||||||
`oidc.protect()` / `oidc.requireRole(...)` / `oidc.requireScopes(...)` return a `Middleware` — a composable
|
|
||||||
`Handler → Handler` wrapper. Flash applies middleware right-to-left so the OIDC check
|
|
||||||
runs before your handler.
|
|
||||||
|
|
||||||
## Accessing the authenticated user
|
|
||||||
|
|
||||||
`ClaimsHolder` holds the JWT claims for the current request in a `ThreadLocal`.
|
|
||||||
It is populated by the OIDC middleware before your handler runs and cleared in the
|
|
||||||
`finally` block afterward. It is safe with virtual threads (each request gets its
|
|
||||||
own virtual thread, so `ThreadLocal` values are naturally isolated).
|
|
||||||
|
|
||||||
### OidcUser (preferred)
|
|
||||||
|
|
||||||
```java
|
|
||||||
OidcUser u = ClaimsHolder.user(); // never null inside a protected handler
|
|
||||||
|
|
||||||
String sub = u.sub(); // unique user ID
|
|
||||||
String email = u.email();
|
|
||||||
String username = u.username(); // preferred_username
|
|
||||||
String name = u.name(); // full display name
|
|
||||||
|
|
||||||
// Roles — pass the dot-path matching your provider's claim structure
|
|
||||||
List<String> roles = u.roles("realm_access.roles"); // Keycloak realm roles
|
|
||||||
List<String> clientRoles = u.roles("resource_access.my-client.roles"); // Keycloak client roles
|
|
||||||
List<String> groups = u.roles("groups"); // Authelia
|
|
||||||
|
|
||||||
boolean isAdmin = u.hasRole("realm_access.roles", "admin");
|
|
||||||
|
|
||||||
// Scopes (OIDC/OAuth2 generic): checks "scope" then "scp"
|
|
||||||
List<String> scopes = u.scopes();
|
|
||||||
boolean canWrite = u.hasScope("orders:write");
|
|
||||||
|
|
||||||
// Custom claim path resolution (for provider-specific payloads)
|
|
||||||
List<String> customScopes = u.scopes("scope,scp,permissions.scopes");
|
|
||||||
boolean canApprove = u.hasScope("permissions.scopes", "orders:approve");
|
|
||||||
|
|
||||||
// Arbitrary claim
|
|
||||||
String locale = (String) u.claim("locale");
|
|
||||||
Long exp = u.claim("exp", Long.class);
|
|
||||||
|
|
||||||
// Full raw map (escape hatch)
|
|
||||||
Map<String, Object> all = u.claims();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Raw access (escape hatch)
|
|
||||||
|
|
||||||
```java
|
|
||||||
Map<String, Object> claims = ClaimsHolder.get();
|
|
||||||
String email = ClaimsHolder.claim("email");
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
The middleware adds negligible overhead on the hot path for authenticated requests:
|
|
||||||
|
|
||||||
| Step | Cost |
|
|
||||||
|---|---|
|
|
||||||
| `Authorization` header check | `O(1)` map lookup |
|
|
||||||
| Cookie parse | `O(cookie_length)` single pass scan |
|
|
||||||
| Session lookup | `O(1)` `ConcurrentHashMap.get()` |
|
|
||||||
| Token expiry check | `O(1)` `Instant` comparison |
|
|
||||||
| `ClaimsHolder.set()` | `O(1)` `ThreadLocal.set()` |
|
|
||||||
|
|
||||||
No network calls, no cryptography, no JSON parsing on the happy path (valid session).
|
|
||||||
JWKS key fetching only happens for Bearer token validation and is cached + rate-limited by
|
|
||||||
Nimbus's `JWKSourceBuilder`. Silent token refresh only triggers when the access token expires.
|
|
||||||
|
|
||||||
Role/scope claim paths are compiled once during middleware construction (mount time), not per request.
|
|
||||||
|
|
||||||
## Authentication flow details
|
|
||||||
|
|
||||||
On each request the middleware resolves credentials in this order:
|
|
||||||
|
|
||||||
1. **Bearer token** (`Authorization: Bearer <jwt>`) — validated against JWKS.
|
|
||||||
2. **Session cookie** (`oidc_session`) — looked up in the session store; transparently
|
|
||||||
refreshed if the access token is expired (silent refresh via refresh token).
|
|
||||||
3. **No valid credentials**:
|
|
||||||
- Browser clients (no `Accept: application/json`) → redirect to `{prefix}/login?redirect={path}`
|
|
||||||
- API clients → `401 Unauthorized`
|
|
||||||
|
|
||||||
### API error semantics (RFC 6750)
|
|
||||||
|
|
||||||
For API clients (`Accept: application/json`) the middleware includes `WWW-Authenticate`:
|
|
||||||
|
|
||||||
- missing credentials: `Bearer realm="<schemeName>"`
|
|
||||||
- invalid bearer token: `Bearer realm="<schemeName>", error="invalid_token"`
|
|
||||||
- insufficient scopes: `Bearer realm="<schemeName>", error="insufficient_scope", scope="<required scopes>"`
|
|
||||||
|
|
||||||
This enables interoperable client-side handling and proper OAuth2 challenge semantics.
|
|
||||||
|
|
||||||
### Token validation (OIDC Core §3.1.3.7)
|
|
||||||
|
|
||||||
| Check | Access token | ID token |
|
|
||||||
|---|---|---|
|
|
||||||
| Signature (JWKS) | yes | yes |
|
|
||||||
| `iss` | yes | yes |
|
|
||||||
| `aud` = clientId | no (varies by provider) | yes |
|
|
||||||
| `exp`, `iat`, `sub` | yes | yes |
|
|
||||||
| `nonce` | — | yes |
|
|
||||||
|
|
||||||
JWKS keys are cached, rate-limited, and retried on cache-miss (handles key rotation).
|
|
||||||
|
|
||||||
### Claim merge strategy
|
|
||||||
|
|
||||||
At callback time the extension merges access token + ID token claims:
|
|
||||||
|
|
||||||
- Access token claims first (contains provider-specific data like `realm_access.roles`)
|
|
||||||
- ID token claims override (contains verified identity: `sub`, `email`, `name`, …)
|
|
||||||
|
|
||||||
This is provider-agnostic: authorization claims live in the AT per RFC 9068,
|
|
||||||
identity claims live in the IT per OIDC Core.
|
|
||||||
|
|
||||||
## Standards & compliance notes
|
|
||||||
|
|
||||||
This extension is designed to be compliant with the most relevant OIDC/OAuth2 RFCs:
|
|
||||||
|
|
||||||
- RFC 8414 (Authorization Server Metadata): discovery via `/.well-known/openid-configuration`
|
|
||||||
- OpenID Connect Core 1.0: Authorization Code flow + PKCE + `nonce` validation on ID token
|
|
||||||
- RFC 7636 (PKCE): S256 challenge/verifier flow
|
|
||||||
- RFC 6750 (Bearer Token Usage): `WWW-Authenticate` challenges with standard error codes
|
|
||||||
- RFC 9068 (JWT Profile for Access Tokens): JWT bearer access-token validation path
|
|
||||||
- RFC 7519 / RFC 7517 / RFC 7515 family: JWT/JWK/JWS validation via Nimbus + JWKS caching/rotation
|
|
||||||
|
|
||||||
Provider interoperability details:
|
|
||||||
|
|
||||||
- scope extraction supports both standard forms: `scope` (space-delimited string) and `scp` (list/string)
|
|
||||||
- roles remain configurable via `rolesClaimPath` (`realm_access.roles`, `groups`, etc.)
|
|
||||||
- scope claim fallback chain is configurable via `scopeClaimPaths`
|
|
||||||
|
|
||||||
## Testing scopes with Keycloak
|
|
||||||
|
|
||||||
Quick path to test `@ScopesAllowed` end-to-end:
|
|
||||||
|
|
||||||
1. **Create a client scope**
|
|
||||||
- Realm -> Client scopes -> Create
|
|
||||||
- Name: `orders:write` (or any scope name you want to enforce)
|
|
||||||
2. **Attach it to your client**
|
|
||||||
- Clients -> `<your-client>` -> Client scopes
|
|
||||||
- Add the scope as `Default` (always in token) or `Optional` (requested via `scope` param)
|
|
||||||
3. **Ensure scope mapper reaches the token**
|
|
||||||
- For most Keycloak setups this is automatic via built-in `microprofile-jwt`/scope mappers
|
|
||||||
- Verify the access token contains either `scope` string or `scp` list
|
|
||||||
4. **Request the scope in Flash config**
|
|
||||||
- Include it in `OidcConfig.scopes(...)`, e.g. `"openid profile email orders:write"`
|
|
||||||
5. **Protect a handler**
|
|
||||||
- `@ScopesAllowed("orders:write")` on class-based handlers
|
|
||||||
- or `oidc.requireScopes("orders:write")` for lambda routes
|
|
||||||
6. **Verify behavior**
|
|
||||||
- token with scope -> 200
|
|
||||||
- token without scope -> 403 + `WWW-Authenticate: ... insufficient_scope`
|
|
||||||
|
|
||||||
Useful token inspection flow while testing:
|
|
||||||
|
|
||||||
- Obtain a token from Keycloak
|
|
||||||
- Decode payload (`jwt.io` or local tool)
|
|
||||||
- check `scope` / `scp` claims
|
|
||||||
- call your protected endpoint and inspect status + `WWW-Authenticate`
|
|
||||||
|
|
||||||
## Session store
|
|
||||||
|
|
||||||
The default `InMemoryOidcSessionStore` is sufficient for single-instance deployments.
|
|
||||||
For clustered deployments, implement `OidcSessionStore`:
|
|
||||||
|
|
||||||
```java
|
|
||||||
public interface OidcSessionStore {
|
|
||||||
void save(OidcSession session);
|
|
||||||
Optional<OidcSession> find(String sessionId);
|
|
||||||
void delete(String sessionId);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```java
|
|
||||||
OidcConfig.builder(...)
|
|
||||||
.sessionStore(new RedisOidcSessionStore(redisClient))
|
|
||||||
.build()
|
|
||||||
```
|
|
||||||
|
|
||||||
`OidcSession` fields: `id`, `accessToken`, `idToken`, `refreshToken`, `expiresAt` (`Instant`), `claims` (merged map).
|
|
||||||
|
|
||||||
## Logout
|
|
||||||
|
|
||||||
Add a logout button anywhere in your UI — a `<form>` is sufficient (no JavaScript needed):
|
|
||||||
|
|
||||||
```html
|
|
||||||
<form method="POST" action="/auth/logout">
|
|
||||||
<button type="submit">Logout</button>
|
|
||||||
</form>
|
|
||||||
```
|
|
||||||
|
|
||||||
The `POST {prefix}/logout` handler:
|
|
||||||
1. Reads the `oidc_session` cookie, looks up the session, retrieves the `id_token`.
|
|
||||||
2. Deletes the local session and clears the cookie (`Max-Age=0`).
|
|
||||||
3. If the provider has an `end_session_endpoint` (standard IdPs do), redirects there with
|
|
||||||
`?id_token_hint=<idToken>&post_logout_redirect_uri=<postLogoutRedirectUri>` — this logs
|
|
||||||
the user out of the IdP as well.
|
|
||||||
4. Otherwise redirects to `postLogoutRedirectUri` (default: `/`).
|
|
||||||
|
|
||||||
## Bearer token (API clients)
|
|
||||||
|
|
||||||
For API-to-API or SPA-to-API calls, pass a Bearer access token directly. The middleware
|
|
||||||
validates the JWT signature against JWKS and extracts the claims — no session involved:
|
|
||||||
|
|
||||||
```
|
|
||||||
Authorization: Bearer <access_token>
|
|
||||||
```
|
|
||||||
|
|
||||||
The token must be a JWT (opaque tokens are not supported). Claims are available via
|
|
||||||
`ClaimsHolder.user()` as usual.
|
|
||||||
|
|
||||||
## Multi-tenant
|
|
||||||
|
|
||||||
Multiple OIDC providers on one server — each `OidcExtension` instance is fully independent
|
|
||||||
(its own PKCE state store, session store, validator, and middleware):
|
|
||||||
|
|
||||||
```java
|
|
||||||
OidcConfig tenantA = OidcConfig.builder("https://idp/realms/a", "clientA", "secretA", "/a/auth/callback")
|
|
||||||
.routePrefix("/a/auth").schemeName("tenantA").build();
|
|
||||||
|
|
||||||
OidcConfig tenantB = OidcConfig.builder("https://idp/realms/b", "clientB", "secretB", "/b/auth/callback")
|
|
||||||
.routePrefix("/b/auth").schemeName("tenantB").build();
|
|
||||||
|
|
||||||
app.install(new OidcExtension(tenantA))
|
|
||||||
.install(new OidcExtension(tenantB));
|
|
||||||
```
|
|
||||||
|
|
||||||
To reference a specific tenant's middleware on lambda routes, keep the extension instances
|
|
||||||
and retrieve `OidcMiddleware` from context after `start()`:
|
|
||||||
|
|
||||||
```java
|
|
||||||
OidcExtension extA = new OidcExtension(tenantA);
|
|
||||||
OidcExtension extB = new OidcExtension(tenantB);
|
|
||||||
|
|
||||||
FlashApp app = FlashApp.create(8080)
|
|
||||||
.install(extA)
|
|
||||||
.install(extB)
|
|
||||||
.start()
|
|
||||||
.join(); // wait for bind
|
|
||||||
|
|
||||||
OidcMiddleware mwA = app.ctx().require(OidcMiddleware.class); // last registered = tenantB
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Note:** because both extensions register `OidcMiddleware.class` in the same context,
|
|
||||||
> only the last one wins under that key. For multi-tenant setups, use distinct context
|
|
||||||
> keys or provide middleware under a wrapper/alias type, or use lambda routes with explicit
|
|
||||||
> middleware captured from the extension instance before `install()`.
|
|
||||||
|
|
||||||
Class-based handlers annotated with `@Authenticated` / `@RolesAllowed` get the last
|
|
||||||
registered processor's middleware. For true multi-tenant class-based routing, install
|
|
||||||
tenant-specific annotation processors with different annotations.
|
|
||||||
|
|
||||||
## OpenAPI integration
|
|
||||||
|
|
||||||
If `flash-ext-openapi` is on the classpath and installed (order irrelevant),
|
|
||||||
the extension automatically:
|
|
||||||
|
|
||||||
- Adds a `components.securitySchemes` entry for the provider (OAuth2, authorizationCode flow)
|
|
||||||
- Adds `security` requirements to every operation whose handler carries `@Authenticated`
|
|
||||||
, `@RolesAllowed`, or `@ScopesAllowed`
|
|
||||||
|
|
||||||
No extra code needed. To customize the scheme name:
|
|
||||||
|
|
||||||
```java
|
|
||||||
OidcConfig.builder(...).schemeName("keycloak").build()
|
|
||||||
```
|
|
||||||
|
|
||||||
If `flash-ext-openapi` is absent the integration is silently skipped.
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
|
||||||
<modelVersion>4.0.0</modelVersion>
|
|
||||||
|
|
||||||
<parent>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash-extensions</artifactId>
|
|
||||||
<version>2.0.0</version>
|
|
||||||
</parent>
|
|
||||||
|
|
||||||
<artifactId>flash-ext-oidc</artifactId>
|
|
||||||
|
|
||||||
<dependencies>
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash-ext-openapi</artifactId>
|
|
||||||
<optional>true</optional>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.nimbusds</groupId>
|
|
||||||
<artifactId>nimbus-jose-jwt</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>net.minidev</groupId>
|
|
||||||
<artifactId>json-smart</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.projectlombok</groupId>
|
|
||||||
<artifactId>lombok</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.junit.jupiter</groupId>
|
|
||||||
<artifactId>junit-jupiter</artifactId>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
|
|
||||||
</project>
|
|
||||||
-40
@@ -1,40 +0,0 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
|
||||||
|
|
||||||
import java.lang.annotation.ElementType;
|
|
||||||
import java.lang.annotation.Retention;
|
|
||||||
import java.lang.annotation.RetentionPolicy;
|
|
||||||
import java.lang.annotation.Target;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marks a handler as requiring a valid JWT. Any bearer token that passes
|
|
||||||
* signature + expiry + issuer validation is accepted — no role check is performed.
|
|
||||||
*
|
|
||||||
* <p>For role-based access use {@link RolesAllowed} instead (it implies authentication).
|
|
||||||
*
|
|
||||||
* <p>Set {@code optional = true} on public routes that personalise their response when
|
|
||||||
* the user happens to be logged in but should remain accessible to guests. The middleware
|
|
||||||
* will populate {@link ClaimsHolder} if credentials are present and silently skip it
|
|
||||||
* otherwise — the request is never rejected.
|
|
||||||
*
|
|
||||||
* <pre>{@code
|
|
||||||
* // Hard auth — redirects / 401 when unauthenticated:
|
|
||||||
* @Route(method = HttpMethod.GET, path = "/api/profile")
|
|
||||||
* @Authenticated
|
|
||||||
* public class GetProfile extends JacksonHandler { ... }
|
|
||||||
*
|
|
||||||
* // Soft auth — guest-friendly, ClaimsHolder populated only when logged in:
|
|
||||||
* @Route(method = HttpMethod.GET, path = "/")
|
|
||||||
* @Authenticated(optional = true)
|
|
||||||
* public class HomePage extends HtmlHandler { ... }
|
|
||||||
* }</pre>
|
|
||||||
*/
|
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
|
||||||
@Target(ElementType.TYPE)
|
|
||||||
public @interface Authenticated {
|
|
||||||
/**
|
|
||||||
* When {@code true} the middleware never rejects unauthenticated requests — it only
|
|
||||||
* populates {@link ClaimsHolder} when valid credentials are present.
|
|
||||||
* Defaults to {@code false} (hard authentication required).
|
|
||||||
*/
|
|
||||||
boolean optional() default false;
|
|
||||||
}
|
|
||||||
-71
@@ -1,71 +0,0 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thread-local store for JWT claims, populated by the OIDC middleware before
|
|
||||||
* the handler runs and cleared in the {@code finally} block afterward.
|
|
||||||
*
|
|
||||||
* <p>Safe with virtual threads: each request gets its own virtual thread, so
|
|
||||||
* {@link ThreadLocal} values are naturally isolated per request.
|
|
||||||
*
|
|
||||||
* <pre>{@code
|
|
||||||
* // Inside any handler protected by @Authenticated or @RolesAllowed:
|
|
||||||
*
|
|
||||||
* // Preferred — typed wrapper:
|
|
||||||
* OidcUser user = ClaimsHolder.user();
|
|
||||||
* String email = user.email();
|
|
||||||
* List<String> roles = user.roles("realm_access.roles");
|
|
||||||
*
|
|
||||||
* // Raw escape hatch:
|
|
||||||
* Map<String, Object> all = ClaimsHolder.get();
|
|
||||||
* }</pre>
|
|
||||||
*/
|
|
||||||
public final class ClaimsHolder {
|
|
||||||
|
|
||||||
private static final ThreadLocal<Map<String, Object>> HOLDER = new ThreadLocal<>();
|
|
||||||
|
|
||||||
private ClaimsHolder() {}
|
|
||||||
|
|
||||||
/** Called by the OIDC middleware after successful token validation. */
|
|
||||||
static void set(Map<String, Object> claims) {
|
|
||||||
HOLDER.set(claims);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Called by the OIDC middleware in the {@code finally} block. */
|
|
||||||
static void clear() {
|
|
||||||
HOLDER.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a type-safe {@link OidcUser} view of the current request's claims,
|
|
||||||
* or {@code null} if the route is not protected by OIDC middleware.
|
|
||||||
*
|
|
||||||
* <p>This is the preferred entry point for both lambda and class-based handlers.
|
|
||||||
*/
|
|
||||||
public static OidcUser user() {
|
|
||||||
Map<String, Object> claims = HOLDER.get();
|
|
||||||
return claims != null ? new OidcUser(claims) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the raw claims map for the current request, or {@code null} if
|
|
||||||
* the route is not protected by OIDC middleware.
|
|
||||||
*
|
|
||||||
* @see #user() for the preferred type-safe accessor
|
|
||||||
*/
|
|
||||||
public static Map<String, Object> get() {
|
|
||||||
return HOLDER.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the value of a single claim as a String, or {@code null} if
|
|
||||||
* the claim is absent or the request is not authenticated.
|
|
||||||
*/
|
|
||||||
public static String claim(String key) {
|
|
||||||
Map<String, Object> claims = HOLDER.get();
|
|
||||||
if (claims == null) return null;
|
|
||||||
Object v = claims.get(key);
|
|
||||||
return v != null ? v.toString() : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-18
@@ -1,18 +0,0 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OAuth2 client authentication method for the token endpoint (RFC 6749 §2.3).
|
|
||||||
*
|
|
||||||
* <ul>
|
|
||||||
* <li>{@link #POST} — credentials sent as {@code client_id} / {@code client_secret}
|
|
||||||
* form fields (default; most providers).</li>
|
|
||||||
* <li>{@link #BASIC} — credentials sent as an {@code Authorization: Basic} header;
|
|
||||||
* body contains only grant-specific parameters.</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
public enum ClientAuthMethod {
|
|
||||||
/** {@code client_secret_post} — credentials in the request body. */
|
|
||||||
POST,
|
|
||||||
/** {@code client_secret_basic} — credentials in the {@code Authorization} header. */
|
|
||||||
BASIC
|
|
||||||
}
|
|
||||||
-50
@@ -1,50 +0,0 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
|
||||||
|
|
||||||
import net.minidev.json.JSONValue;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.http.HttpClient;
|
|
||||||
import java.net.http.HttpRequest;
|
|
||||||
import java.net.http.HttpResponse;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches and parses the OIDC provider discovery document at
|
|
||||||
* {@code {issuer}/.well-known/openid-configuration}.
|
|
||||||
*/
|
|
||||||
final class DiscoveryClient {
|
|
||||||
|
|
||||||
private DiscoveryClient() {}
|
|
||||||
|
|
||||||
static OidcProviderMetadata fetch(String issuer, HttpClient http) throws Exception {
|
|
||||||
String url = issuer.endsWith("/")
|
|
||||||
? issuer + ".well-known/openid-configuration"
|
|
||||||
: issuer + "/.well-known/openid-configuration";
|
|
||||||
|
|
||||||
HttpResponse<String> resp = http.send(
|
|
||||||
HttpRequest.newBuilder().uri(URI.create(url)).GET().build(),
|
|
||||||
HttpResponse.BodyHandlers.ofString());
|
|
||||||
|
|
||||||
if (resp.statusCode() != 200)
|
|
||||||
throw new IllegalStateException(
|
|
||||||
"OIDC discovery failed [" + resp.statusCode() + "]: " + url);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> doc = (Map<String, Object>) JSONValue.parse(resp.body());
|
|
||||||
|
|
||||||
return new OidcProviderMetadata(
|
|
||||||
require(doc, "authorization_endpoint"),
|
|
||||||
require(doc, "token_endpoint"),
|
|
||||||
(String) doc.get("userinfo_endpoint"), // optional
|
|
||||||
require(doc, "jwks_uri"),
|
|
||||||
(String) doc.get("end_session_endpoint") // optional
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String require(Map<String, Object> doc, String key) {
|
|
||||||
Object v = doc.get(key);
|
|
||||||
if (v == null) throw new IllegalStateException(
|
|
||||||
"Discovery doc missing required field: " + key);
|
|
||||||
return v.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-20
@@ -1,20 +0,0 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thread-safe in-memory {@link OidcSessionStore}.
|
|
||||||
*
|
|
||||||
* <p>Sessions are lost on restart and not shared across instances. For
|
|
||||||
* production deployments with multiple nodes or restart-persistence requirements,
|
|
||||||
* supply a custom implementation via {@link OidcConfig.Builder#sessionStore}.
|
|
||||||
*/
|
|
||||||
public final class InMemoryOidcSessionStore implements OidcSessionStore {
|
|
||||||
|
|
||||||
private final ConcurrentHashMap<String, OidcSession> store = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
@Override public void save(OidcSession s) { store.put(s.id(), s); }
|
|
||||||
@Override public Optional<OidcSession> find(String id) { return Optional.ofNullable(store.get(id)); }
|
|
||||||
@Override public void delete(String id) { store.remove(id); }
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
|
||||||
|
|
||||||
import net.minidev.json.JSONValue;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.Base64;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Low-level JWT payload extraction — no signature or expiry validation.
|
|
||||||
*
|
|
||||||
* <p>Use only for tokens received directly from the provider over a trusted TLS
|
|
||||||
* connection (e.g. {@code id_token} from the token endpoint). Bearer tokens on
|
|
||||||
* incoming requests must go through {@link JwtValidator#validate(String)} instead.
|
|
||||||
*/
|
|
||||||
final class JwtUtils {
|
|
||||||
|
|
||||||
private JwtUtils() {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base64URL-decodes the JWT payload and returns the claims as a map.
|
|
||||||
* Signature, expiry, and issuer are NOT checked.
|
|
||||||
*/
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
static Map<String, Object> parseClaims(String jwt) {
|
|
||||||
String[] parts = jwt.split("\\.");
|
|
||||||
if (parts.length < 2) throw new IllegalArgumentException("Malformed JWT: " + jwt);
|
|
||||||
// Pad to a multiple of 4 for the standard decoder
|
|
||||||
String padded = parts[1];
|
|
||||||
switch (padded.length() % 4) {
|
|
||||||
case 2 -> padded += "==";
|
|
||||||
case 3 -> padded += "=";
|
|
||||||
}
|
|
||||||
byte[] payload = Base64.getUrlDecoder().decode(padded);
|
|
||||||
return (Map<String, Object>) JSONValue.parse(
|
|
||||||
new String(payload, StandardCharsets.UTF_8));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-184
@@ -1,184 +0,0 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
|
||||||
|
|
||||||
import com.nimbusds.jose.JWSAlgorithm;
|
|
||||||
import com.nimbusds.jose.jwk.source.JWKSource;
|
|
||||||
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
|
|
||||||
import com.nimbusds.jose.proc.JWSKeySelector;
|
|
||||||
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
|
|
||||||
import com.nimbusds.jose.proc.SecurityContext;
|
|
||||||
import com.nimbusds.jose.util.Resource;
|
|
||||||
import com.nimbusds.jose.util.ResourceRetriever;
|
|
||||||
import com.nimbusds.jwt.JWTClaimsSet;
|
|
||||||
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
|
|
||||||
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
|
|
||||||
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
|
|
||||||
import dev.relism.flash.exceptions.HttpException;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.URL;
|
|
||||||
import java.net.http.HttpClient;
|
|
||||||
import java.net.http.HttpRequest;
|
|
||||||
import java.net.http.HttpResponse;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates JWTs against a remote JWKS endpoint using Nimbus JOSE+JWT.
|
|
||||||
*
|
|
||||||
* <p>Two validation modes:
|
|
||||||
* <ul>
|
|
||||||
* <li>{@link #validate(String)} — access token bearer validation per request (hot path).
|
|
||||||
* Checks signature, {@code iss}, {@code exp}, {@code iat}, {@code sub}.
|
|
||||||
* Throws {@link HttpException} 401 so the middleware can short-circuit.</li>
|
|
||||||
* <li>{@link #validateIdToken(String, String)} — ID token validation at callback time.
|
|
||||||
* Checks signature, {@code iss}, {@code aud} == clientId, {@code exp}, {@code iat},
|
|
||||||
* {@code sub}, and {@code nonce} (if provided).
|
|
||||||
* Throws {@link OidcValidationException} (not 401 — it is a provider/protocol error).</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <p>JWKS handling: the shared {@link JWKSource} uses caching + rate-limiting + automatic
|
|
||||||
* retry-on-key-miss (key rotation). Both processors share the same source — one JWKS
|
|
||||||
* fetch serves both token types.
|
|
||||||
*/
|
|
||||||
public class JwtValidator {
|
|
||||||
|
|
||||||
private final JWKSource<SecurityContext> jwkSource;
|
|
||||||
private final ConfigurableJWTProcessor<SecurityContext> accessTokenProcessor;
|
|
||||||
private final ConfigurableJWTProcessor<SecurityContext> idTokenProcessor;
|
|
||||||
private final String algorithm;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param jwksUri JWKS endpoint URI
|
|
||||||
* @param issuer Expected {@code iss} claim
|
|
||||||
* @param clientId OAuth2 client ID — used as expected {@code aud} in ID tokens
|
|
||||||
* @param algorithm JWS algorithm (e.g. {@code "RS256"})
|
|
||||||
* @param http Shared {@link HttpClient} used for all JWKS fetches — already configured
|
|
||||||
* with the correct TLS policy (trust-all or default trust store).
|
|
||||||
*/
|
|
||||||
public JwtValidator(String jwksUri, String issuer, String clientId,
|
|
||||||
String algorithm, HttpClient http) {
|
|
||||||
try {
|
|
||||||
// Use the caller-supplied HttpClient for JWKS retrieval so that TLS policy
|
|
||||||
// (insecureTls / custom trust store) is applied consistently everywhere.
|
|
||||||
this.jwkSource = JWKSourceBuilder
|
|
||||||
.create(new URL(jwksUri), httpRetriever(http))
|
|
||||||
.cache(true)
|
|
||||||
.rateLimited(true)
|
|
||||||
.retrying(true)
|
|
||||||
.build();
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new IllegalStateException("Failed to init JWKS source: " + jwksUri, e);
|
|
||||||
}
|
|
||||||
this.algorithm = algorithm;
|
|
||||||
this.accessTokenProcessor = buildAccessTokenProcessor(jwkSource, issuer, algorithm);
|
|
||||||
this.idTokenProcessor = buildIdTokenProcessor(jwkSource, issuer, clientId, algorithm);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Public API -----------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates a JWT access token (bearer on incoming request).
|
|
||||||
* Returns claims on success; throws {@link HttpException} 401 on any failure.
|
|
||||||
*/
|
|
||||||
public Map<String, Object> validate(String token) {
|
|
||||||
if (!isJwt(token)) throw HttpException.unauthorized(); // opaque token — can't validate
|
|
||||||
try {
|
|
||||||
return accessTokenProcessor.process(token, null).getClaims();
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw HttpException.unauthorized();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates an ID token received directly from the token endpoint.
|
|
||||||
*
|
|
||||||
* <p>Checks: signature (JWKS), {@code iss}, {@code aud} == clientId,
|
|
||||||
* {@code exp}, {@code iat}, {@code sub}, and {@code nonce} if provided.
|
|
||||||
*
|
|
||||||
* @param idToken Raw ID token string
|
|
||||||
* @param nonce Nonce sent in the authorization request; {@code null} to skip check
|
|
||||||
* @throws OidcValidationException on any validation failure
|
|
||||||
*/
|
|
||||||
public Map<String, Object> validateIdToken(String idToken, String nonce) {
|
|
||||||
try {
|
|
||||||
Map<String, Object> claims = idTokenProcessor.process(idToken, null).getClaims();
|
|
||||||
if (nonce != null && !nonce.equals(claims.get("nonce")))
|
|
||||||
throw new OidcValidationException("ID token nonce mismatch", null);
|
|
||||||
return claims;
|
|
||||||
} catch (OidcValidationException e) {
|
|
||||||
throw e;
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new OidcValidationException("ID token validation failed: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns {@code true} if {@code token} is a signed JWT (three dot-separated Base64URL parts).
|
|
||||||
* Used to detect opaque access tokens before attempting JWKS validation.
|
|
||||||
*/
|
|
||||||
public static boolean isJwt(String token) {
|
|
||||||
if (token == null || token.isBlank()) return false;
|
|
||||||
int dots = 0;
|
|
||||||
for (int i = 0; i < token.length(); i++) if (token.charAt(i) == '.') dots++;
|
|
||||||
return dots == 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Processors -----------------------------------------------------------
|
|
||||||
|
|
||||||
private static ConfigurableJWTProcessor<SecurityContext> buildAccessTokenProcessor(
|
|
||||||
JWKSource<SecurityContext> src, String issuer, String algorithm) {
|
|
||||||
|
|
||||||
ConfigurableJWTProcessor<SecurityContext> p = new DefaultJWTProcessor<>();
|
|
||||||
p.setJWSKeySelector(keySelector(src, algorithm));
|
|
||||||
// iss required; aud not enforced on ATs (varies by provider)
|
|
||||||
if (issuer != null && !issuer.isBlank()) {
|
|
||||||
p.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
|
|
||||||
new JWTClaimsSet.Builder().issuer(issuer).build(),
|
|
||||||
Set.of("sub", "iat", "exp")));
|
|
||||||
}
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ConfigurableJWTProcessor<SecurityContext> buildIdTokenProcessor(
|
|
||||||
JWKSource<SecurityContext> src, String issuer, String clientId, String algorithm) {
|
|
||||||
|
|
||||||
ConfigurableJWTProcessor<SecurityContext> p = new DefaultJWTProcessor<>();
|
|
||||||
p.setJWSKeySelector(keySelector(src, algorithm));
|
|
||||||
// iss + aud = clientId strictly required (OIDC Core §3.1.3.7)
|
|
||||||
JWTClaimsSet.Builder required = new JWTClaimsSet.Builder();
|
|
||||||
if (issuer != null) required.issuer(issuer);
|
|
||||||
if (clientId != null) required.audience(clientId);
|
|
||||||
p.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
|
|
||||||
required.build(), Set.of("sub", "iat", "exp")));
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static JWSKeySelector<SecurityContext> keySelector(
|
|
||||||
JWKSource<SecurityContext> src, String algorithm) {
|
|
||||||
return new JWSVerificationKeySelector<>(JWSAlgorithm.parse(algorithm), src);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps a {@link HttpClient} as a Nimbus {@link ResourceRetriever}.
|
|
||||||
* The client already carries the correct TLS policy (trust-all or default),
|
|
||||||
* so JWKS fetches honour the same SSL configuration as discovery and token requests.
|
|
||||||
*/
|
|
||||||
private static ResourceRetriever httpRetriever(HttpClient http) {
|
|
||||||
return url -> {
|
|
||||||
try {
|
|
||||||
HttpResponse<String> resp = http.send(
|
|
||||||
HttpRequest.newBuilder().uri(url.toURI()).GET().build(),
|
|
||||||
HttpResponse.BodyHandlers.ofString());
|
|
||||||
if (resp.statusCode() != 200)
|
|
||||||
throw new IOException("JWKS fetch failed [" + resp.statusCode() + "]: " + url);
|
|
||||||
String contentType = resp.headers()
|
|
||||||
.firstValue("Content-Type").orElse("application/json");
|
|
||||||
return new Resource(resp.body(), contentType);
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw e;
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new IOException("JWKS retrieval error: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-98
@@ -1,98 +0,0 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
|
||||||
|
|
||||||
import java.util.LinkedHashSet;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compiled authorization policy derived from handler annotations at mount time.
|
|
||||||
* Immutable and allocation-free on the request hot path.
|
|
||||||
*/
|
|
||||||
final class OidcAuthPolicy {
|
|
||||||
|
|
||||||
private static final String[] EMPTY = new String[0];
|
|
||||||
|
|
||||||
private static final OidcAuthPolicy AUTH_REQUIRED = new OidcAuthPolicy(
|
|
||||||
false, EMPTY, EMPTY, ScopesAllowed.Match.ALL);
|
|
||||||
private static final OidcAuthPolicy AUTH_OPTIONAL = new OidcAuthPolicy(
|
|
||||||
true, EMPTY, EMPTY, ScopesAllowed.Match.ALL);
|
|
||||||
|
|
||||||
private final boolean optionalAuth;
|
|
||||||
private final String[] requiredRoles;
|
|
||||||
private final String[] requiredScopes;
|
|
||||||
private final ScopesAllowed.Match scopeMatch;
|
|
||||||
|
|
||||||
private OidcAuthPolicy(boolean optionalAuth,
|
|
||||||
String[] requiredRoles,
|
|
||||||
String[] requiredScopes,
|
|
||||||
ScopesAllowed.Match scopeMatch) {
|
|
||||||
this.optionalAuth = optionalAuth;
|
|
||||||
this.requiredRoles = requiredRoles;
|
|
||||||
this.requiredScopes = requiredScopes;
|
|
||||||
this.scopeMatch = scopeMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
static OidcAuthPolicy authenticated() { return AUTH_REQUIRED; }
|
|
||||||
|
|
||||||
static OidcAuthPolicy optional() { return AUTH_OPTIONAL; }
|
|
||||||
|
|
||||||
static OidcAuthPolicy rolesAny(String... roles) {
|
|
||||||
return new OidcAuthPolicy(false, normalizeRequired("RolesAllowed", roles), EMPTY, ScopesAllowed.Match.ALL);
|
|
||||||
}
|
|
||||||
|
|
||||||
static OidcAuthPolicy scopes(String[] scopes, ScopesAllowed.Match match) {
|
|
||||||
return new OidcAuthPolicy(false, EMPTY, normalizeRequired("ScopesAllowed", scopes), match);
|
|
||||||
}
|
|
||||||
|
|
||||||
static OidcAuthPolicy compileFromAnnotations(Class<?> handlerClass) {
|
|
||||||
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
|
|
||||||
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
|
|
||||||
ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class);
|
|
||||||
|
|
||||||
if (auth == null && roles == null && scopes == null) return null;
|
|
||||||
|
|
||||||
boolean optionalAuth = auth != null && auth.optional();
|
|
||||||
String[] requiredRoles = roles != null ? normalizeRequired("RolesAllowed", roles.value()) : EMPTY;
|
|
||||||
String[] requiredScopes = scopes != null ? normalizeRequired("ScopesAllowed", scopes.value()) : EMPTY;
|
|
||||||
ScopesAllowed.Match scopeMatch = scopes != null ? scopes.match() : ScopesAllowed.Match.ALL;
|
|
||||||
|
|
||||||
if (optionalAuth && (requiredRoles.length > 0 || requiredScopes.length > 0)) {
|
|
||||||
throw new IllegalStateException("@Authenticated(optional = true) cannot be combined with @RolesAllowed/@ScopesAllowed on "
|
|
||||||
+ handlerClass.getName());
|
|
||||||
}
|
|
||||||
|
|
||||||
return new OidcAuthPolicy(optionalAuth, requiredRoles, requiredScopes, scopeMatch);
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<String> openApiScopesFor(Class<?> handlerClass) {
|
|
||||||
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
|
|
||||||
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
|
|
||||||
ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class);
|
|
||||||
if (auth == null && roles == null && scopes == null) return null;
|
|
||||||
if (scopes == null) return List.of();
|
|
||||||
return List.of(normalizeRequired("ScopesAllowed", scopes.value()));
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean optionalAuth() { return optionalAuth; }
|
|
||||||
|
|
||||||
String[] requiredRoles() { return requiredRoles; }
|
|
||||||
|
|
||||||
String[] requiredScopes() { return requiredScopes; }
|
|
||||||
|
|
||||||
ScopesAllowed.Match scopeMatch() { return scopeMatch; }
|
|
||||||
|
|
||||||
private static String[] normalizeRequired(String annotation, String[] values) {
|
|
||||||
if (values == null || values.length == 0)
|
|
||||||
throw new IllegalStateException("@" + annotation + " requires at least one value");
|
|
||||||
|
|
||||||
LinkedHashSet<String> normalized = new LinkedHashSet<>(values.length);
|
|
||||||
for (String raw : values) {
|
|
||||||
if (raw == null) continue;
|
|
||||||
String trimmed = raw.trim();
|
|
||||||
if (!trimmed.isEmpty()) normalized.add(trimmed);
|
|
||||||
}
|
|
||||||
if (normalized.isEmpty())
|
|
||||||
throw new IllegalStateException("@" + annotation + " requires at least one non-empty value");
|
|
||||||
|
|
||||||
return normalized.toArray(String[]::new);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-261
@@ -1,261 +0,0 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full OIDC client configuration. Build via
|
|
||||||
* {@link #builder(String, String, String, String)} or {@link #fromEnv()}.
|
|
||||||
*
|
|
||||||
* <p>Required fields: {@code issuer}, {@code clientId}, {@code clientSecret},
|
|
||||||
* {@code redirectUri}. Everything else has a sensible default.
|
|
||||||
*
|
|
||||||
* <p>If {@code redirectUri} starts with {@code /} it is treated as server-relative:
|
|
||||||
* the absolute URL is resolved at request time using {@link #selfScheme()} and the
|
|
||||||
* incoming {@code Host} header. Use {@link Builder#https()} when behind TLS.
|
|
||||||
*
|
|
||||||
* <pre>{@code
|
|
||||||
* // Keycloak
|
|
||||||
* OidcConfig.builder(
|
|
||||||
* "https://keycloak.example.com/realms/myrealm",
|
|
||||||
* "my-app", "secret", "/auth/callback")
|
|
||||||
* .rolesClaimPath("realm_access.roles") // Keycloak default
|
|
||||||
* .scopeClaimPaths("scope,scp") // default; supports many IdPs
|
|
||||||
* .build();
|
|
||||||
*
|
|
||||||
* // Authelia
|
|
||||||
* OidcConfig.builder(
|
|
||||||
* "https://auth.example.com",
|
|
||||||
* "my-app", "secret", "/auth/callback")
|
|
||||||
* .rolesClaimPath("groups")
|
|
||||||
* .scopeClaimPaths("scope,scp")
|
|
||||||
* .build();
|
|
||||||
*
|
|
||||||
* // Two tenants on one server
|
|
||||||
* OidcConfig tenantA = OidcConfig.builder("https://idp/realms/a", ..., "/tenantA/auth/callback")
|
|
||||||
* .routePrefix("/tenantA/auth").build();
|
|
||||||
* OidcConfig tenantB = OidcConfig.builder("https://idp/realms/b", ..., "/tenantB/auth/callback")
|
|
||||||
* .routePrefix("/tenantB/auth").build();
|
|
||||||
* app.install(new OidcExtension(tenantA))
|
|
||||||
* .install(new OidcExtension(tenantB));
|
|
||||||
* }</pre>
|
|
||||||
*/
|
|
||||||
public final class OidcConfig {
|
|
||||||
|
|
||||||
private final String issuer;
|
|
||||||
private final String clientId;
|
|
||||||
private final String clientSecret;
|
|
||||||
private final String redirectUri;
|
|
||||||
private final String scopes;
|
|
||||||
private final String routePrefix;
|
|
||||||
private final String selfScheme;
|
|
||||||
private final String rolesClaimPath;
|
|
||||||
private final String scopeClaimPaths;
|
|
||||||
private final String algorithm;
|
|
||||||
private final String postLogoutRedirectUri;
|
|
||||||
private final OidcSessionStore sessionStore;
|
|
||||||
private final boolean insecureTls;
|
|
||||||
private final ClientAuthMethod clientAuthMethod;
|
|
||||||
private final String schemeName;
|
|
||||||
|
|
||||||
private OidcConfig(Builder b) {
|
|
||||||
this.issuer = require(b.issuer, "issuer");
|
|
||||||
this.clientId = require(b.clientId, "clientId");
|
|
||||||
this.clientSecret = require(b.clientSecret, "clientSecret");
|
|
||||||
this.redirectUri = require(b.redirectUri, "redirectUri");
|
|
||||||
this.scopes = b.scopes;
|
|
||||||
this.routePrefix = b.routePrefix;
|
|
||||||
this.selfScheme = b.selfScheme;
|
|
||||||
this.rolesClaimPath = b.rolesClaimPath;
|
|
||||||
this.scopeClaimPaths = b.scopeClaimPaths;
|
|
||||||
this.algorithm = b.algorithm;
|
|
||||||
this.postLogoutRedirectUri = b.postLogoutRedirectUri;
|
|
||||||
this.sessionStore = b.sessionStore != null ? b.sessionStore
|
|
||||||
: new InMemoryOidcSessionStore();
|
|
||||||
this.insecureTls = b.insecureTls;
|
|
||||||
this.clientAuthMethod = b.clientAuthMethod;
|
|
||||||
this.schemeName = b.schemeName != null ? b.schemeName : deriveScheme(this.issuer);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Getters --------------------------------------------------------------
|
|
||||||
|
|
||||||
public String issuer() { return issuer; }
|
|
||||||
public String clientId() { return clientId; }
|
|
||||||
public String clientSecret() { return clientSecret; }
|
|
||||||
public String redirectUri() { return redirectUri; }
|
|
||||||
public String scopes() { return scopes; }
|
|
||||||
public String routePrefix() { return routePrefix; }
|
|
||||||
public String selfScheme() { return selfScheme; }
|
|
||||||
public String rolesClaimPath() { return rolesClaimPath; }
|
|
||||||
/** Comma-separated claim paths used to read OAuth2 scopes (default: {@code "scope,scp"}). */
|
|
||||||
public String scopeClaimPaths() { return scopeClaimPaths; }
|
|
||||||
public String algorithm() { return algorithm; }
|
|
||||||
public String postLogoutRedirectUri() { return postLogoutRedirectUri; }
|
|
||||||
public OidcSessionStore sessionStore() { return sessionStore; }
|
|
||||||
/** If {@code true}, TLS certificate validation is skipped. <b>Never use in production.</b> */
|
|
||||||
public boolean insecureTls() { return insecureTls; }
|
|
||||||
public ClientAuthMethod clientAuthMethod() { return clientAuthMethod; }
|
|
||||||
/** OpenAPI security scheme name (derived from issuer if not set explicitly). */
|
|
||||||
public String schemeName() { return schemeName; }
|
|
||||||
|
|
||||||
// -- Factory --------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads configuration from environment variables:
|
|
||||||
* <pre>
|
|
||||||
* OIDC_ISSUER required
|
|
||||||
* OIDC_CLIENT_ID required
|
|
||||||
* OIDC_CLIENT_SECRET required
|
|
||||||
* OIDC_REDIRECT_URI required (e.g. /auth/callback)
|
|
||||||
* OIDC_SCOPES default: openid profile email
|
|
||||||
* OIDC_ROUTE_PREFIX default: /auth
|
|
||||||
* OIDC_SELF_SCHEME default: http
|
|
||||||
* OIDC_ROLES_CLAIM default: realm_access.roles
|
|
||||||
* OIDC_SCOPE_CLAIMS default: scope,scp
|
|
||||||
* OIDC_ALGORITHM default: RS256
|
|
||||||
* OIDC_POST_LOGOUT_REDIRECT default: /
|
|
||||||
* </pre>
|
|
||||||
*/
|
|
||||||
public static OidcConfig fromEnv() {
|
|
||||||
return builder(env("OIDC_ISSUER"), env("OIDC_CLIENT_ID"),
|
|
||||||
env("OIDC_CLIENT_SECRET"), env("OIDC_REDIRECT_URI"))
|
|
||||||
.scopes (envOr("OIDC_SCOPES", "openid profile email"))
|
|
||||||
.routePrefix (envOr("OIDC_ROUTE_PREFIX", "/auth"))
|
|
||||||
.selfScheme (envOr("OIDC_SELF_SCHEME", "http"))
|
|
||||||
.rolesClaimPath (envOr("OIDC_ROLES_CLAIM", "realm_access.roles"))
|
|
||||||
.scopeClaimPaths (envOr("OIDC_SCOPE_CLAIMS", "scope,scp"))
|
|
||||||
.algorithm (envOr("OIDC_ALGORITHM", "RS256"))
|
|
||||||
.postLogoutRedirectUri(envOr("OIDC_POST_LOGOUT_REDIRECT", "/"))
|
|
||||||
.clientAuthMethod(ClientAuthMethod.valueOf(
|
|
||||||
envOr("OIDC_CLIENT_AUTH_METHOD", "POST").toUpperCase()))
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Builder builder(String issuer, String clientId,
|
|
||||||
String clientSecret, String redirectUri) {
|
|
||||||
return new Builder(issuer, clientId, clientSecret, redirectUri);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convenience factory for Keycloak: constructs the issuer as
|
|
||||||
* {@code {serverUrl}/realms/{realm}} automatically.
|
|
||||||
*
|
|
||||||
* <pre>{@code
|
|
||||||
* OidcConfig.keycloak(
|
|
||||||
* "https://keycloak.example.com", "flashboard",
|
|
||||||
* "my-app", "secret", "/auth/callback")
|
|
||||||
* .https()
|
|
||||||
* .build();
|
|
||||||
* }</pre>
|
|
||||||
*/
|
|
||||||
public static Builder keycloak(String serverUrl, String realm,
|
|
||||||
String clientId, String clientSecret,
|
|
||||||
String redirectUri) {
|
|
||||||
String base = serverUrl.endsWith("/") ? serverUrl.substring(0, serverUrl.length() - 1) : serverUrl;
|
|
||||||
String issuer = base + "/realms/" + realm;
|
|
||||||
return new Builder(issuer, clientId, clientSecret, redirectUri)
|
|
||||||
.rolesClaimPath("realm_access.roles"); // Keycloak default
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Helpers --------------------------------------------------------------
|
|
||||||
|
|
||||||
private static String require(String v, String name) {
|
|
||||||
if (v == null || v.isBlank())
|
|
||||||
throw new IllegalArgumentException("OidcConfig: " + name + " is required");
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String env(String key) {
|
|
||||||
String v = System.getenv(key);
|
|
||||||
if (v == null || v.isBlank())
|
|
||||||
throw new IllegalArgumentException("Missing required env var: " + key);
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String envOr(String key, String def) {
|
|
||||||
String v = System.getenv(key);
|
|
||||||
return (v != null && !v.isBlank()) ? v : def;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Builder --------------------------------------------------------------
|
|
||||||
|
|
||||||
public static final class Builder {
|
|
||||||
|
|
||||||
private final String issuer;
|
|
||||||
private final String clientId;
|
|
||||||
private final String clientSecret;
|
|
||||||
private final String redirectUri;
|
|
||||||
|
|
||||||
private String scopes = "openid profile email";
|
|
||||||
private String routePrefix = "/auth";
|
|
||||||
private String selfScheme = "http";
|
|
||||||
private String rolesClaimPath = "realm_access.roles";
|
|
||||||
private String scopeClaimPaths = "scope,scp";
|
|
||||||
private String algorithm = "RS256";
|
|
||||||
private String postLogoutRedirectUri = "/";
|
|
||||||
private OidcSessionStore sessionStore;
|
|
||||||
private boolean insecureTls = false;
|
|
||||||
private ClientAuthMethod clientAuthMethod = ClientAuthMethod.POST;
|
|
||||||
private String schemeName = null;
|
|
||||||
|
|
||||||
private Builder(String issuer, String clientId, String clientSecret, String redirectUri) {
|
|
||||||
this.issuer = issuer;
|
|
||||||
this.clientId = clientId;
|
|
||||||
this.clientSecret = clientSecret;
|
|
||||||
this.redirectUri = redirectUri;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Override requested scopes (default: {@code openid profile email}). */
|
|
||||||
public Builder scopes(String scopes) { this.scopes = scopes; return this; }
|
|
||||||
/** Route prefix for login/callback/logout (default: {@code /auth}). */
|
|
||||||
public Builder routePrefix(String prefix) { this.routePrefix = prefix; return this; }
|
|
||||||
/** Scheme used when resolving self-relative redirect URIs (default: {@code http}). */
|
|
||||||
public Builder selfScheme(String scheme) { this.selfScheme = scheme; return this; }
|
|
||||||
/** Shorthand for {@code selfScheme("https")}. */
|
|
||||||
public Builder https() { return selfScheme("https"); }
|
|
||||||
/** Dot-separated path to the roles array in JWT claims (default: {@code realm_access.roles}). */
|
|
||||||
public Builder rolesClaimPath(String path) { this.rolesClaimPath = path; return this; }
|
|
||||||
/** Comma-separated claim paths used to resolve OAuth2 scopes (default: {@code scope,scp}). */
|
|
||||||
public Builder scopeClaimPaths(String paths) { this.scopeClaimPaths = paths; return this; }
|
|
||||||
/** JWS algorithm (default: {@code RS256}). */
|
|
||||||
public Builder algorithm(String algorithm) { this.algorithm = algorithm; return this; }
|
|
||||||
/** Where to redirect after logout (default: {@code /}). */
|
|
||||||
public Builder postLogoutRedirectUri(String uri) { this.postLogoutRedirectUri = uri; return this; }
|
|
||||||
/** Custom session store (default: {@link InMemoryOidcSessionStore}). */
|
|
||||||
public Builder sessionStore(OidcSessionStore store) { this.sessionStore = store; return this; }
|
|
||||||
/**
|
|
||||||
* Disables TLS certificate verification for all HTTP calls made by this extension.
|
|
||||||
* <b>Only use in development with self-signed certificates — never in production.</b>
|
|
||||||
*/
|
|
||||||
public Builder insecureTls() { this.insecureTls = true; return this; }
|
|
||||||
/** Token endpoint client authentication method (default: {@link ClientAuthMethod#POST}). */
|
|
||||||
public Builder clientAuthMethod(ClientAuthMethod method) { this.clientAuthMethod = method; return this; }
|
|
||||||
/** Override the OpenAPI security scheme name (default: derived from the issuer URI). */
|
|
||||||
public Builder schemeName(String name) { this.schemeName = name; return this; }
|
|
||||||
|
|
||||||
public OidcConfig build() { return new OidcConfig(this); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derives a short, human-readable scheme name from the issuer URI.
|
|
||||||
* Takes the last non-empty path segment; falls back to the host.
|
|
||||||
*
|
|
||||||
* <p>Examples:
|
|
||||||
* <ul>
|
|
||||||
* <li>{@code https://keycloak.dev.home/realms/flashboard} → {@code "flashboard"}</li>
|
|
||||||
* <li>{@code https://auth.example.com} → {@code "auth.example.com"}</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
private static String deriveScheme(String issuer) {
|
|
||||||
try {
|
|
||||||
java.net.URI uri = new java.net.URI(issuer);
|
|
||||||
String path = uri.getPath();
|
|
||||||
if (path != null && !path.isEmpty()) {
|
|
||||||
String[] parts = path.split("/");
|
|
||||||
for (int i = parts.length - 1; i >= 0; i--) {
|
|
||||||
if (!parts[i].isEmpty()) return parts[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return uri.getHost();
|
|
||||||
} catch (Exception e) {
|
|
||||||
return "oidc";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-336
@@ -1,336 +0,0 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
|
||||||
|
|
||||||
import dev.relism.flash.ext.openapi.OpenApiContributor;
|
|
||||||
import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
|
|
||||||
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
|
|
||||||
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
|
|
||||||
import dev.relism.flash.extension.FlashContext;
|
|
||||||
import dev.relism.flash.extension.FlashExtension;
|
|
||||||
import dev.relism.flash.extension.FlashRegistrar;
|
|
||||||
import dev.relism.flash.models.Request;
|
|
||||||
|
|
||||||
import javax.net.ssl.SSLContext;
|
|
||||||
import javax.net.ssl.TrustManager;
|
|
||||||
import javax.net.ssl.X509TrustManager;
|
|
||||||
import java.net.URLEncoder;
|
|
||||||
import java.net.http.HttpClient;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.security.SecureRandom;
|
|
||||||
import java.security.cert.X509Certificate;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.*;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full OIDC Authorization Code + PKCE flow for Flash.
|
|
||||||
*
|
|
||||||
* <p>At {@link #provide}, the extension:
|
|
||||||
* <ol>
|
|
||||||
* <li>Fetches the provider discovery document — fail-fast at startup.</li>
|
|
||||||
* <li>Provides {@link OidcMiddleware} and {@link JwtValidator} in the context.</li>
|
|
||||||
* <li>Registers annotation processors for {@link Authenticated}, {@link RolesAllowed}
|
|
||||||
* and {@link ScopesAllowed}.</li>
|
|
||||||
* </ol>
|
|
||||||
*
|
|
||||||
* <p>At {@link #routes}, three routes are registered:
|
|
||||||
* <ul>
|
|
||||||
* <li>{@code GET {prefix}/login} — builds the authorization URL and redirects.</li>
|
|
||||||
* <li>{@code GET {prefix}/callback} — exchanges the code, creates a session, redirects.</li>
|
|
||||||
* <li>{@code POST {prefix}/logout} — invalidates the session, redirects to provider
|
|
||||||
* end-session endpoint (if available) or to {@link OidcConfig#postLogoutRedirectUri()}.</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <pre>{@code
|
|
||||||
* // Keycloak
|
|
||||||
* app.install(new OidcExtension(
|
|
||||||
* OidcConfig.builder(
|
|
||||||
* "https://keycloak.example.com/realms/myrealm",
|
|
||||||
* "my-app", "secret", "/auth/callback")
|
|
||||||
* .rolesClaimPath("realm_access.roles")
|
|
||||||
* .build()));
|
|
||||||
*
|
|
||||||
* // Two providers / tenants on one server
|
|
||||||
* app.install(new OidcExtension(tenantAConfig))
|
|
||||||
* .install(new OidcExtension(tenantBConfig));
|
|
||||||
* }</pre>
|
|
||||||
*/
|
|
||||||
public class OidcExtension implements FlashExtension {
|
|
||||||
|
|
||||||
private final OidcConfig config;
|
|
||||||
|
|
||||||
// Initialized in provide(), used in routes() — private to this extension instance.
|
|
||||||
private OidcProviderMetadata meta;
|
|
||||||
private OidcStateStore stateStore;
|
|
||||||
private TokenClient tokenClient;
|
|
||||||
private JwtValidator validator;
|
|
||||||
private OidcMiddleware oidcMw;
|
|
||||||
|
|
||||||
public OidcExtension(OidcConfig config) {
|
|
||||||
this.config = config;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Phase 1: services ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void provide(FlashContext ctx) {
|
|
||||||
HttpClient http = buildHttpClient(config);
|
|
||||||
|
|
||||||
// Discover provider endpoints (blocking; fail fast at startup).
|
|
||||||
try {
|
|
||||||
meta = DiscoveryClient.fetch(config.issuer(), http);
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new IllegalStateException("OIDC discovery failed for issuer: " + config.issuer(), e);
|
|
||||||
}
|
|
||||||
|
|
||||||
validator = new JwtValidator(meta.jwksUri(), config.issuer(), config.clientId(), config.algorithm(), http);
|
|
||||||
stateStore = new OidcStateStore();
|
|
||||||
tokenClient = new TokenClient(http, config);
|
|
||||||
oidcMw = new OidcMiddleware(validator, config, meta, tokenClient);
|
|
||||||
|
|
||||||
ctx.provide(OidcMiddleware.class, oidcMw);
|
|
||||||
ctx.provide(JwtValidator.class, validator);
|
|
||||||
|
|
||||||
ctx.addAnnotationProcessor(handlerClass -> {
|
|
||||||
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
|
|
||||||
return policy != null ? List.of(oidcMw.policyMiddleware(policy)) : List.of();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Phase 2: routes ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
|
|
||||||
String prefix = config.routePrefix();
|
|
||||||
|
|
||||||
// ── GET {prefix}/login ────────────────────────────────────────────────
|
|
||||||
// Builds the provider authorization URL with PKCE + state and redirects.
|
|
||||||
// Optional query param: ?redirect={relative-url} (default: /)
|
|
||||||
app.get(prefix + "/login", (req, res) -> {
|
|
||||||
String verifier = PkceUtils.generateVerifier();
|
|
||||||
String challenge = PkceUtils.computeChallenge(verifier);
|
|
||||||
String state = UUID.randomUUID().toString();
|
|
||||||
String nonce = UUID.randomUUID().toString();
|
|
||||||
|
|
||||||
String redirect = req.query("redirect");
|
|
||||||
if (redirect == null || !redirect.startsWith("/")) redirect = "/";
|
|
||||||
|
|
||||||
stateStore.put(state, redirect, verifier, nonce);
|
|
||||||
|
|
||||||
String authUrl = meta.authorizationEndpoint()
|
|
||||||
+ "?response_type=code"
|
|
||||||
+ "&client_id=" + enc(config.clientId())
|
|
||||||
+ "&redirect_uri=" + enc(absoluteRedirectUri(req))
|
|
||||||
+ "&scope=" + enc(config.scopes())
|
|
||||||
+ "&state=" + state
|
|
||||||
+ "&nonce=" + enc(nonce)
|
|
||||||
+ "&code_challenge=" + challenge
|
|
||||||
+ "&code_challenge_method=S256";
|
|
||||||
|
|
||||||
res.redirect(authUrl);
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── GET {prefix}/callback ─────────────────────────────────────────────
|
|
||||||
// Validates state, exchanges code for tokens, creates session, redirects.
|
|
||||||
app.get(prefix + "/callback", (req, res) -> {
|
|
||||||
String error = req.query("error");
|
|
||||||
if (error != null) {
|
|
||||||
res.status(400);
|
|
||||||
return "Authentication error: " + error
|
|
||||||
+ (req.query("error_description") != null
|
|
||||||
? " — " + req.query("error_description") : "");
|
|
||||||
}
|
|
||||||
|
|
||||||
String code = req.query("code");
|
|
||||||
String state = req.query("state");
|
|
||||||
|
|
||||||
OidcStateStore.Entry entry = stateStore.consumeAndRemove(state).orElse(null);
|
|
||||||
if (entry == null) {
|
|
||||||
res.status(400);
|
|
||||||
return "Invalid or expired state parameter";
|
|
||||||
}
|
|
||||||
|
|
||||||
OidcTokenResponse tokens = tokenClient.exchangeCode(
|
|
||||||
meta.tokenEndpoint(), code, absoluteRedirectUri(req), entry.codeVerifier());
|
|
||||||
|
|
||||||
// Validate ID token: signature + iss + aud + exp + iat + sub + nonce (OIDC Core §3.1.3.7)
|
|
||||||
if (tokens.idToken() != null) {
|
|
||||||
try {
|
|
||||||
validator.validateIdToken(tokens.idToken(), entry.nonce());
|
|
||||||
} catch (OidcValidationException e) {
|
|
||||||
res.status(400);
|
|
||||||
return "ID token validation failed: " + e.getMessage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object> claims = mergeClaims(tokens);
|
|
||||||
OidcSession session = new OidcSession(
|
|
||||||
UUID.randomUUID().toString(),
|
|
||||||
tokens.accessToken(), tokens.idToken(), tokens.refreshToken(),
|
|
||||||
Instant.now().plusSeconds(tokens.expiresIn()), claims);
|
|
||||||
config.sessionStore().save(session);
|
|
||||||
|
|
||||||
res.header("Set-Cookie", sessionCookie(session.id()))
|
|
||||||
.redirect(entry.originalUrl());
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── POST {prefix}/logout ──────────────────────────────────────────────
|
|
||||||
// Invalidates the local session and redirects to end_session_endpoint.
|
|
||||||
app.post(prefix + "/logout", (req, res) -> {
|
|
||||||
String sessionId = OidcMiddleware.cookieValue(req, "oidc_session");
|
|
||||||
String idTokenHint = null;
|
|
||||||
|
|
||||||
if (sessionId != null) {
|
|
||||||
OidcSession session = config.sessionStore().find(sessionId).orElse(null);
|
|
||||||
if (session != null) idTokenHint = session.idToken();
|
|
||||||
config.sessionStore().delete(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
String clearCookie = "oidc_session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax";
|
|
||||||
String location;
|
|
||||||
|
|
||||||
if (meta.endSessionEndpoint() != null) {
|
|
||||||
String postLogout = absoluteSelf(req, config.postLogoutRedirectUri());
|
|
||||||
StringBuilder url = new StringBuilder(meta.endSessionEndpoint())
|
|
||||||
.append("?post_logout_redirect_uri=").append(enc(postLogout));
|
|
||||||
if (idTokenHint != null)
|
|
||||||
url.append("&id_token_hint=").append(enc(idTokenHint));
|
|
||||||
location = url.toString();
|
|
||||||
} else {
|
|
||||||
location = config.postLogoutRedirectUri();
|
|
||||||
}
|
|
||||||
|
|
||||||
res.header("Set-Cookie", clearCookie).redirect(location);
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Register OpenAPI security scheme if flash-ext-openapi is on the classpath.
|
|
||||||
try {
|
|
||||||
OpenApiIntegration.register(ctx, config, meta);
|
|
||||||
} catch (NoClassDefFoundError ignored) {
|
|
||||||
// flash-ext-openapi not available — OpenAPI integration disabled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merges claims from both the access token and the ID token.
|
|
||||||
* ID token values win on conflict so that verified identity claims are authoritative.
|
|
||||||
*/
|
|
||||||
private static Map<String, Object> mergeClaims(OidcTokenResponse tokens) {
|
|
||||||
Map<String, Object> merged = new HashMap<>();
|
|
||||||
if (tokens.accessToken() != null) merged.putAll(JwtUtils.parseClaims(tokens.accessToken()));
|
|
||||||
if (tokens.idToken() != null) merged.putAll(JwtUtils.parseClaims(tokens.idToken()));
|
|
||||||
return Map.copyOf(merged);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds an {@link HttpClient}. If {@link OidcConfig#insecureTls()} is set,
|
|
||||||
* installs a trust-all {@link SSLContext} that accepts any certificate.
|
|
||||||
* <b>Only safe for development with self-signed certificates.</b>
|
|
||||||
*/
|
|
||||||
private static HttpClient buildHttpClient(OidcConfig config) {
|
|
||||||
if (!config.insecureTls()) return HttpClient.newHttpClient();
|
|
||||||
try {
|
|
||||||
TrustManager[] trustAll = { new X509TrustManager() {
|
|
||||||
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
|
|
||||||
public void checkClientTrusted(X509Certificate[] c, String a) {}
|
|
||||||
public void checkServerTrusted(X509Certificate[] c, String a) {}
|
|
||||||
}};
|
|
||||||
SSLContext sslCtx = SSLContext.getInstance("TLS");
|
|
||||||
sslCtx.init(null, trustAll, new SecureRandom());
|
|
||||||
return HttpClient.newBuilder().sslContext(sslCtx).build();
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new IllegalStateException("Failed to create trust-all SSLContext", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String absoluteRedirectUri(Request req) {
|
|
||||||
return absoluteSelf(req, config.redirectUri());
|
|
||||||
}
|
|
||||||
|
|
||||||
private String absoluteSelf(Request req, String uri) {
|
|
||||||
if (!uri.startsWith("/")) return uri;
|
|
||||||
return config.selfScheme() + "://" + req.header("Host") + uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String enc(String v) {
|
|
||||||
return URLEncoder.encode(v, StandardCharsets.UTF_8);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String sessionCookie(String id) {
|
|
||||||
return "oidc_session=" + id + "; HttpOnly; Path=/; SameSite=Lax";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loaded lazily so that {@code flash-ext-openapi} classes are only resolved at
|
|
||||||
* runtime when {@link OpenApiContributorRegistry} is actually on the classpath.
|
|
||||||
*/
|
|
||||||
private static final class OpenApiIntegration {
|
|
||||||
static void register(FlashContext ctx,
|
|
||||||
OidcConfig config, OidcProviderMetadata meta) {
|
|
||||||
ctx.find(OpenApiContributorRegistry.class)
|
|
||||||
.ifPresent(registry -> registry.add(new OpenApiContributor() {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Map<String, Object> componentContributions() {
|
|
||||||
Map<String, String> scopesMap = new LinkedHashMap<>();
|
|
||||||
for (String s : config.scopes().split("\\s+")) {
|
|
||||||
if (!s.isBlank()) scopesMap.put(s, s);
|
|
||||||
}
|
|
||||||
Map<String, Object> flow = new LinkedHashMap<>();
|
|
||||||
flow.put("authorizationUrl", meta.authorizationEndpoint());
|
|
||||||
flow.put("tokenUrl", meta.tokenEndpoint());
|
|
||||||
flow.put("scopes", scopesMap);
|
|
||||||
|
|
||||||
Map<String, Object> scheme = new LinkedHashMap<>();
|
|
||||||
scheme.put("type", "oauth2");
|
|
||||||
scheme.put("flows", Map.of("authorizationCode", flow));
|
|
||||||
|
|
||||||
Map<String, Object> securitySchemes = new LinkedHashMap<>();
|
|
||||||
securitySchemes.put(config.schemeName(), scheme);
|
|
||||||
return Map.of("securitySchemes", securitySchemes);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public OpenApiOperationContribution operationFor(Class<?> handlerClass) {
|
|
||||||
OpenApiOperationContribution.Builder out =
|
|
||||||
OpenApiOperationContribution.builder();
|
|
||||||
|
|
||||||
List<String> operationScopes = OidcAuthPolicy.openApiScopesFor(handlerClass);
|
|
||||||
if (operationScopes != null) {
|
|
||||||
out.security(config.schemeName(), operationScopes);
|
|
||||||
}
|
|
||||||
|
|
||||||
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
|
|
||||||
if (policy == null || policy.optionalAuth()) return out.build();
|
|
||||||
|
|
||||||
out.response(401, OpenApiResponseContribution.of("Authentication required"));
|
|
||||||
|
|
||||||
String[] roles = policy.requiredRoles();
|
|
||||||
String[] scopes = policy.requiredScopes();
|
|
||||||
if (roles.length == 0 && scopes.length == 0) return out.build();
|
|
||||||
|
|
||||||
String roleMessage = roles.length == 0 ? null : roleRequiredMessage(roles);
|
|
||||||
String scopeMessage = scopes.length == 0 ? null : scopeRequiredMessage(scopes);
|
|
||||||
if (roleMessage != null && scopeMessage != null) {
|
|
||||||
out.response(403, OpenApiResponseContribution.of(roleMessage + "; " + scopeMessage));
|
|
||||||
} else
|
|
||||||
out.response(403, OpenApiResponseContribution.of(Objects.requireNonNullElse(roleMessage, scopeMessage)));
|
|
||||||
return out.build();
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String roleRequiredMessage(String[] roles) {
|
|
||||||
if (roles.length == 1) return "\"" + roles[0] + "\" role required";
|
|
||||||
return "Roles \"" + String.join(", ", roles) + "\" are required";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String scopeRequiredMessage(String[] scopes) {
|
|
||||||
if (scopes.length == 1) return "\"" + scopes[0] + "\" scope required";
|
|
||||||
return "Scopes \"" + String.join(", ", scopes) + "\" are required";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-492
@@ -1,492 +0,0 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
|
||||||
|
|
||||||
import dev.relism.flash.exceptions.HttpException;
|
|
||||||
import dev.relism.flash.extension.FlashContext;
|
|
||||||
import dev.relism.flash.models.Response;
|
|
||||||
import dev.relism.flash.models.Request;
|
|
||||||
import dev.relism.flash.routing.Middleware;
|
|
||||||
|
|
||||||
import java.net.URLEncoder;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request-level OIDC middleware. Exposed in the {@link FlashContext}
|
|
||||||
* for manual use on lambda routes; injected automatically for handlers annotated with
|
|
||||||
* {@link Authenticated}, {@link RolesAllowed} or {@link ScopesAllowed}.
|
|
||||||
*
|
|
||||||
* <p>Resolution order on each request:
|
|
||||||
* <ol>
|
|
||||||
* <li>{@code Authorization: Bearer ...} header — validated via JWKS ({@link JwtValidator}).</li>
|
|
||||||
* <li>{@code oidc_session} cookie — looked up in {@link OidcSessionStore}; transparently
|
|
||||||
* refreshed if the access token is expired.</li>
|
|
||||||
* <li>Browser clients (no {@code Accept: application/json}) → redirect to
|
|
||||||
* {@code {routePrefix}/login?redirect={path}}.</li>
|
|
||||||
* <li>API clients → 401.</li>
|
|
||||||
* </ol>
|
|
||||||
*
|
|
||||||
* <pre>{@code
|
|
||||||
* // Manual use on a lambda route:
|
|
||||||
* OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class);
|
|
||||||
* app.get("/api/me", (req, res) -> ClaimsHolder.claim("sub"), oidc.protect());
|
|
||||||
* app.delete("/admin/users/{id}", handler, oidc.requireRole("admin"));
|
|
||||||
* }</pre>
|
|
||||||
*/
|
|
||||||
public class OidcMiddleware {
|
|
||||||
|
|
||||||
private static final String BEARER = "Bearer";
|
|
||||||
|
|
||||||
private final JwtValidator validator;
|
|
||||||
private final OidcConfig config;
|
|
||||||
private final OidcProviderMetadata meta;
|
|
||||||
private final TokenClient tokenClient;
|
|
||||||
private final String[] roleClaimPathParts;
|
|
||||||
private final String[][] scopeClaimPathParts;
|
|
||||||
|
|
||||||
OidcMiddleware(JwtValidator validator, OidcConfig config,
|
|
||||||
OidcProviderMetadata meta, TokenClient tokenClient) {
|
|
||||||
this.validator = validator;
|
|
||||||
this.config = config;
|
|
||||||
this.meta = meta;
|
|
||||||
this.tokenClient = tokenClient;
|
|
||||||
this.roleClaimPathParts = splitClaimPath(config.rolesClaimPath());
|
|
||||||
this.scopeClaimPathParts = splitClaimPaths(config.scopeClaimPaths());
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Public API -----------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the bearer token or session cookie. Browser clients are redirected
|
|
||||||
* to the login page on failure; API clients receive 401.
|
|
||||||
*/
|
|
||||||
public Middleware protect() {
|
|
||||||
return next -> (req, res) -> {
|
|
||||||
Map<String, Object> claims = resolve(req, res);
|
|
||||||
if (claims == null) return null; // redirect already written
|
|
||||||
ClaimsHolder.set(claims);
|
|
||||||
try {
|
|
||||||
return next.handle(req, res);
|
|
||||||
} finally {
|
|
||||||
ClaimsHolder.clear();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Silently populates {@link ClaimsHolder} if a valid bearer token or session cookie
|
|
||||||
* is present, but never rejects or redirects unauthenticated requests. Use this on
|
|
||||||
* public routes that want to personalise the response when the user happens to be
|
|
||||||
* logged in (e.g. showing a username on a landing page).
|
|
||||||
*
|
|
||||||
* <pre>{@code
|
|
||||||
* app.get("/", handler, oidc.optional());
|
|
||||||
* // Inside handler: ClaimsHolder.user() is non-null iff the user is logged in.
|
|
||||||
* }</pre>
|
|
||||||
*/
|
|
||||||
public Middleware optional() {
|
|
||||||
return next -> (req, res) -> {
|
|
||||||
Map<String, Object> claims = resolveQuiet(req);
|
|
||||||
if (claims != null) ClaimsHolder.set(claims);
|
|
||||||
try {
|
|
||||||
return next.handle(req, res);
|
|
||||||
} finally {
|
|
||||||
ClaimsHolder.clear();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compiled authorization policy path used by annotation-driven mounting.
|
|
||||||
* The policy is immutable and built once at boot.
|
|
||||||
*/
|
|
||||||
public Middleware authorize(OidcAuthPolicy policy) {
|
|
||||||
if (policy.optionalAuth()) return optional();
|
|
||||||
return next -> (req, res) -> {
|
|
||||||
Map<String, Object> claims = resolve(req, res);
|
|
||||||
if (claims == null) return null;
|
|
||||||
enforcePolicy(claims, policy, res);
|
|
||||||
ClaimsHolder.set(claims);
|
|
||||||
try {
|
|
||||||
return next.handle(req, res);
|
|
||||||
} finally {
|
|
||||||
ClaimsHolder.clear();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Like {@link #protect()} but also enforces that the caller holds at least one
|
|
||||||
* of the given roles (OR semantics). Roles are extracted via
|
|
||||||
* {@link OidcConfig#rolesClaimPath()}.
|
|
||||||
*/
|
|
||||||
public Middleware requireRole(String... roles) {
|
|
||||||
return authorize(OidcAuthPolicy.rolesAny(roles));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Requires all listed scopes to be present in the token.
|
|
||||||
* Scopes are resolved from configured claim paths (default: {@code scope,scp}).
|
|
||||||
*/
|
|
||||||
public Middleware requireScopes(String... scopes) {
|
|
||||||
return authorize(OidcAuthPolicy.scopes(scopes, ScopesAllowed.Match.ALL));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Requires at least one of the listed scopes to be present in the token.
|
|
||||||
* Scopes are resolved from configured claim paths (default: {@code scope,scp}).
|
|
||||||
*/
|
|
||||||
public Middleware requireAnyScope(String... scopes) {
|
|
||||||
return authorize(OidcAuthPolicy.scopes(scopes, ScopesAllowed.Match.ANY));
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Package-private: AnnotationProcessor hooks ---------------------------
|
|
||||||
|
|
||||||
Middleware authenticatedMiddleware() { return protect(); }
|
|
||||||
Middleware optionalMiddleware() { return optional(); }
|
|
||||||
Middleware rolesMiddleware(String[] required) { return requireRole(required); }
|
|
||||||
Middleware scopesMiddleware(String[] required, ScopesAllowed.Match match) {
|
|
||||||
return authorize(OidcAuthPolicy.scopes(required, match));
|
|
||||||
}
|
|
||||||
Middleware policyMiddleware(OidcAuthPolicy policy) { return authorize(policy); }
|
|
||||||
|
|
||||||
// -- Internals ------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Like {@link #resolve} but never redirects or throws — returns {@code null} silently
|
|
||||||
* when no valid credentials are present. Used by {@link #optional()}.
|
|
||||||
*/
|
|
||||||
private Map<String, Object> resolveQuiet(Request req) {
|
|
||||||
String bearerToken = extractBearerToken(req.header("Authorization"));
|
|
||||||
if (bearerToken != null) {
|
|
||||||
try {
|
|
||||||
return validator.validate(bearerToken);
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String sessionId = cookieValue(req, "oidc_session");
|
|
||||||
if (sessionId != null) {
|
|
||||||
Optional<OidcSession> found = config.sessionStore().find(sessionId);
|
|
||||||
if (found.isPresent()) {
|
|
||||||
OidcSession session = found.get();
|
|
||||||
if (!session.isAccessTokenExpired())
|
|
||||||
return session.claims();
|
|
||||||
if (session.refreshToken() != null) {
|
|
||||||
try {
|
|
||||||
OidcSession refreshed = doRefresh(session);
|
|
||||||
config.sessionStore().save(refreshed);
|
|
||||||
return refreshed.claims();
|
|
||||||
} catch (Exception ignored) { }
|
|
||||||
}
|
|
||||||
config.sessionStore().delete(sessionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns claims on success, or {@code null} if a redirect was already written to
|
|
||||||
* {@code res}. Throws {@link HttpException} 401/403 for API clients.
|
|
||||||
*/
|
|
||||||
private Map<String, Object> resolve(Request req, Response res) {
|
|
||||||
// 1. Bearer token
|
|
||||||
String bearerToken = extractBearerToken(req.header("Authorization"));
|
|
||||||
if (bearerToken != null) {
|
|
||||||
try {
|
|
||||||
return validator.validate(bearerToken);
|
|
||||||
} catch (HttpException e) {
|
|
||||||
res.header("WWW-Authenticate", invalidTokenChallenge());
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Session cookie
|
|
||||||
String sessionId = cookieValue(req, "oidc_session");
|
|
||||||
if (sessionId != null) {
|
|
||||||
Optional<OidcSession> found = config.sessionStore().find(sessionId);
|
|
||||||
if (found.isPresent()) {
|
|
||||||
OidcSession session = found.get();
|
|
||||||
|
|
||||||
if (!session.isAccessTokenExpired())
|
|
||||||
return session.claims();
|
|
||||||
|
|
||||||
// Access token expired — try silent refresh
|
|
||||||
if (session.refreshToken() != null) {
|
|
||||||
try {
|
|
||||||
OidcSession refreshed = doRefresh(session);
|
|
||||||
config.sessionStore().save(refreshed);
|
|
||||||
return refreshed.claims();
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
// Refresh failed — fall through to re-authenticate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
config.sessionStore().delete(sessionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. No valid credentials
|
|
||||||
String accept = req.header("Accept");
|
|
||||||
if (accept != null && accept.contains("application/json")) {
|
|
||||||
res.header("WWW-Authenticate", bearerChallenge());
|
|
||||||
throw HttpException.unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Browser — redirect to login, preserving the original URL in state
|
|
||||||
String loginUrl = config.routePrefix() + "/login?redirect="
|
|
||||||
+ URLEncoder.encode(req.path(), StandardCharsets.UTF_8);
|
|
||||||
res.redirect(loginUrl);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private OidcSession doRefresh(OidcSession old) throws Exception {
|
|
||||||
OidcTokenResponse tokens = tokenClient.refresh(
|
|
||||||
meta.tokenEndpoint(), old.refreshToken());
|
|
||||||
|
|
||||||
Map<String, Object> claims = mergeRefreshedClaims(tokens, old);
|
|
||||||
|
|
||||||
return new OidcSession(
|
|
||||||
old.id(),
|
|
||||||
tokens.accessToken(),
|
|
||||||
tokens.idToken() != null ? tokens.idToken() : old.idToken(),
|
|
||||||
tokens.refreshToken() != null ? tokens.refreshToken() : old.refreshToken(),
|
|
||||||
Instant.now().plusSeconds(tokens.expiresIn()),
|
|
||||||
claims
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void enforcePolicy(Map<String, Object> claims, OidcAuthPolicy policy, Response res) {
|
|
||||||
checkRoles(claims, policy.requiredRoles());
|
|
||||||
checkScopes(claims, policy.requiredScopes(), policy.scopeMatch(), res);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void checkRoles(Map<String, Object> claims, String[] required) {
|
|
||||||
if (required.length == 0) return;
|
|
||||||
if (rolesAllowed(claims, required)) return;
|
|
||||||
throw HttpException.forbidden();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void checkScopes(Map<String, Object> claims, String[] required, ScopesAllowed.Match match,
|
|
||||||
Response res) {
|
|
||||||
if (required.length == 0) return;
|
|
||||||
if (scopesAllowed(claims, required, match)) return;
|
|
||||||
res.header("WWW-Authenticate", insufficientScopeChallenge(required));
|
|
||||||
throw HttpException.forbidden();
|
|
||||||
}
|
|
||||||
|
|
||||||
static String extractBearerToken(String authorizationHeader) {
|
|
||||||
if (authorizationHeader == null) return null;
|
|
||||||
int len = authorizationHeader.length();
|
|
||||||
int start = 0;
|
|
||||||
while (start < len && Character.isWhitespace(authorizationHeader.charAt(start))) start++;
|
|
||||||
int schemeEnd = start + BEARER.length();
|
|
||||||
if (schemeEnd > len || !authorizationHeader.regionMatches(true, start, BEARER, 0, BEARER.length())) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (schemeEnd == len || !Character.isWhitespace(authorizationHeader.charAt(schemeEnd))) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
int tokenStart = schemeEnd;
|
|
||||||
while (tokenStart < len && Character.isWhitespace(authorizationHeader.charAt(tokenStart))) tokenStart++;
|
|
||||||
if (tokenStart >= len) return null;
|
|
||||||
int tokenEnd = len;
|
|
||||||
while (tokenEnd > tokenStart && Character.isWhitespace(authorizationHeader.charAt(tokenEnd - 1))) tokenEnd--;
|
|
||||||
return tokenEnd > tokenStart ? authorizationHeader.substring(tokenStart, tokenEnd) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
String bearerChallenge() {
|
|
||||||
return BEARER + " realm=\"" + quoted(config.schemeName()) + "\"";
|
|
||||||
}
|
|
||||||
|
|
||||||
String invalidTokenChallenge() {
|
|
||||||
return bearerChallenge() + ", error=\"invalid_token\"";
|
|
||||||
}
|
|
||||||
|
|
||||||
String insufficientScopeChallenge(String[] requiredScopes) {
|
|
||||||
return bearerChallenge() + ", error=\"insufficient_scope\", scope=\""
|
|
||||||
+ quoted(spaceDelimited(requiredScopes)) + "\"";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String spaceDelimited(String[] values) {
|
|
||||||
if (values == null || values.length == 0) return "";
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
for (int i = 0; i < values.length; i++) {
|
|
||||||
if (i > 0) sb.append(' ');
|
|
||||||
sb.append(values[i]);
|
|
||||||
}
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String quoted(String value) {
|
|
||||||
StringBuilder out = new StringBuilder(value.length() + 8);
|
|
||||||
for (int i = 0; i < value.length(); i++) {
|
|
||||||
char c = value.charAt(i);
|
|
||||||
if (c == '"' || c == '\\') out.append('\\');
|
|
||||||
out.append(c);
|
|
||||||
}
|
|
||||||
return out.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean rolesAllowed(Map<String, Object> claims, String[] required) {
|
|
||||||
Object actual = valueAtPath(claims, roleClaimPathParts);
|
|
||||||
if (actual == null) return false;
|
|
||||||
for (String role : required) {
|
|
||||||
if (containsToken(actual, role)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean scopesAllowed(Map<String, Object> claims, String[] required, ScopesAllowed.Match match) {
|
|
||||||
if (match == ScopesAllowed.Match.ALL) {
|
|
||||||
for (String scope : required) {
|
|
||||||
if (!hasScope(claims, scope)) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
for (String scope : required) {
|
|
||||||
if (hasScope(claims, scope)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean hasScope(Map<String, Object> claims, String scope) {
|
|
||||||
for (String[] pathParts : scopeClaimPathParts) {
|
|
||||||
Object value = valueAtPath(claims, pathParts);
|
|
||||||
if (value != null && containsToken(value, scope)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Object valueAtPath(Map<String, Object> claims, String[] pathParts) {
|
|
||||||
Object current = claims;
|
|
||||||
for (String part : pathParts) {
|
|
||||||
if (!(current instanceof Map<?, ?> map)) return null;
|
|
||||||
current = map.get(part);
|
|
||||||
if (current == null) return null;
|
|
||||||
}
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean containsToken(Object source, String token) {
|
|
||||||
if (source instanceof String s) return containsDelimitedToken(s, token);
|
|
||||||
if (source instanceof List<?> list) {
|
|
||||||
for (Object item : list) {
|
|
||||||
if (item == null) continue;
|
|
||||||
if (tokenEquals(item.toString(), token)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (source instanceof Object[] arr) {
|
|
||||||
for (Object item : arr) {
|
|
||||||
if (item == null) continue;
|
|
||||||
if (tokenEquals(item.toString(), token)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return tokenEquals(source.toString(), token);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean containsDelimitedToken(String value, String token) {
|
|
||||||
int len = value.length();
|
|
||||||
int i = 0;
|
|
||||||
while (i < len) {
|
|
||||||
while (i < len && isScopeDelimiter(value.charAt(i))) i++;
|
|
||||||
int start = i;
|
|
||||||
while (i < len && !isScopeDelimiter(value.charAt(i))) i++;
|
|
||||||
int end = i;
|
|
||||||
if (end > start && end - start == token.length() && value.regionMatches(start, token, 0, token.length())) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean tokenEquals(String value, String token) {
|
|
||||||
int start = 0;
|
|
||||||
int end = value.length();
|
|
||||||
while (start < end && Character.isWhitespace(value.charAt(start))) start++;
|
|
||||||
while (end > start && Character.isWhitespace(value.charAt(end - 1))) end--;
|
|
||||||
return end - start == token.length() && value.regionMatches(start, token, 0, token.length());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isScopeDelimiter(char c) {
|
|
||||||
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',';
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String[] splitClaimPath(String path) {
|
|
||||||
if (path == null || path.isBlank()) {
|
|
||||||
throw new IllegalStateException("OIDC claim path cannot be blank");
|
|
||||||
}
|
|
||||||
List<String> parts = new ArrayList<>(4);
|
|
||||||
int start = 0;
|
|
||||||
int len = path.length();
|
|
||||||
for (int i = 0; i <= len; i++) {
|
|
||||||
if (i == len || path.charAt(i) == '.') {
|
|
||||||
String p = path.substring(start, i).trim();
|
|
||||||
if (!p.isEmpty()) parts.add(p);
|
|
||||||
start = i + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (parts.isEmpty()) {
|
|
||||||
throw new IllegalStateException("OIDC claim path cannot be blank");
|
|
||||||
}
|
|
||||||
return parts.toArray(String[]::new);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String[][] splitClaimPaths(String paths) {
|
|
||||||
String source = (paths == null || paths.isBlank()) ? "scope,scp" : paths;
|
|
||||||
List<String[]> out = new ArrayList<>(4);
|
|
||||||
int start = 0;
|
|
||||||
int len = source.length();
|
|
||||||
for (int i = 0; i <= len; i++) {
|
|
||||||
if (i == len || source.charAt(i) == ',') {
|
|
||||||
String raw = source.substring(start, i).trim();
|
|
||||||
if (!raw.isEmpty()) out.add(splitClaimPath(raw));
|
|
||||||
start = i + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (out.isEmpty()) {
|
|
||||||
return new String[][]{ splitClaimPath("scope"), splitClaimPath("scp") };
|
|
||||||
}
|
|
||||||
return out.toArray(String[][]::new);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Map<String, Object> mergeRefreshedClaims(OidcTokenResponse tokens, OidcSession old) {
|
|
||||||
Map<String, Object> merged = new HashMap<>();
|
|
||||||
// Fall back to old claims first, then overlay fresh token claims
|
|
||||||
merged.putAll(old.claims());
|
|
||||||
if (tokens.accessToken() != null)
|
|
||||||
merged.putAll(JwtUtils.parseClaims(tokens.accessToken()));
|
|
||||||
if (tokens.idToken() != null)
|
|
||||||
merged.putAll(JwtUtils.parseClaims(tokens.idToken()));
|
|
||||||
return Map.copyOf(merged);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Shared cookie utility (also used by OidcExtension) -------------------
|
|
||||||
|
|
||||||
static String cookieValue(Request req, String name) {
|
|
||||||
String header = req.header("Cookie");
|
|
||||||
if (header == null || header.isBlank()) return null;
|
|
||||||
int len = header.length();
|
|
||||||
int start = 0;
|
|
||||||
while (start < len) {
|
|
||||||
int semi = header.indexOf(';', start);
|
|
||||||
int end = semi < 0 ? len : semi;
|
|
||||||
int eq = header.indexOf('=', start);
|
|
||||||
if (eq > start && eq < end) {
|
|
||||||
int ns = start, ne = eq;
|
|
||||||
while (ns < ne && header.charAt(ns) == ' ') ns++;
|
|
||||||
while (ne > ns && header.charAt(ne-1) == ' ') ne--;
|
|
||||||
if (ne - ns == name.length() && header.regionMatches(ns, name, 0, name.length()))
|
|
||||||
return header.substring(eq + 1, end).strip();
|
|
||||||
}
|
|
||||||
start = end + 1;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-15
@@ -1,15 +0,0 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OIDC provider endpoints discovered from {@code {issuer}/.well-known/openid-configuration}.
|
|
||||||
*
|
|
||||||
* <p>{@link #endSessionEndpoint()} may be {@code null} — not all providers expose it
|
|
||||||
* (e.g. some Authelia configurations omit it).
|
|
||||||
*/
|
|
||||||
public record OidcProviderMetadata(
|
|
||||||
String authorizationEndpoint,
|
|
||||||
String tokenEndpoint,
|
|
||||||
String userinfoEndpoint,
|
|
||||||
String jwksUri,
|
|
||||||
String endSessionEndpoint // nullable
|
|
||||||
) {}
|
|
||||||
-47
@@ -1,47 +0,0 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An authenticated user's OIDC session — persisted in {@link OidcSessionStore} and
|
|
||||||
* looked up via the {@code oidc_session} cookie on every request.
|
|
||||||
*
|
|
||||||
* <p>Sessions are immutable; a refreshed access token produces a new instance
|
|
||||||
* that replaces the old one in the store (same {@link #id()}).
|
|
||||||
*/
|
|
||||||
public final class OidcSession {
|
|
||||||
|
|
||||||
private final String id;
|
|
||||||
private final String accessToken;
|
|
||||||
private final String idToken;
|
|
||||||
private final String refreshToken; // may be null
|
|
||||||
private final Instant accessTokenExpiresAt;
|
|
||||||
private final Map<String, Object> claims; // decoded from id_token
|
|
||||||
|
|
||||||
public OidcSession(String id, String accessToken, String idToken,
|
|
||||||
String refreshToken, Instant accessTokenExpiresAt,
|
|
||||||
Map<String, Object> claims) {
|
|
||||||
this.id = id;
|
|
||||||
this.accessToken = accessToken;
|
|
||||||
this.idToken = idToken;
|
|
||||||
this.refreshToken = refreshToken;
|
|
||||||
this.accessTokenExpiresAt = accessTokenExpiresAt;
|
|
||||||
this.claims = Map.copyOf(claims);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns {@code true} if the access token has expired or will expire within
|
|
||||||
* the next 30 seconds (eager refresh to avoid mid-request expiry).
|
|
||||||
*/
|
|
||||||
public boolean isAccessTokenExpired() {
|
|
||||||
return Instant.now().isAfter(accessTokenExpiresAt.minusSeconds(30));
|
|
||||||
}
|
|
||||||
|
|
||||||
public String id() { return id; }
|
|
||||||
public String accessToken() { return accessToken; }
|
|
||||||
public String idToken() { return idToken; }
|
|
||||||
public String refreshToken() { return refreshToken; }
|
|
||||||
public Instant accessTokenExpiresAt() { return accessTokenExpiresAt; }
|
|
||||||
public Map<String, Object> claims() { return claims; }
|
|
||||||
}
|
|
||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Backing store for {@link OidcSession} objects. The default implementation is
|
|
||||||
* {@link InMemoryOidcSessionStore}; supply a custom one via
|
|
||||||
* {@link OidcConfig.Builder#sessionStore(OidcSessionStore)} for Redis, JDBC, etc.
|
|
||||||
*/
|
|
||||||
public interface OidcSessionStore {
|
|
||||||
void save(OidcSession session);
|
|
||||||
Optional<OidcSession> find(String sessionId);
|
|
||||||
void delete(String sessionId);
|
|
||||||
}
|
|
||||||
-39
@@ -1,39 +0,0 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Short-lived store mapping state nonces → (original URL, PKCE verifier).
|
|
||||||
*
|
|
||||||
* <p>Entries expire after {@value #TTL_SECONDS} seconds. Cleanup runs on every
|
|
||||||
* access to prevent unbounded growth without needing a background thread.
|
|
||||||
*/
|
|
||||||
final class OidcStateStore {
|
|
||||||
|
|
||||||
static final int TTL_SECONDS = 600; // 10 minutes
|
|
||||||
|
|
||||||
record Entry(String originalUrl, String codeVerifier, String nonce, Instant expiresAt) {}
|
|
||||||
|
|
||||||
private final ConcurrentHashMap<String, Entry> store = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
void put(String state, String originalUrl, String codeVerifier, String nonce) {
|
|
||||||
cleanup();
|
|
||||||
store.put(state, new Entry(originalUrl, codeVerifier, nonce,
|
|
||||||
Instant.now().plusSeconds(TTL_SECONDS)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Atomically retrieves and removes the entry; returns empty if absent or expired. */
|
|
||||||
Optional<Entry> consumeAndRemove(String nonce) {
|
|
||||||
cleanup();
|
|
||||||
Entry e = store.remove(nonce);
|
|
||||||
if (e == null || Instant.now().isAfter(e.expiresAt())) return Optional.empty();
|
|
||||||
return Optional.of(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void cleanup() {
|
|
||||||
Instant now = Instant.now();
|
|
||||||
store.entrySet().removeIf(kv -> now.isAfter(kv.getValue().expiresAt()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
|
||||||
|
|
||||||
/** Parsed response from an OAuth2 token endpoint. Package-private — internal use only. */
|
|
||||||
record OidcTokenResponse(
|
|
||||||
String accessToken,
|
|
||||||
String idToken, // may be null on refresh if provider omits it
|
|
||||||
String refreshToken, // may be null
|
|
||||||
int expiresIn,
|
|
||||||
int refreshExpiresIn
|
|
||||||
) {}
|
|
||||||
-237
@@ -1,237 +0,0 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Type-safe view over the JWT claims stored in {@link ClaimsHolder}.
|
|
||||||
*
|
|
||||||
* <p>Obtainable from any protected context via {@link ClaimsHolder#user()}.
|
|
||||||
* Class-based handlers that extend the {@code SessionHandler} hierarchy already
|
|
||||||
* have a provisioned DB user in {@code currentUser}; {@code OidcUser} complements
|
|
||||||
* that by giving access to the raw OIDC claims when needed, and is the primary
|
|
||||||
* API for lambda routes.
|
|
||||||
*
|
|
||||||
* <pre>{@code
|
|
||||||
* // Lambda route (OidcMiddleware injected):
|
|
||||||
* app.get("/api/whoami", (req, res) -> {
|
|
||||||
* OidcUser u = ClaimsHolder.user();
|
|
||||||
* return Map.of("sub", u.sub(), "email", u.email(), "roles", u.roles("realm_access.roles"), "scopes", u.scopes());
|
|
||||||
* }, oidcMw.protect());
|
|
||||||
*
|
|
||||||
* // Class-based handler (currentUser is the DB entity; oidcUser() for raw claims):
|
|
||||||
* protected Object handleAuthenticated(Request req, Response res) throws Exception {
|
|
||||||
* OidcUser u = oidcUser(); // same as ClaimsHolder.user()
|
|
||||||
* return json(res, currentUser); // DB entity — provisioned from OIDC sub
|
|
||||||
* }
|
|
||||||
* }</pre>
|
|
||||||
*/
|
|
||||||
public final class OidcUser {
|
|
||||||
|
|
||||||
private final Map<String, Object> claims;
|
|
||||||
|
|
||||||
OidcUser(Map<String, Object> claims) {
|
|
||||||
this.claims = claims;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Common OIDC standard claims ───────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Subject identifier — unique, stable user ID issued by the provider. */
|
|
||||||
public String sub() { return str("sub"); }
|
|
||||||
|
|
||||||
/** User's email address ({@code email} claim). */
|
|
||||||
public String email() { return str("email"); }
|
|
||||||
|
|
||||||
/** Human-readable username ({@code preferred_username} claim). */
|
|
||||||
public String username() { return str("preferred_username"); }
|
|
||||||
|
|
||||||
/** Full display name ({@code name} claim). */
|
|
||||||
public String name() { return str("name"); }
|
|
||||||
|
|
||||||
// ── Roles ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts the roles list by traversing a dot-separated claim path.
|
|
||||||
*
|
|
||||||
* <p>Example paths:
|
|
||||||
* <ul>
|
|
||||||
* <li>{@code "realm_access.roles"} — Keycloak realm roles</li>
|
|
||||||
* <li>{@code "resource_access.my-client.roles"} — Keycloak client roles</li>
|
|
||||||
* <li>{@code "groups"} — Authelia / generic IdPs</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* @return list of role strings, or an empty list if the path doesn't exist
|
|
||||||
*/
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
public List<String> roles(String claimPath) {
|
|
||||||
String[] parts = claimPath.split("\\.");
|
|
||||||
Object current = claims;
|
|
||||||
for (String part : parts) {
|
|
||||||
if (!(current instanceof Map<?, ?> m)) return List.of();
|
|
||||||
current = m.get(part);
|
|
||||||
}
|
|
||||||
if (current instanceof List<?> list)
|
|
||||||
return list.stream().map(Object::toString).toList();
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns {@code true} if the user holds {@code role} at the given claim path. */
|
|
||||||
public boolean hasRole(String claimPath, String role) {
|
|
||||||
return roles(claimPath).contains(role);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Scopes ---------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves OAuth2 scopes from standard OIDC/OAuth claims using fallback order:
|
|
||||||
* {@code scope} then {@code scp}. Supports both space-separated string and list forms.
|
|
||||||
*/
|
|
||||||
public List<String> scopes() {
|
|
||||||
return scopes("scope,scp");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves scopes from comma-separated claim paths (example: {@code "scope,scp,permissions.scopes"}).
|
|
||||||
*/
|
|
||||||
public List<String> scopes(String claimPaths) {
|
|
||||||
List<String> out = new ArrayList<>();
|
|
||||||
for (String[] path : splitClaimPaths(claimPaths)) {
|
|
||||||
Object value = valueAtPath(path);
|
|
||||||
if (value == null) continue;
|
|
||||||
if (value instanceof String s) {
|
|
||||||
appendDelimitedTokens(out, s);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (value instanceof List<?> list) {
|
|
||||||
for (Object item : list) {
|
|
||||||
if (item == null) continue;
|
|
||||||
String token = item.toString().trim();
|
|
||||||
if (!token.isEmpty()) out.add(token);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
String token = value.toString().trim();
|
|
||||||
if (!token.isEmpty()) out.add(token);
|
|
||||||
}
|
|
||||||
return out.isEmpty() ? List.of() : List.copyOf(out);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns {@code true} if the user has {@code scope}, searching default claim paths {@code scope,scp}. */
|
|
||||||
public boolean hasScope(String scope) {
|
|
||||||
return hasScope("scope,scp", scope);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns {@code true} if the user has {@code scope} in any of {@code claimPaths}. */
|
|
||||||
public boolean hasScope(String claimPaths, String scope) {
|
|
||||||
if (scope == null || scope.isBlank()) return false;
|
|
||||||
String target = scope.trim();
|
|
||||||
for (String[] path : splitClaimPaths(claimPaths)) {
|
|
||||||
Object value = valueAtPath(path);
|
|
||||||
if (value == null) continue;
|
|
||||||
if (value instanceof String s && containsDelimitedToken(s, target)) return true;
|
|
||||||
if (value instanceof List<?> list) {
|
|
||||||
for (Object item : list) {
|
|
||||||
if (item == null) continue;
|
|
||||||
if (target.equals(item.toString().trim())) return true;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (target.equals(value.toString().trim())) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Arbitrary claim access ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the value of any claim, cast to {@code T}.
|
|
||||||
*
|
|
||||||
* @throws ClassCastException if the stored value is not assignable to {@code type}
|
|
||||||
*/
|
|
||||||
public <T> T claim(String key, Class<T> type) {
|
|
||||||
return type.cast(claims.get(key));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the raw claim value, or {@code null} if absent. */
|
|
||||||
public Object claim(String key) { return claims.get(key); }
|
|
||||||
|
|
||||||
/** Escape hatch — returns the full unmodified claims map. */
|
|
||||||
public Map<String, Object> claims() { return claims; }
|
|
||||||
|
|
||||||
// ── Internals ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private String str(String key) {
|
|
||||||
Object v = claims.get(key);
|
|
||||||
return v != null ? v.toString() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Object valueAtPath(String[] path) {
|
|
||||||
Object current = claims;
|
|
||||||
for (String part : path) {
|
|
||||||
if (!(current instanceof Map<?, ?> m)) return null;
|
|
||||||
current = m.get(part);
|
|
||||||
if (current == null) return null;
|
|
||||||
}
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String[][] splitClaimPaths(String claimPaths) {
|
|
||||||
String source = (claimPaths == null || claimPaths.isBlank()) ? "scope,scp" : claimPaths;
|
|
||||||
List<String[]> out = new ArrayList<>(4);
|
|
||||||
int start = 0;
|
|
||||||
int len = source.length();
|
|
||||||
for (int i = 0; i <= len; i++) {
|
|
||||||
if (i == len || source.charAt(i) == ',') {
|
|
||||||
String raw = source.substring(start, i).trim();
|
|
||||||
if (!raw.isEmpty()) out.add(splitPath(raw));
|
|
||||||
start = i + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out.isEmpty() ? new String[][]{ splitPath("scope"), splitPath("scp") } : out.toArray(String[][]::new);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String[] splitPath(String path) {
|
|
||||||
List<String> out = new ArrayList<>(4);
|
|
||||||
int start = 0;
|
|
||||||
int len = path.length();
|
|
||||||
for (int i = 0; i <= len; i++) {
|
|
||||||
if (i == len || path.charAt(i) == '.') {
|
|
||||||
String raw = path.substring(start, i).trim();
|
|
||||||
if (!raw.isEmpty()) out.add(raw);
|
|
||||||
start = i + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out.isEmpty() ? new String[]{ path } : out.toArray(String[]::new);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void appendDelimitedTokens(List<String> target, String source) {
|
|
||||||
int len = source.length();
|
|
||||||
int i = 0;
|
|
||||||
while (i < len) {
|
|
||||||
while (i < len && isDelimiter(source.charAt(i))) i++;
|
|
||||||
int start = i;
|
|
||||||
while (i < len && !isDelimiter(source.charAt(i))) i++;
|
|
||||||
if (i > start) target.add(source.substring(start, i));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean containsDelimitedToken(String source, String token) {
|
|
||||||
int len = source.length();
|
|
||||||
int i = 0;
|
|
||||||
while (i < len) {
|
|
||||||
while (i < len && isDelimiter(source.charAt(i))) i++;
|
|
||||||
int start = i;
|
|
||||||
while (i < len && !isDelimiter(source.charAt(i))) i++;
|
|
||||||
int end = i;
|
|
||||||
if (end > start && end - start == token.length() && source.regionMatches(start, token, 0, token.length())) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isDelimiter(char c) {
|
|
||||||
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
|
||||||
|
|
||||||
import dev.relism.flash.exceptions.HttpException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thrown when OIDC token validation fails (signature, claims, nonce, expiry, etc.).
|
|
||||||
* Distinct from {@link HttpException}: this signals a protocol-level
|
|
||||||
* failure, not an HTTP response — callers decide the appropriate status code.
|
|
||||||
*/
|
|
||||||
public final class OidcValidationException extends RuntimeException {
|
|
||||||
public OidcValidationException(String message, Throwable cause) {
|
|
||||||
super(message, cause);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-36
@@ -1,36 +0,0 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.security.SecureRandom;
|
|
||||||
import java.util.Base64;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PKCE (RFC 7636) utilities: code verifier generation and S256 challenge computation.
|
|
||||||
* Package-private — used exclusively by {@link OidcExtension}.
|
|
||||||
*/
|
|
||||||
final class PkceUtils {
|
|
||||||
|
|
||||||
private static final SecureRandom RANDOM = new SecureRandom();
|
|
||||||
|
|
||||||
private PkceUtils() {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a cryptographically random code verifier (43 URL-safe characters,
|
|
||||||
* per RFC 7636 §4.1 — 32 bytes encoded as unpadded Base64URL).
|
|
||||||
*/
|
|
||||||
static String generateVerifier() {
|
|
||||||
byte[] bytes = new byte[32];
|
|
||||||
RANDOM.nextBytes(bytes);
|
|
||||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Computes the S256 code challenge: {@code BASE64URL(SHA-256(ASCII(verifier)))}.
|
|
||||||
*/
|
|
||||||
static String computeChallenge(String verifier) throws Exception {
|
|
||||||
byte[] digest = MessageDigest.getInstance("SHA-256")
|
|
||||||
.digest(verifier.getBytes(StandardCharsets.US_ASCII));
|
|
||||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
|
||||||
|
|
||||||
import java.lang.annotation.ElementType;
|
|
||||||
import java.lang.annotation.Retention;
|
|
||||||
import java.lang.annotation.RetentionPolicy;
|
|
||||||
import java.lang.annotation.Target;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restricts a handler to callers whose JWT contains at least one of the
|
|
||||||
* specified roles. Authentication is implicitly required — no need to combine
|
|
||||||
* with {@link Authenticated}.
|
|
||||||
*
|
|
||||||
* <p>Roles are read from the claim configured in {@link OidcConfig#rolesClaimPath()}
|
|
||||||
* (default: {@code "roles"}). Nested paths like {@code "realm_access.roles"} are
|
|
||||||
* supported with dot notation.
|
|
||||||
*
|
|
||||||
* <pre>{@code
|
|
||||||
* @Route(method = HttpMethod.DELETE, path = "/api/admin/blogs/{id}")
|
|
||||||
* @RolesAllowed("admin")
|
|
||||||
* public class DeleteBlog extends JacksonHandler { ... }
|
|
||||||
*
|
|
||||||
* // Multiple accepted roles (OR semantics — any one role is sufficient):
|
|
||||||
* @RolesAllowed({"admin", "editor"})
|
|
||||||
* public class UpdateBlog extends JacksonHandler { ... }
|
|
||||||
* }</pre>
|
|
||||||
*/
|
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
|
||||||
@Target(ElementType.TYPE)
|
|
||||||
public @interface RolesAllowed {
|
|
||||||
/** One or more role names. Access is granted if the caller has any of them. */
|
|
||||||
String[] value();
|
|
||||||
}
|
|
||||||
-45
@@ -1,45 +0,0 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
|
||||||
|
|
||||||
import java.lang.annotation.ElementType;
|
|
||||||
import java.lang.annotation.Retention;
|
|
||||||
import java.lang.annotation.RetentionPolicy;
|
|
||||||
import java.lang.annotation.Target;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restricts a handler to callers whose token carries the required OAuth2 scopes.
|
|
||||||
* Authentication is implicitly required.
|
|
||||||
*
|
|
||||||
* <p>Scopes are resolved from the configured claim paths in
|
|
||||||
* {@link OidcConfig#scopeClaimPaths()} (default: {@code "scope,scp"}) and support
|
|
||||||
* both standard formats:
|
|
||||||
* <ul>
|
|
||||||
* <li>{@code scope}: space-separated string</li>
|
|
||||||
* <li>{@code scp}: string list (or string)</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <pre>{@code
|
|
||||||
* @Route(method = HttpMethod.GET, path = "/api/orders")
|
|
||||||
* @ScopesAllowed("orders:read")
|
|
||||||
* public class ListOrders extends JacksonHandler { ... }
|
|
||||||
*
|
|
||||||
* @Route(method = HttpMethod.POST, path = "/api/orders")
|
|
||||||
* @ScopesAllowed(value = {"orders:write", "payments:write"}, match = ScopesAllowed.Match.ANY)
|
|
||||||
* public class CreateOrder extends JacksonHandler { ... }
|
|
||||||
* }</pre>
|
|
||||||
*/
|
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
|
||||||
@Target(ElementType.TYPE)
|
|
||||||
public @interface ScopesAllowed {
|
|
||||||
/** Required scopes. */
|
|
||||||
String[] value();
|
|
||||||
|
|
||||||
/** Matching mode for {@link #value()}. */
|
|
||||||
Match match() default Match.ALL;
|
|
||||||
|
|
||||||
enum Match {
|
|
||||||
/** Any one required scope is sufficient. */
|
|
||||||
ANY,
|
|
||||||
/** All required scopes must be present. */
|
|
||||||
ALL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-112
@@ -1,112 +0,0 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
|
||||||
|
|
||||||
import net.minidev.json.JSONValue;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.URLEncoder;
|
|
||||||
import java.net.http.HttpClient;
|
|
||||||
import java.net.http.HttpRequest;
|
|
||||||
import java.net.http.HttpResponse;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.Base64;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HTTP client for OAuth2 token endpoint operations (pure HTTP, no SDK).
|
|
||||||
*
|
|
||||||
* <p>Supports two client authentication methods (RFC 6749 §2.3):
|
|
||||||
* <ul>
|
|
||||||
* <li>{@link ClientAuthMethod#POST} — credentials in form body ({@code client_secret_post})</li>
|
|
||||||
* <li>{@link ClientAuthMethod#BASIC} — credentials in {@code Authorization: Basic} header
|
|
||||||
* ({@code client_secret_basic})</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
final class TokenClient {
|
|
||||||
|
|
||||||
private final HttpClient http;
|
|
||||||
private final String clientId;
|
|
||||||
private final String clientSecret;
|
|
||||||
private final ClientAuthMethod authMethod;
|
|
||||||
|
|
||||||
TokenClient(HttpClient http, OidcConfig config) {
|
|
||||||
this.http = http;
|
|
||||||
this.clientId = config.clientId();
|
|
||||||
this.clientSecret = config.clientSecret();
|
|
||||||
this.authMethod = config.clientAuthMethod();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Authorization Code + PKCE exchange. */
|
|
||||||
OidcTokenResponse exchangeCode(String tokenEndpoint,
|
|
||||||
String code, String redirectUri,
|
|
||||||
String codeVerifier) throws Exception {
|
|
||||||
Map<String, String> params = new LinkedHashMap<>();
|
|
||||||
params.put("grant_type", "authorization_code");
|
|
||||||
params.put("code", code);
|
|
||||||
params.put("redirect_uri", redirectUri);
|
|
||||||
params.put("code_verifier", codeVerifier);
|
|
||||||
return post(tokenEndpoint, params);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Refresh token grant. */
|
|
||||||
OidcTokenResponse refresh(String tokenEndpoint, String refreshToken) throws Exception {
|
|
||||||
Map<String, String> params = new LinkedHashMap<>();
|
|
||||||
params.put("grant_type", "refresh_token");
|
|
||||||
params.put("refresh_token", refreshToken);
|
|
||||||
return post(tokenEndpoint, params);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Internals ------------------------------------------------------------
|
|
||||||
|
|
||||||
private OidcTokenResponse post(String url, Map<String, String> params) throws Exception {
|
|
||||||
HttpRequest.Builder req = HttpRequest.newBuilder()
|
|
||||||
.uri(URI.create(url))
|
|
||||||
.header("Content-Type", "application/x-www-form-urlencoded");
|
|
||||||
|
|
||||||
if (authMethod == ClientAuthMethod.BASIC) {
|
|
||||||
String creds = Base64.getEncoder().encodeToString(
|
|
||||||
(clientId + ":" + clientSecret).getBytes(StandardCharsets.UTF_8));
|
|
||||||
req.header("Authorization", "Basic " + creds);
|
|
||||||
} else {
|
|
||||||
params.put("client_id", clientId);
|
|
||||||
params.put("client_secret", clientSecret);
|
|
||||||
}
|
|
||||||
|
|
||||||
HttpResponse<String> resp = http.send(
|
|
||||||
req.POST(HttpRequest.BodyPublishers.ofString(form(params))).build(),
|
|
||||||
HttpResponse.BodyHandlers.ofString());
|
|
||||||
|
|
||||||
if (resp.statusCode() < 200 || resp.statusCode() >= 300)
|
|
||||||
throw new IllegalStateException(
|
|
||||||
"Token endpoint [" + resp.statusCode() + "]: " + resp.body());
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> json = (Map<String, Object>) JSONValue.parse(resp.body());
|
|
||||||
|
|
||||||
return new OidcTokenResponse(
|
|
||||||
(String) json.get("access_token"),
|
|
||||||
(String) json.get("id_token"),
|
|
||||||
(String) json.get("refresh_token"),
|
|
||||||
numInt(json, "expires_in", 300),
|
|
||||||
numInt(json, "refresh_expires_in", 1800)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String form(Map<String, String> params) {
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
params.forEach((k, v) -> {
|
|
||||||
if (!sb.isEmpty()) sb.append('&');
|
|
||||||
sb.append(enc(k)).append('=').append(enc(v));
|
|
||||||
});
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String enc(String v) {
|
|
||||||
return URLEncoder.encode(v, StandardCharsets.UTF_8);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int numInt(Map<String, Object> m, String key, int def) {
|
|
||||||
Object v = m.get(key);
|
|
||||||
return v instanceof Number n ? n.intValue() : def;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-94
@@ -1,94 +0,0 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
|
|
||||||
class OidcAuthPolicyTest {
|
|
||||||
|
|
||||||
static class PlainHandler {}
|
|
||||||
|
|
||||||
@Authenticated
|
|
||||||
static class AuthenticatedHandler {}
|
|
||||||
|
|
||||||
@Authenticated(optional = true)
|
|
||||||
static class OptionalHandler {}
|
|
||||||
|
|
||||||
@RolesAllowed({"admin", " editor ", "admin"})
|
|
||||||
static class RolesHandler {}
|
|
||||||
|
|
||||||
@ScopesAllowed(value = {"orders:write", " payments:write ", "orders:write"}, match = ScopesAllowed.Match.ANY)
|
|
||||||
static class ScopesHandler {}
|
|
||||||
|
|
||||||
@Authenticated
|
|
||||||
@RolesAllowed("admin")
|
|
||||||
@ScopesAllowed(value = {"orders:read", "payments:read"}, match = ScopesAllowed.Match.ALL)
|
|
||||||
static class CombinedHandler {}
|
|
||||||
|
|
||||||
@Authenticated(optional = true)
|
|
||||||
@ScopesAllowed("orders:read")
|
|
||||||
static class InvalidOptionalHandler {}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void compileFromAnnotations_noSecurityAnnotations_returnsNull() {
|
|
||||||
assertNull(OidcAuthPolicy.compileFromAnnotations(PlainHandler.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void compileFromAnnotations_authenticated_createsRequiredAuthPolicy() {
|
|
||||||
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(AuthenticatedHandler.class);
|
|
||||||
assertNotNull(policy);
|
|
||||||
assertFalse(policy.optionalAuth());
|
|
||||||
assertEquals(0, policy.requiredRoles().length);
|
|
||||||
assertEquals(0, policy.requiredScopes().length);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void compileFromAnnotations_optionalAuth_createsOptionalPolicy() {
|
|
||||||
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(OptionalHandler.class);
|
|
||||||
assertNotNull(policy);
|
|
||||||
assertTrue(policy.optionalAuth());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void compileFromAnnotations_rolesAndScopes_areNormalizedAndMerged() {
|
|
||||||
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(CombinedHandler.class);
|
|
||||||
assertNotNull(policy);
|
|
||||||
assertFalse(policy.optionalAuth());
|
|
||||||
assertArrayEquals(new String[]{"admin"}, policy.requiredRoles());
|
|
||||||
assertArrayEquals(new String[]{"orders:read", "payments:read"}, policy.requiredScopes());
|
|
||||||
assertEquals(ScopesAllowed.Match.ALL, policy.scopeMatch());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void compileFromAnnotations_scopesAny_preservesMatchModeAndDedupes() {
|
|
||||||
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(ScopesHandler.class);
|
|
||||||
assertNotNull(policy);
|
|
||||||
assertArrayEquals(new String[]{"orders:write", "payments:write"}, policy.requiredScopes());
|
|
||||||
assertEquals(ScopesAllowed.Match.ANY, policy.scopeMatch());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void compileFromAnnotations_optionalCannotBeCombinedWithConstraints() {
|
|
||||||
assertThrows(IllegalStateException.class,
|
|
||||||
() -> OidcAuthPolicy.compileFromAnnotations(InvalidOptionalHandler.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void openApiScopesFor_returnsScopesWhenPresent() {
|
|
||||||
assertEquals(List.of("orders:write", "payments:write"),
|
|
||||||
OidcAuthPolicy.openApiScopesFor(ScopesHandler.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void openApiScopesFor_rolesOnly_returnsEmptyList() {
|
|
||||||
assertEquals(List.of(), OidcAuthPolicy.openApiScopesFor(RolesHandler.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void openApiScopesFor_noSecurity_returnsNull() {
|
|
||||||
assertNull(OidcAuthPolicy.openApiScopesFor(PlainHandler.class));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-72
@@ -1,72 +0,0 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
|
|
||||||
class OidcMiddlewareAuthzTest {
|
|
||||||
|
|
||||||
private static OidcMiddleware middleware(String rolesPath, String scopePaths) {
|
|
||||||
OidcConfig cfg = OidcConfig.builder("https://idp.example.com", "client", "secret", "/auth/callback")
|
|
||||||
.rolesClaimPath(rolesPath)
|
|
||||||
.scopeClaimPaths(scopePaths)
|
|
||||||
.build();
|
|
||||||
return new OidcMiddleware(null, cfg, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void rolesAllowed_readsConfiguredNestedClaimPath() {
|
|
||||||
OidcMiddleware mw = middleware("realm_access.roles", "scope,scp");
|
|
||||||
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("user", "admin")));
|
|
||||||
|
|
||||||
assertTrue(mw.rolesAllowed(claims, new String[]{"admin"}));
|
|
||||||
assertFalse(mw.rolesAllowed(claims, new String[]{"ops"}));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void scopesAllowed_all_requiresEveryScope() {
|
|
||||||
OidcMiddleware mw = middleware("roles", "scope,scp");
|
|
||||||
Map<String, Object> claims = Map.of("scope", "openid profile orders:read");
|
|
||||||
|
|
||||||
assertTrue(mw.scopesAllowed(claims, new String[]{"openid", "orders:read"}, ScopesAllowed.Match.ALL));
|
|
||||||
assertFalse(mw.scopesAllowed(claims, new String[]{"openid", "orders:write"}, ScopesAllowed.Match.ALL));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void scopesAllowed_any_acceptsAnyConfiguredScopeSource() {
|
|
||||||
OidcMiddleware mw = middleware("roles", "scope,scp,permissions.scopes");
|
|
||||||
Map<String, Object> claims = Map.of(
|
|
||||||
"scp", List.of("payments:write"),
|
|
||||||
"permissions", Map.of("scopes", "orders:approve")
|
|
||||||
);
|
|
||||||
|
|
||||||
assertTrue(mw.scopesAllowed(claims, new String[]{"orders:approve", "orders:read"}, ScopesAllowed.Match.ANY));
|
|
||||||
assertTrue(mw.scopesAllowed(claims, new String[]{"payments:write"}, ScopesAllowed.Match.ANY));
|
|
||||||
assertFalse(mw.scopesAllowed(claims, new String[]{"unknown"}, ScopesAllowed.Match.ANY));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void extractBearerToken_acceptsCaseInsensitiveBearerAndTrimsSpaces() {
|
|
||||||
assertEquals("abc.def.ghi", OidcMiddleware.extractBearerToken("Bearer abc.def.ghi"));
|
|
||||||
assertEquals("abc", OidcMiddleware.extractBearerToken(" bearer abc "));
|
|
||||||
assertNull(OidcMiddleware.extractBearerToken("Basic Zm9vOmJhcg=="));
|
|
||||||
assertNull(OidcMiddleware.extractBearerToken("Bearer"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void bearerChallenge_containsRealmAndRfcErrors() {
|
|
||||||
OidcMiddleware mw = middleware("roles", "scope,scp");
|
|
||||||
|
|
||||||
String basic = mw.bearerChallenge();
|
|
||||||
String invalid = mw.invalidTokenChallenge();
|
|
||||||
String insufficient = mw.insufficientScopeChallenge(new String[]{"orders:read", "payments:write"});
|
|
||||||
|
|
||||||
assertTrue(basic.startsWith("Bearer realm=\""));
|
|
||||||
assertTrue(invalid.contains("error=\"invalid_token\""));
|
|
||||||
assertTrue(insufficient.contains("error=\"insufficient_scope\""));
|
|
||||||
assertTrue(insufficient.contains("scope=\"orders:read payments:write\""));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user