diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
deleted file mode 100644
index e17a3155a..000000000
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ /dev/null
@@ -1,21 +0,0 @@
----
-name: Bug report
-about: Submit a bug report
-title: ''
-labels: ''
-assignees: ''
-
----
-
-Make sure the title of the issue explains the problem you are having. Also, the description of the issue must clearly explain what is broken, not what you want us to implement. Go through this checklist and make sure you answer "YES" to all points:
-
- - You have all pre-requisites listed in README.md installed
- - You are sure that you are not reporting a duplicate (search all issues)
- - You say "is broken" or "doesn't work" in the title
- - You tell us what you are trying to do
- - You explain the results you are getting
- - You suggest an alternative result you would like to see
-
-This article will help you understand what we are looking for: http://www.yegor256.com/2014/11/24/principles-of-bug-tracking.html
-
-Thank you for your contribution!
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
deleted file mode 100644
index 397985e1b..000000000
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-name: Feature request
-about: Suggest an idea for this project
-title: ''
-labels: enhancement
-assignees: ''
-
----
-
-Make sure the title of the ticket explains the feature you are proposing. Go through this checklist before submitting the feature:
- - It's a feature request, not bug report. For bug reports use another template.
- - Is your feature request related to a problem? Please provide a clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
-- Describe the solution you'd like: a clear and concise description of what you want to happen.
-- Describe alternatives you've considered: a clear and concise description of any alternative solutions or features you've considered.
-- Additional context: add any other context or screenshots about the feature request here.
diff --git a/.github/workflows/ci-checks.yml b/.github/workflows/ci-checks.yml
deleted file mode 100644
index 683e6f7f8..000000000
--- a/.github/workflows/ci-checks.yml
+++ /dev/null
@@ -1,53 +0,0 @@
----
-name: CI checks
-"on":
- push:
- branches:
- - master
- pull_request:
- branches:
- - master
-jobs:
- maven-build:
- runs-on: ${{ matrix.os }}
- strategy:
- matrix:
- os: [ubuntu-latest, windows-latest]
- steps:
- - uses: actions/checkout@v2
- - name: Download Linux JDK
- if: ${{ matrix.os == 'ubuntu-latest' }}
- run: |
- curl --silent -o ${{ runner.temp }}/jdk-21_linux-x64_bin.tar.gz \
- https://download.oracle.com/java/21/latest/jdk-21_linux-x64_bin.tar.gz
- - name: Set up Linux JDK
- uses: actions/setup-java@v2
- if: ${{ matrix.os == 'ubuntu-latest' }}
- with:
- distribution: jdkfile
- jdkFile: ${{ runner.temp }}/jdk-21_linux-x64_bin.tar.gz
- java-version: 21
- - name: Download Windows JDK
- if: ${{ matrix.os == 'windows-latest' }}
- run: |
- curl --silent -o ${{ runner.temp }}/jdk-21_windows-x64_bin.zip https://download.oracle.com/java/21/latest/jdk-21_windows-x64_bin.zip
- - name: Set up Windows JDK
- uses: actions/setup-java@v2
- if: ${{ matrix.os == 'windows-latest' }}
- with:
- distribution: jdkfile
- jdkFile: ${{ runner.temp }}/jdk-21_windows-x64_bin.zip
- java-version: 21
- - uses: actions/cache@v1
- with:
- path: ~/.m2/repository
- key: ${{ runner.os }}-jdk-21-maven-${{ hashFiles('**/pom.xml') }}
- restore-keys: |
- ${{ runner.os }}-jdk-21-maven-
- - name: Build it with Maven
- run: mvn -B install -Pqulice
- xcop-lint:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v2
- - uses: g4s8/xcop-action@master
diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml
deleted file mode 100644
index a19f7dc00..000000000
--- a/.github/workflows/docker-release.yml
+++ /dev/null
@@ -1,93 +0,0 @@
-name: Release Docker images
-on:
- push:
- tags:
- - "v*"
-jobs:
- docker-publish:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v2
- - uses: actions/setup-java@v2
- with:
- java-version: 21
- distribution: adopt
- - uses: actions/cache@v1
- with:
- path: ~/.m2/repository
- key: ${{ runner.os }}-jdk-21-maven-${{ hashFiles('**/pom.xml') }}
- restore-keys: |
- ${{ runner.os }}-jdk-21-maven-
- runs-on: ubuntu-latest
- - run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- - name: Login to Docker Hub
- uses: docker/login-action@v1
- with:
- username: ${{ secrets.DOCKERHUB_USER }}
- password: ${{ secrets.DOCKERHUB_PASSWORD }}
- - run: mvn versions:set -DnewVersion=${{ env.RELEASE_VERSION }}
- - run: mvn install -DskipTests
- - run: mvn -B deploy -Pdocker-build -Ddocker.image.name=${{ secrets.DOCKERHUB_REPO }} -DskipTests
- working-directory: artipie-main
- - run: mvn versions:set -DnewVersion=latest
- - run: mvn install -DskipTests
- - run: mvn -B deploy -Pdocker-build -Ddocker.image.name=${{ secrets.DOCKERHUB_REPO }} -DskipTests
- working-directory: artipie-main
- - run: mvn package -Pjar-build -DskipTests
- working-directory: artipie-main
- - name: Create Github Release
- id: create_release
- uses: actions/create-release@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- tag_name: ${{ github.ref }}
- release_name: Release ${{ env.RELEASE_VERSION }}
- draft: false
- prerelease: false
- - name: Upload Release Asset
- id: upload-release-asset
- uses: actions/upload-release-asset@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- upload_url: ${{ steps.create_release.outputs.upload_url }}
- asset_path: ./artipie-main/target/artipie-main-latest-jar-with-dependencies.jar
- asset_name: artipie-${{ env.RELEASE_VERSION }}-jar-with-dependencies.jar
- asset_content_type: application/jar
- working-directory: artipie-main
-
- # run-benchmarks:
- # runs-on: ubuntu-latest
- # needs: docker-publish
- # steps:
- # - name: Check out the code
- # uses: actions/checkout@v2
- # with:
- # ref: gh-pages
- # - name: Set env
- # env:
- # ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true'
- # run: echo ::set-env name=RELEASE_VERSION::${GITHUB_REF:10}
- # - name: Run benchmarks
- # id: run_benchmarks
- # uses: artipie/benchmarks@master
- # with:
- # aws-access-key: '${{ secrets.PERF_AWS_ACCESS_KEY }}'
- # aws-secret-key: '${{ secrets.PERF_AWS_SECRET_KEY }}'
- # version: '${{ env.RELEASE_VERSION }}'
- # - name: Commit benchmark results
- # run: |
- # export REPORT=${{ steps.run_benchmarks.outputs.report }}
- # export VERSION=${{ env.RELEASE_VERSION }}
- # mkdir -p benchmarks/$VERSION
- # mv $REPORT benchmarks/$VERSION/
- # git config --local user.email "action@github.com"
- # git config --local user.name "GitHub Action"
- # git add benchmarks/$VERSION/$REPORT
- # git commit -m "Add benchmark results for version=$VERSION"
- # - name: Push benchmark results
- # uses: ad-m/github-push-action@master
- # with:
- # github_token: ${{ secrets.GITHUB_TOKEN }}
- # branch: 'gh-pages'
diff --git a/.github/workflows/docker-ubuntu-release.yml b/.github/workflows/docker-ubuntu-release.yml
deleted file mode 100644
index 667938f6c..000000000
--- a/.github/workflows/docker-ubuntu-release.yml
+++ /dev/null
@@ -1,45 +0,0 @@
-name: Release Docker images
-on:
- push:
- tags:
- - "v*"
-jobs:
- docker-publish:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v2
- - uses: actions/setup-java@v2
- with:
- java-version: 21
- distribution: adopt
- - uses: actions/cache@v1
- with:
- path: ~/.m2/repository
- key: ${{ runner.os }}-jdk-21-maven-${{ hashFiles('**/pom.xml') }}
- restore-keys: |
- ${{ runner.os }}-jdk-21-maven-
- runs-on: ubuntu-latest
- - run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- - name: Login to Docker Hub
- uses: docker/login-action@v1
- with:
- username: ${{ secrets.DOCKERHUB_USER }}
- password: ${{ secrets.DOCKERHUB_PASSWORD }}
- - run: mvn versions:set -DnewVersion=${{ env.RELEASE_VERSION }}
- - run: mvn install -DskipTests
- - run: mvn -B deploy -Pubuntu-docker -DskipTests
- working-directory: artipie-main
- - run: mvn versions:set -DnewVersion=latest
- - run: mvn install -DskipTests
- - run: mvn -B deploy -Pubuntu-docker -DskipTests
- working-directory: artipie-main
- - name: Create Github Release
- id: create_release
- uses: actions/create-release@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- tag_name: ${{ github.ref }}
- release_name: Release ${{ env.RELEASE_VERSION }}
- draft: false
- prerelease: false
diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml
deleted file mode 100644
index 1d0397f10..000000000
--- a/.github/workflows/integration-tests.yml
+++ /dev/null
@@ -1,33 +0,0 @@
----
-name: Integration tests
-"on":
- push:
- branches:
- - master
- pull_request:
- branches:
- - master
-jobs:
- maven-it:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v2
- - name: Download JDK
- run: |
- wget --no-verbose --directory-prefix ${{ runner.temp }} \
- https://download.oracle.com/java/21/latest/jdk-21_linux-x64_bin.tar.gz
- - name: Set up JDK
- uses: actions/setup-java@v2
- with:
- distribution: jdkfile
- jdkFile: ${{ runner.temp }}/jdk-21_linux-x64_bin.tar.gz
- java-version: 21
- - uses: actions/cache@v1
- with:
- path: ~/.m2/repository
- key: ubuntu-latest-jdk-21-maven-${{ hashFiles('**/pom.xml') }}
- restore-keys: |
- ubuntu-latest-jdk-21-maven-
- - run: mvn -B install -Pitcase -pl "!:artipie-main"
- - run: mvn -B verify -Pdocker-build -Pitcase
- working-directory: artipie-main
diff --git a/.github/workflows/maven-adapter-release.yml b/.github/workflows/maven-adapter-release.yml
deleted file mode 100644
index 8cf67c203..000000000
--- a/.github/workflows/maven-adapter-release.yml
+++ /dev/null
@@ -1,71 +0,0 @@
-name: Create Maven adapter release
-on:
- push:
- tags:
- - '*_*'
-jobs:
- build:
- name: Build release
- runs-on: ubuntu-latest
- steps:
- - name: Set env
- run: echo "TAG=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- - uses: winterjung/split@v2
- id: split
- with:
- msg: ${{ env.TAG }}
- separator: '_'
- - uses: actions/checkout@v2.3.3
- - uses: actions/setup-java@v2
- with:
- java-version: 21
- distribution: adopt
- - uses: actions/cache@v2
- with:
- path: ~/.m2/repository
- key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
- restore-keys: |
- ${{ runner.os }}-maven-
- - name: Import GPG key
- uses: crazy-max/ghaction-import-gpg@v3
- with:
- gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }}
- passphrase: ${{ secrets.GPG_PASSPHRASE }}
- - name: Set version
- run: mvn -B versions:set -DnewVersion=${{ steps.split.outputs._1 }} versions:commit
- - name: Create settings.xml
- uses: whelk-io/maven-settings-xml-action@v15
- with:
- servers: |
- [
- {
- "id": "oss.sonatype.org",
- "username": "${{ secrets.SONATYPE_USER }}",
- "password": "${{ secrets.SONATYPE_PASSWORD }}"
- }
- ]
- profiles: |
- [
- {
- "id": "artipie",
- "properties": {
- "gpg.keyname": "${{ secrets.GPG_KEYNAME }}",
- "gpg.passphrase": "${{ secrets.GPG_PASSPHRASE }}"
- }
- }
- ]
- - run: mvn -B install -DskipTests
- - run: mvn deploy -Partipie,publish,sonatype,gpg-sign -DskipTests --errors
- working-directory: ${{steps.split.outputs._0}}
- env:
- MAVEN_OPTS: --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.text=ALL-UNNAMED --add-opens=java.desktop/java.awt.font=ALL-UNNAMED
- - name: Create Github Release
- id: create_release
- uses: actions/create-release@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- tag_name: ${{ github.ref }}
- release_name: Release ${{ env.TAG }}
- draft: false
- prerelease: false
diff --git a/.github/workflows/perftest-client.yml b/.github/workflows/perftest-client.yml
deleted file mode 100644
index 96dacd476..000000000
--- a/.github/workflows/perftest-client.yml
+++ /dev/null
@@ -1,194 +0,0 @@
-name: Run Artipie perftests
-concurrency: perftest_env
-on:
- workflow_run:
- workflows: [Prepare Artipie for perftests]
- types: [ completed ]
-
-jobs:
- perf-test2:
- if: ${{ github.event.workflow_run.conclusion == 'success' }}
- runs-on: [wsl-client]
- steps:
- - uses: actions/checkout@v3
- with:
- ref: ${{ github.event.workflow_run.head_branch }}
- - run: env;git branch -a; git status
- - name: Check connection
- run: . "/opt/.benchvars" && timeout 30 curl -v "http://$SERVER_HOST:$REPO_PORT/bintest"
- - name: Check variables
- run: test ! -f /opt/.benchvars && exit 1;exit 0
- - name: Checkout benchmarks repo
- run: git clone --depth=1 https://github.com/artipie/benchmarks.git
- - name: Checkout benchmarks branch
- run: |
- pwd; ls -lah; git branch -a
- git fetch --all
- git checkout master
- git pull --ff-only
- cd loadtests
- working-directory: benchmarks
- - name: Prepare JMeter
- run: |
- [ ! -s $HOME/apache-jmeter-5.5.tgz ] && wget https://dlcdn.apache.org/jmeter/binaries/apache-jmeter-5.5.tgz -O $HOME/apache-jmeter-5.5.tgz
- tar xf $HOME/apache-jmeter-5.5.tgz
- working-directory: benchmarks/loadtests
- - name: Prepare artifacts repo
- run: time ./prep-maven-dyn.py --total-artifacts 100 && du -ms ./test-data/maven-dyn
- working-directory: benchmarks/loadtests
- - name: Run upload test
- run: |
- . "/opt/.benchvars"
- perfRes="./_perf_results"
- rm -rf "$perfRes" ./root/var/.storage/data
- mkdir -p "$perfRes"
- tests="jmx-files-maven-ul jmx-files-maven-dl jmx-files-ul jmx-files-dl jmx-maven-ul jmx-maven-dl"
- set -x
- for testName in $tests ; do
- echo "Running test $testName for $SERVER_HOST with $REPO_PORT port ..."
- timeout 300 "./$testName.sh" "$SERVER_HOST" "$REPO_PORT" 180 maven-dyn
- DOCKER_HOST="$SERVER_HOST:$DOCKER_PORT" docker exec artipie jcmd 1 GC.run
- sleep 10
- testRes=`readlink -f last_test_result`
- mv -fv "$testRes" "$perfRes/$testName"
- ls -lah "$perfRes/$testName"
- rm -fv last_test_result
- done
- working-directory: benchmarks/loadtests
- - name: Extract JFR log
- run: |
- . "/opt/.benchvars"
- DOCKER_HOST="$SERVER_HOST:$DOCKER_PORT" docker exec artipie jcmd 1 JFR.dump filename=/var/artipie/.storage/data/bintest/artipie.jfr
- sleep 30
- rm -fv artipie.jfr artipie.jfr.tar.xz
- timeout 30 wget "http://$SERVER_HOST:$REPO_PORT/bintest/artipie.jfr"
- tar cJf artipie.jfr.tar.xz artipie.jfr
- working-directory: benchmarks/loadtests
- - name: Uploading results
- env:
- GITHUB_TAG: ${{ github.event.workflow_run.head_branch }}
- run: |
- ls -lah && pwd
- tag="$GITHUB_TAG"
- if [ -z "$tag" ] ; then
- hash=`git rev-parse HEAD`
- tag="$hash"
- echo "Error: tag is empty with commit $hash"
- exit 1
- fi
- . /opt/.benchvars
- cd "benchmarks/loadtests"
-
- perftestsRepo='./perftests_repo'
- rm -rfv "$perftestsRepo"
- dstDir="$perftestsRepo/perftests/$tag"
- mkdir -p "$dstDir"
- perfRes="./_perf_results"
- for t in "$perfRes"/* ; do
- dst="./$dstDir/$(basename $t)"
- mkdir -p "$dst"
- ls -lah "$t"
- cp -fv "$t/statistics.json" "$dst"
- done
- tree "$perftestsRepo"
- ls -lah "$dstDir"
-
- time ./sync_perftests.sh https://central.artipie.com/artipie/benchmarks "${UPLOAD_LOGIN}" "${UPLOAD_PASSWORD}"
-
- git config --global user.name "Perftest Action"
- git config --global user.email "perftest@test.com"
-
- url="https://central.artipie.com/artipie/benchmarks/perftests_repo/jfr/artipie.last.jfr.tar.xz"
- curl -vT "./artipie.jfr.tar.xz" -u"${UPLOAD_LOGIN}:${UPLOAD_PASSWORD}" "$url"
-
- env
- rm -rf "$perfRes"
- echo "Uploaded all test results for tag: $tag; commit: $hash"
- - name: Generating graphs
- working-directory: benchmarks/loadtests
- run: |
- pip3 install packaging==21.3 matplotlib==3.6.3 mdutils==1.6.0
-
- . "/opt/.benchvars"
- rm -rfv ./graphs
- time ./perfplot.py perftests_repo/perftests ./graphs
- for f in ./graphs/* ; do
- echo "$f"
- url="https://central.artipie.com/artipie/benchmarks/perftests_repo/graphs/$(basename $f)"
- echo curl -vT "$f" -u"UPLOAD_LOGIN:UPLOAD_PASSWORD" "$url"
- curl -vT "$f" -u"${UPLOAD_LOGIN}:${UPLOAD_PASSWORD}" "$url"
- done
-
- tmpDir="perftests_repo/tmp"
-
- # For v* tags:
- rm -rfv "$tmpDir"
- mkdir -p "$tmpDir"
- if [ -n "`find perftests_repo/perftests -maxdepth 1 -name 'v*'`" ] ; then
- cp -rfv "perftests_repo/perftests"/v* "$tmpDir"
- rm -rfv ./graphs_v
- time ./perfplot.py "$tmpDir" ./graphs_v
- for f in ./graphs_v/* ; do
- echo "$f"
- url="https://central.artipie.com/artipie/benchmarks/perftests_repo/graphs_v/$(basename $f)"
- echo curl -vT "$f" -u"UPLOAD_LOGIN:UPLOAD_PASSWORD" "$url"
- curl -vT "$f" -u"${UPLOAD_LOGIN}:${UPLOAD_PASSWORD}" "$url"
- done
- else
- echo "No v* tag results in perftests_repo/perftests"
- fi
-
- # For t* tags:
- rm -rfv "$tmpDir"
- mkdir -p "$tmpDir"
- if [ -n "`find perftests_repo/perftests -maxdepth 1 -name 't*'`" ] ; then
- cp -rfv "perftests_repo/perftests"/t* "$tmpDir"
- rm -rfv ./graphs_t
- time ./perfplot.py "$tmpDir" ./graphs_t
- for f in ./graphs_t/* ; do
- echo "$f"
- url="https://central.artipie.com/artipie/benchmarks/perftests_repo/graphs_t/$(basename $f)"
- echo curl -vT "$f" -u"UPLOAD_LOGIN:UPLOAD_PASSWORD" "$url"
- curl -vT "$f" -u"${UPLOAD_LOGIN}:${UPLOAD_PASSWORD}" "$url"
- done
- else
- echo "No t* tag results in perftests_repo/perftests"
- fi
-
- rm -rfv "$tmpDir"
-
- - name: Check performance difference stability
- env:
- GITHUB_TAG: ${{ github.event.workflow_run.head_branch }}
- working-directory: benchmarks/loadtests
- run: |
- tag="$GITHUB_TAG"
- hash=`git rev-parse HEAD`
- if [ -z "$tag" ] ; then
- tag="$hash"
- echo "Error: tag is empty with commit $hash"
- exit 1
- fi
-
- tmpDir="perftests_repo/tmp" # compare with previous tag of the same type
- rm -rfv "$tmpDir"
- mkdir -p "$tmpDir"
- if [[ "$tag" =~ ^v.*$ ]] ; then
- cp -rfv "perftests_repo/perftests"/v* "$tmpDir"
- elif [[ "$tag" =~ ^t.*$ ]] ; then
- cp -rfv "perftests_repo/perftests"/t* "$tmpDir"
- else
- cp -rfv "perftests_repo/perftests"/* "$tmpDir"
- fi
-
- diffLimit="15.0" # wsl client <--> wsl server currently less stable
- ls -lah perftests_repo/perftests
- if ./checkstats.py perftests_repo/perftests "$diffLimit" ; then
- echo "Performance difference withit limit range"
- else
- echo "WARNING: Performance difference out of range. Sending emails..."
- email="Subject: Artipie perftests\n\nArtipie perfrormance testing warning:\n\nPerformance difference is out of range in perftest-client.yml with threshold ${diffLimit}%.
- Please check GitHub Actions logs for Artipie git tag: $tag; commit hash: $hash\n"
- echo -e "$email" | ssmtp artipiebox@gmail.com chgenn.x@gmail.com
- fi
- rm -rf "$tmpDir"
diff --git a/.github/workflows/perftest-server.yml b/.github/workflows/perftest-server.yml
deleted file mode 100644
index c70629a10..000000000
--- a/.github/workflows/perftest-server.yml
+++ /dev/null
@@ -1,208 +0,0 @@
-name: Prepare Artipie for perftests
-concurrency: perftest_env
-on:
- push:
- tags:
- - "t*"
- - "v*"
-
-jobs:
- perf-test1:
- runs-on: [wsl-server]
- steps:
- - uses: actions/checkout@v3
- - run: cat /etc/issue; mount; ls -lah; env; pwd; git status
- - name: Check variables
- run: test ! -f $HOME/.benchvars && exit 1;exit 0
- - name: Prepare docker env
- run: |
- docker info
- docker stop artipie || :
- docker rm -fv artipie || :
- docker image rm -f artipie/artipie:1.0-SNAPSHOT || :
- docker ps
- - name: Maven adapters build
- run: mvn install -DskipTests
- - name: Artipie docker image build
- run: timeout 600 mvn clean install -Pdocker-build -DskipTests
- working-directory: artpie-main
- - name: Check docker image
- run: docker image inspect artipie/artipie:1.0-SNAPSHOT|head -n50
- - name: Checkout results repo/branch
- run: git clone --depth=1 https://github.com/artipie/benchmarks.git
- - name: Branch + Artipie server
- run: |
- pwd; ls -lah; git branch -a
- git fetch
- git checkout master
- git pull --ff-only
- cd loadtests
- ./artipie-snapshot.sh 8081
- sleep 10
- docker ps
- tree ./root
- ls -lah root/var/repo/artipie
- working-directory: benchmarks
- - name: Check connection
- run: . "/opt/.benchvars" && timeout 30 curl -v "http://$SERVER_HOST:$REPO_PORT/bintest"
-
- - name: Prepare JMeter
- run: |
- [ ! -s $HOME/apache-jmeter-5.5.tgz ] && wget https://dlcdn.apache.org/jmeter/binaries/apache-jmeter-5.5.tgz -O $HOME/apache-jmeter-5.5.tgz
- tar xf $HOME/apache-jmeter-5.5.tgz
- working-directory: benchmarks/loadtests
- - name: Prepare artifacts repo
- run: time ./prep-maven-dyn.py --total-artifacts 100 && du -ms ./test-data/maven-dyn
- working-directory: benchmarks/loadtests
- - name: Run upload test
- run: |
- . "/opt/.benchvars"
- perfRes="./_perf_results"
- rm -rf "$perfRes" ./root/var/.storage/data
- mkdir -p "$perfRes"
- tests="jmx-files-maven-ul jmx-files-maven-dl jmx-files-ul jmx-files-dl jmx-maven-ul jmx-maven-dl"
- set -x
- for testName in $tests ; do
- echo "Running test $testName for $SERVER_HOST with $REPO_PORT port ..."
- timeout 300 "./$testName.sh" "$SERVER_HOST" "$REPO_PORT" 180 maven-dyn
- docker exec artipie jcmd 1 GC.run
- sleep 10
- testRes=`readlink -f last_test_result`
- mv -fv "$testRes" "$perfRes/$testName"
- ls -lah "$perfRes/$testName"
- rm -fv last_test_result
- done
- working-directory: benchmarks/loadtests
- - name: Extract JFR log
- run: |
- . "/opt/.benchvars"
- DOCKER_HOST="$SERVER_HOST:$DOCKER_PORT" docker exec artipie jcmd 1 JFR.dump filename=/var/artipie/.storage/data/bintest/artipie.jfr
- sleep 30
- rm -fv artipie.jfr artipie.jfr.tar.xz
- timeout 30 wget "http://$SERVER_HOST:$REPO_PORT/bintest/artipie.jfr"
- tar cJf artipie.jfr.tar.xz artipie.jfr
- working-directory: benchmarks/loadtests
- - name: Uploading results
- run: |
- ls -lah && pwd
- tag="$GITHUB_REF_NAME" # GITHUB_TAG
- if [ -z "$tag" ] ; then
- hash=`git rev-parse HEAD`
- tag="$hash"
- echo "Error: tag is empty with commit $hash"
- exit 1
- fi
- . /opt/.benchvars
- cd "benchmarks/loadtests"
-
- perftestsRepo='./perftests_repo'
- rm -rfv "$perftestsRepo"
- dstDir="$perftestsRepo/perftests/$tag"
- mkdir -p "$dstDir"
- perfRes="./_perf_results"
- for t in "$perfRes"/* ; do
- dst="./$dstDir/$(basename $t)"
- mkdir -p "$dst"
- ls -lah "$t"
- cp -fv "$t/statistics.json" "$dst"
- done
- tree "$perftestsRepo"
- ls -lah "$dstDir"
-
- time ./sync_perftests.sh https://central.artipie.com/artipie/benchmarks/localhost "${UPLOAD_LOGIN}" "${UPLOAD_PASSWORD}"
-
- git config --global user.name "Perftest Action"
- git config --global user.email "perftest@test.com"
-
- url="https://central.artipie.com/artipie/benchmarks/localhost/perftests_repo/jfr/artipie.last.jfr.tar.xz"
- echo SKIPPING curl -vT "./artipie.jfr.tar.xz" -u"${UPLOAD_LOGIN}:${UPLOAD_PASSWORD}" "$url"
-
- env
- rm -rf "$perfRes"
- echo "Uploaded all test results for tag: $tag; commit: $hash"
- - name: Generating graphs
- working-directory: benchmarks/loadtests
- run: |
- pip3 install packaging==21.3 matplotlib==3.6.3 mdutils==1.6.0
-
- . "/opt/.benchvars"
- rm -rfv ./graphs
- time ./perfplot.py perftests_repo/perftests ./graphs
- for f in ./graphs/* ; do
- echo "$f"
- url="https://central.artipie.com/artipie/benchmarks/localhost/perftests_repo/graphs/$(basename $f)"
- echo curl -vT "$f" -u"UPLOAD_LOGIN:UPLOAD_PASSWORD" "$url"
- curl -vT "$f" -u"${UPLOAD_LOGIN}:${UPLOAD_PASSWORD}" "$url"
- done
-
- tmpDir="perftests_repo/tmp"
-
- # For v* tags:
- rm -rfv "$tmpDir"
- mkdir -p "$tmpDir"
- if [ -n "`find perftests_repo/perftests -maxdepth 1 -name 'v*'`" ] ; then
- cp -rfv "perftests_repo/perftests"/v* "$tmpDir"
- rm -rfv ./graphs_v
- time ./perfplot.py "$tmpDir" ./graphs_v
- for f in ./graphs_v/* ; do
- echo "$f"
- url="https://central.artipie.com/artipie/benchmarks/localhost/perftests_repo/graphs_v/$(basename $f)"
- echo curl -vT "$f" -u"UPLOAD_LOGIN:UPLOAD_PASSWORD" "$url"
- curl -vT "$f" -u"${UPLOAD_LOGIN}:${UPLOAD_PASSWORD}" "$url"
- done
- else
- echo "No v* tag results in perftests_repo/perftests"
- fi
-
- # For t* tags:
- rm -rfv "$tmpDir"
- mkdir -p "$tmpDir"
- if [ -n "`find perftests_repo/perftests -maxdepth 1 -name 't*'`" ] ; then
- cp -rfv "perftests_repo/perftests"/t* "$tmpDir"
- rm -rfv ./graphs_t
- time ./perfplot.py "$tmpDir" ./graphs_t
- for f in ./graphs_t/* ; do
- echo "$f"
- url="https://central.artipie.com/artipie/benchmarks/localhost/perftests_repo/graphs_t/$(basename $f)"
- echo curl -vT "$f" -u"UPLOAD_LOGIN:UPLOAD_PASSWORD" "$url"
- curl -vT "$f" -u"${UPLOAD_LOGIN}:${UPLOAD_PASSWORD}" "$url"
- done
- else
- echo "No t* tag results in perftests_repo/perftests"
- fi
-
- rm -rfv "$tmpDir"
-
- - name: Check performance difference stability
- working-directory: benchmarks/loadtests
- run: |
- tag="$GITHUB_REF_NAME" # GITHUB_TAG
- hash=`git rev-parse HEAD`
- if [ -z "$tag" ] ; then
- tag="$hash"
- echo "Error: tag is empty with commit $hash"
- exit 1
- fi
-
- tmpDir="perftests_repo/tmp" # compare with previous tag of the same type
- rm -rfv "$tmpDir"
- mkdir -p "$tmpDir"
- if [[ "$tag" =~ ^v.*$ ]] ; then
- cp -rfv "perftests_repo/perftests"/v* "$tmpDir"
- elif [[ "$tag" =~ ^t.*$ ]] ; then
- cp -rfv "perftests_repo/perftests"/t* "$tmpDir"
- else
- cp -rfv "perftests_repo/perftests"/* "$tmpDir"
- fi
-
- diffLimit="7.0"
- ls -lah perftests_repo/perftests
- if ./checkstats.py perftests_repo/perftests "$diffLimit" ; then
- echo "Performance difference withit limit range"
- else
- echo "WARNING: Performance difference out of range. Sending emails..."
- email="Subject: Artipie perftests\n\nArtipie perfrormance testing warning:\n\nPerformance difference is out of range in perftest-server.yml with threshold ${diffLimit}%.
- Please check GitHub Actions logs for Artipie git tag: $tag; commit hash: $hash\n"
- echo -e "$email" | ssmtp artipiebox@gmail.com chgenn.x@gmail.com
- fi
- rm -rf "$tmpDir"
diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml
deleted file mode 100644
index a663d6deb..000000000
--- a/.github/workflows/smoke-tests.yml
+++ /dev/null
@@ -1,26 +0,0 @@
-name: "Smoke tests"
-on:
- push:
- branches:
- - "master"
- pull_request:
- branches:
- - "master"
-jobs:
- smoke-tests:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v2
- - uses: actions/setup-java@v1
- with:
- java-version: 21
- - uses: actions/cache@v1
- with:
- path: ~/.m2/repository
- key: ${{ runner.os }}-jdk-21-maven-${{ hashFiles('**/pom.xml') }}
- restore-keys: ${{ runner.os }}-jdk-21-maven-
- - run: mvn install -DskipTests
- - run: mvn -B package -Pdocker-build -DskipTests
- working-directory: artipie-main
- - run: examples/run.sh
- working-directory: artipie-main
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
deleted file mode 100644
index 165c6207f..000000000
--- a/.github/workflows/stale.yml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-name: Mark stale issues and pull requests
-on:
- schedule:
- - cron: '00 */6 * * *'
-jobs:
- stale:
- runs-on: ubuntu-latest
- permissions:
- issues: write
- pull-requests: write
- steps:
- - uses: actions/stale@v3
- with:
- repo-token: ${{ secrets.GITHUB_TOKEN }}
- stale-issue-message: 'Issue is stale, CC: @artipie/maintainers'
- stale-pr-message: 'PR is stale, CC: @artipie/maintainers'
- stale-issue-label: 'stale'
- stale-pr-label: 'stale'
- days-before-issue-close: -1
- operations-per-run: 100
diff --git a/.github/workflows/wiki.yml b/.github/workflows/wiki.yml
deleted file mode 100644
index c576af2fe..000000000
--- a/.github/workflows/wiki.yml
+++ /dev/null
@@ -1,16 +0,0 @@
-name: Update Wiki
-on:
- push:
- paths:
- - '.wiki/**'
- branches:
- - master
-jobs:
- update-wiki:
- runs-on: ubuntu-latest
- name: Update wiki
- steps:
- - uses: OrlovM/Wiki-Action@v1
- with:
- path: '.wiki'
- token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index f82117a00..502f01ca1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,15 @@ classes/
.settings/
.storage/data/**
.storage/data/test/**
+artipie-main/docker-compose/artipie/data/**
+artipie-main/docker-compose/artipie/artifacts/npm/node_modules/**
+artipie-main/docker-compose/artipie/cache/**
+artipie-main/docker-compose/artipie/artifacts/php/vendor/**
+
+# Environment files with secrets - never commit these!
+.env
+!.env.example
+artipie-main/docker-compose/.env
+
+# AI agent task/analysis documents - not part of product documentation
+agents/
\ No newline at end of file
diff --git a/.testcontainers.properties b/.testcontainers.properties
new file mode 100644
index 000000000..2ce68f527
--- /dev/null
+++ b/.testcontainers.properties
@@ -0,0 +1,6 @@
+The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+https://github.com/artipie/artipie/blob/master/LICENSE.txt
+
+# Testcontainers configuration for cross-platform compatibility
+testcontainers.reuse.enable=true
+testcontainers.checks.disable=true
diff --git a/.wiki/Configuration-Metadata.md b/.wiki/Configuration-Metadata.md
index 3732d9f1d..ddb7d4d9b 100644
--- a/.wiki/Configuration-Metadata.md
+++ b/.wiki/Configuration-Metadata.md
@@ -1,24 +1,25 @@
-# Artifacts metadata
+# Artifacts metadata (PostgreSQL)
-Artipie can gather uploaded artifacts metadata and write them into [SQLite](https://www.sqlite.org/index.html) database.
-To enable this mechanism, add the following section into Artipie main configuration file:
+Artipie gathers uploaded artifacts metadata and writes them to a PostgreSQL database.
+To enable this, add the following section to the main configuration file (`meta` section):
```yaml
meta:
artifacts_database:
- sqlite_data_file_path: /var/artipie/artifacts.db
- threads_count: 2 # optional, default 1
- interval_seconds: 3 # optional, default 1
+ postgres_host: localhost # required: PostgreSQL host
+ postgres_port: 5432 # optional: default 5432
+ postgres_database: artifacts # required: DB name
+ postgres_user: artipie # required: DB user
+ postgres_password: artipie # required: DB password
+ threads_count: 2 # optional: default 1
+ interval_seconds: 3 # optional: default 1
```
-The essential here is `artifacts_database` section, other fields are optional. If `sqlite_data_file_path` field is absent,
-a database file will be created at the parent location (directory) of the main configuration file. The metadata gathering
-mechanism uses [quartz](http://www.quartz-scheduler.org/) scheduler to process artifacts metadata under the hood. Quartz
-can be [configured separately](http://www.quartz-scheduler.org/documentation/quartz-2.1.7/configuration/ConfigMain.html),
-by default it uses `org.quartz.simpl.SimpleThreadPool` with 10 threads. If `threads_count` is larger than thread pool size,
-threads amount is limited to the thread pool size.
+The metadata writer runs as a Quartz job and periodically flushes queued events to the database.
+Quartz can be configured separately; by default it uses `org.quartz.simpl.SimpleThreadPool` with 10 threads.
+If `threads_count` exceeds the pool size, it is limited by the pool.
-The database has only one table `artifacts` with the following structure:
+The database has a single table `artifacts` with the following structure:
| Name | Type | Description |
|--------------|----------|------------------------------------------|
@@ -31,13 +32,16 @@ The database has only one table `artifacts` with the following structure:
| created_date | datetime | Date uploaded |
| owner | varchar | Artifact uploader login |
-All the fields are not null, unique constraint is created on repo_name, name and version.
+All fields are NOT NULL; a UNIQUE constraint is created on `(repo_name, name, version)`.
+
+Migration note: earlier versions supported SQLite via `sqlite_data_file_path`. This is deprecated in favor of PostgreSQL.
+Please migrate your data and update the configuration to use the `postgres_*` settings.
## Maven, NPM and PyPI proxy adapters
[Maven-proxy](maven-proxy), [npm-proxy](npm-proxy) and [python-proxy](pypi-proxy) have some extra mechanism to process
uploaded artifacts from origin repositories. Generally, the mechanism is a quartz job which verifies uploaded and
-saved to cash storage artifacts and adds metadata common mechanism and database. Proxy adapters metadata gathering is
+saved to cache storage artifacts and adds metadata common mechanism and database. Proxy adapters metadata gathering is
enabled when artifacts database is enabled and proxy repository storage is configured.
It's possible to configure `threads_count` and `interval_seconds` for [Maven-proxy](maven-proxy), [npm-proxy](npm-proxy)
and [python-proxy](pypi-proxy) repositories individually.
diff --git a/.wiki/Configuration-Metrics.md b/.wiki/Configuration-Metrics.md
index 2bfc5401f..1ea772f68 100644
--- a/.wiki/Configuration-Metrics.md
+++ b/.wiki/Configuration-Metrics.md
@@ -32,8 +32,6 @@ Artipie gather the following metrics:
| artipie_response_body_size_bytes | summary | Response body size and chunks | method |
| artipie_request_body_size_bytes | summary | Request body size and chunks | method |
| artipie_request_counter_total | counter | Requests counter | method, status |
-| artipie_response_send_seconds | summary | Response.send execution time | |
-| artipie_connection_accept_seconds | summary | Connection.accept execution time | status |
| artipie_slice_response_seconds | summary | Slice.response execution time | status |
| artipie_storage_value_seconds | summary | Time to read value from storage | id |
| artipie_storage_value_size_bytes | summary | Storage value size and chunks | id |
diff --git a/.wiki/Configuration-Repository.md b/.wiki/Configuration-Repository.md
index 4d4a17343..74cc54680 100644
--- a/.wiki/Configuration-Repository.md
+++ b/.wiki/Configuration-Repository.md
@@ -47,11 +47,12 @@ Detailed configuration for each repository is provided in the corresponding subs
## Single repository on port
Artipie repositories may run on separate ports if configured.
-This feature may be especially useful for Docker repository,
-as it's API is not well suited to serve multiple repositories on single port.
+This feature is especially useful for Docker repositories.
-To run repository on its own port
-`port` parameter should be specified in repository configuration YAML as follows:
+To run a repository on its own port specify the `port` parameter. You can do this
+in YAML or via the REST API (preferred for runtime changes):
+
+YAML example:
```yaml
repo:
@@ -60,9 +61,13 @@ repo:
...
```
-> **Warning**
-> Artipie scans repositories for port configuration only on start,
-> so server requires restart in order to apply changes made in runtime.
+See REST JSON examples in [Rest API](./Rest-api#repository-json-payloads-by-type).
+
+Notes:
+- Repositories created or updated via REST are applied immediately with no restart, including
+ starting new listeners for `port`-bound repositories and enabling HTTP/3 when `http3: true`.
+- If you modify YAML files directly for a repository that binds to a dedicated `port`, a restart
+ may still be required to start the listener. Prefer using the REST API for runtime changes.
## Filters
@@ -138,4 +143,4 @@ This annotation's value should be specified as new pattern type inside `include`
- Assemble jar-file and add it to Artipie's classpath
See examples of classes `GlobFilter`, `GlobFilterFactory`, `RegexpFilter` and `RegexpFilterFactory` in [com.artipie.http.filter](https://github.com/artipie/http/tree/master/src/main/java/com/artipie/http/filter) package.
-
\ No newline at end of file
+
diff --git a/.wiki/Configuration-Scripting.md b/.wiki/Configuration-Scripting.md
index 016ba5b84..da8179011 100644
--- a/.wiki/Configuration-Scripting.md
+++ b/.wiki/Configuration-Scripting.md
@@ -22,9 +22,7 @@ Scrips must have a file extension corresponding to one of the supported scriptin
| Scripting language | File extension |
|--------------------|----------------|
| Groovy | .groovy |
-| Mvel | .mvel |
-| Python | .py |
-| Ruby | .rb |
+| Python 2 | .py |
### Accessing Artipie objects
diff --git a/.wiki/Configuration.md b/.wiki/Configuration.md
index e5210b5ce..2b3041980 100644
--- a/.wiki/Configuration.md
+++ b/.wiki/Configuration.md
@@ -11,6 +11,7 @@ Yaml configuration file contains server meta configuration, such as:
- `configs` - repository config files location, not required, the storage key relative to the
main storage, or, in file system storage terms, subdirectory where repo configs are located relatively to the storage;
- `metrics` - enable and set [metrics collection](./Configuration-Metrics), not required.
+ - `cooldown` - optional cooldown policy for proxy repositories (see below).
Example:
```yaml
@@ -20,9 +21,29 @@ meta:
type: fs
path: /tmp/artipie/configs
configs: repo
+ http_client: # optional, settings for the HttpClient that will be used in xxx-proxy repositories
+ connection_timeout: 25000 # optional, default 15000 ms
+ idle_timeout: 500 # optional, default 0
+ trust_all: true # optional, default false
+ follow_redirects: true # optional, default true
+ http3: true # optional, default false
+ jks: # optional
+ path: /var/artipie/keystore.jks
+ password: secret
+ proxies:
+ - url: http://proxy1.com
+ - url: https://proxy2.com
+ # the HTTP "Basic" authentication defined in RFC 2617
+ realm: user_realm # if this field is defined, then `username` and `password` are mandatory
+ username: user_name
+ password: user_password
credentials:
- type: artipie
- type: env
+ cooldown:
+ enabled: true
+ newer_than_cache_hours: 72
+ fresh_release_hours: 72
policy:
type: artipie
storage:
@@ -62,6 +83,30 @@ Credentials and policy sections are responsible for [user credentials](./Configu
Note that Artipie understands both extensions: `yml` and `yaml`.
+### Cooldown settings
+
+When the optional `cooldown` block is present, Artipie enforces a delay before serving newly
+requested proxy artefacts. The fields control the behaviour:
+
+- `enabled`: toggle the feature on or off.
+- `newer_than_cache_hours`: block window (in hours) when a request targets a version newer than
+ the cached one.
+- `fresh_release_hours`: block window (in hours) for artefacts that have never been cached and were
+ recently released.
+
+Cooldown decisions are persisted to the configured artefacts database. Once a cooldown expires or an
+administrator manually unblocks the artefact via the REST API, that version becomes permanently allowed.
+
+### Http client settings
+The http client settings can be overridden in `xxx-proxy` repository config.
+Proxy servers can be defined by system properties as it's described in [Java documentation](https://docs.oracle.com/javase/8/docs/technotes/guides/net/proxies.html).
+Default values:
+ - connection_timeout: 15_000
+ - idle_timeout: 0
+ - trust_all: false
+ - follow_redirects: true
+ - http3: false
+
## Additional configuration
Here is a list of some additional configurations:
diff --git a/.wiki/DockerCompose.md b/.wiki/DockerCompose.md
index 9a215fdd2..87cea7842 100644
--- a/.wiki/DockerCompose.md
+++ b/.wiki/DockerCompose.md
@@ -3,7 +3,7 @@
Make sure you have already installed both [Docker Engine](https://docs.docker.com/get-docker/) and
[Docker Compose](https://docs.docker.com/compose/install/).
Then, obtain `docker-compose.yaml` file from the repository:
-you can [open it from the browser](https://github.com/artipie/artipie/blob/master/docker-compose.yaml),
+you can [open it from the browser](https://github.com/artipie/artipie/blob/master/artipie-main/docker-compose.yaml),
copy content and save it locally or use [git](https://git-scm.com/) and [clone](https://git-scm.com/docs/git-clone) the repository.
Open command line, `cd` to the location with the compose file and run Artipie service:
@@ -17,6 +17,6 @@ prints to console a list of running repositories, test credentials and a link to
If started on localhost with command above:
* The dashboard URI is `http://localhost:8080/dashboard` and default username and password are `artipie/artipie`.
-* Swagger UI to manage Artipie is available on 'http://localhost:8086/api/index-org.html'. More information about [Rest API](./Rest-api)
-* Artipie server side (repositories) is served on `8081` port and is available on URI `http://localhost:8081/{username}/{reponame}`,
-where `{username}` is the name of the user and `{reponame}` is the name of the repository.
\ No newline at end of file
+* Swagger UI to manage Artipie is available on 'http://localhost:8086/api/index.html'. More information about [Rest API](./Rest-api)
+* Artipie server side (repositories) is served on `8081` port and is available on URI `http://localhost:8081/{reponame}`,
+where `{reponame}` is the name of the repository.
\ No newline at end of file
diff --git a/.wiki/Home.md b/.wiki/Home.md
index 276ebba5f..bc60df87a 100644
--- a/.wiki/Home.md
+++ b/.wiki/Home.md
@@ -5,9 +5,6 @@ Artipie is not just a binary artifact web server -- it's artifact management
constructor which consists of many components built into server assembly.
You have three options for working with Artipie:
- - As a hosted-solution user: Artipie has hosted version at
- https://central.artipie.com - you can sign-up, create repositories
- and deploy artifacts there
- Create self-hosted installation - you can run Artipie Docker image
in private network, install k8s [Helm chart](https://github.com/artipie/helm-charts)
with Artipie or simply run Artipie `jar` file with JVM
@@ -51,18 +48,18 @@ It's time to add a `maven-proxy` repository config file, call it `my-maven.yaml`
```yaml
repo:
type: maven-proxy
+ port: 8085
+ storage:
+ type: fs
+ path: /var/artipie/data
remotes:
- url: https://repo.maven.apache.org/maven2
- cache:
- storage:
- type: fs
- path: /var/artipie/data
```
- field `type` describes repository type, in our case it's `maven-proxy`.
- field `url` points to a remote maven repository.
- field `cache` describes storage to keep artifacts gotten from the remote maven repository.
-Detailed description for every supported repository type can be found [here](https://github.com/artipie/artipie/tree/master/examples).
+Detailed description for every supported repository type can be found [here](https://github.com/artipie/artipie/tree/master/artipie-main/examples).
As long as we defined `/var/artipie/repo` as path for configuration file system storage,
the file `my-maven.yaml` has to be placed on the path `/var/artipie/repo/my-maven.yaml`
@@ -107,4 +104,12 @@ All artifacts obtained through this repository will be stored in the directory `
using structure of folders as it does local maven.
To add a new repository or update an existing repository, you have to simply create or modify repositories
-configuration `yaml` files in the directory `/var/artipie/repo`.
\ No newline at end of file
+configuration `yaml` files in the directory `/var/artipie/repo` or use the [REST API](./Rest-api)
+to create/update repositories dynamically without restart.
+
+## New and notable
+
+- Dynamic repositories: create, update, move and delete repositories at runtime via REST with no restart. See [REST API](./Rest-api).
+- Bearer auth everywhere: all repositories support bearer token authentication for uploads and downloads in addition to Basic auth.
+- PostgreSQL artifacts DB: artifacts metadata are written to PostgreSQL (SQLite support deprecated). See [Artifacts metadata](./Configuration-Metadata).
+- ARM64 support: official Docker images and the service run on `linux/amd64` and `linux/arm64` architectures.
diff --git a/.wiki/Rest-api.md b/.wiki/Rest-api.md
index ec36aeef5..bdf031a9d 100644
--- a/.wiki/Rest-api.md
+++ b/.wiki/Rest-api.md
@@ -9,18 +9,125 @@ between the definitions with the help of "Select a definition" listbox.
-All Rest API endpoints require JWT authentication token to be passed in `Authentification` header.
+All Rest API endpoints require JWT authentication token to be passed in `Authorization: Bearer ` header.
The token can be issued with the help of `POST /api/v1/oauth/token` request on the "Auth Token"
definition page in Swagger. Once token is received, copy it, open another definition, press
"Authorize" button and paste the token. Swagger will add the token to any request you perform.
+Note: The same bearer token can be used to authenticate repository uploads/downloads. All repository
+adapters accept bearer tokens in addition to Basic auth.
+
## Manage repository API
Rest API allows to manage repository settings: read, create, update and remove operations are supported.
Note, that jsons, accepted by Rest endpoints, are equivalents of the YAML repository settings. Which means,
that API accepts all the repository specific settings fields which are applicable to the repository.
Choose repository you are interested in from [this table](./Configuration-Repository#Supported-repository-types)
-to learn all the details.
+to learn all the details.
+
+### Repository JSON payloads by type
+
+All payloads follow this envelope:
+
+```
+{
+ "repo": {
+ "type": "",
+ "storage": "default" | { "type": "fs", "path": "/path" },
+ "port": 8081, // optional: run on dedicated port
+ "http3": true, // optional: enable HTTP/3 for port-bound repos
+ "settings": { ... },
+ "remotes": [ { "url": "https://remote", "username": "u", "password": "p", "priority": 0 } ]
+ }
+}
+```
+
+Below are minimal examples per repository type. Use either a storage alias string or full storage object.
+
+- file:
+ ```
+ { "repo": { "type": "file", "storage": "default" } }
+ ```
+- file-proxy (mirror):
+ ```
+ { "repo": { "type": "file-proxy", "remotes": [ { "url": "https://example.com" } ], "storage": { "type": "fs", "path": "/var/cache/files" } } }
+ ```
+- maven:
+ ```
+ { "repo": { "type": "maven", "storage": "default" } }
+ ```
+- maven-proxy:
+ ```
+ { "repo": { "type": "maven-proxy", "remotes": [ { "url": "https://repo1.maven.org/maven2" } ], "storage": { "type": "fs", "path": "/var/cache/maven" } } }
+ ```
+- npm:
+ ```
+ { "repo": { "type": "npm", "url": "http://host:8080/my-npm", "storage": "default" } }
+ ```
+- npm-proxy:
+ ```
+ { "repo": { "type": "npm-proxy", "settings": { "remote": { "url": "https://registry.npmjs.org" } }, "storage": { "type": "fs", "path": "/var/cache/npm" } } }
+ ```
+- gem:
+ ```
+ { "repo": { "type": "gem", "storage": "default" } }
+ ```
+- helm:
+ ```
+ { "repo": { "type": "helm", "url": "http://host:8080/helm", "storage": "default" } }
+ ```
+- rpm:
+ ```
+ { "repo": { "type": "rpm", "storage": "default", "settings": { "Components": "main", "Architectures": "amd64" } } }
+ ```
+- php (Composer):
+ ```
+ { "repo": { "type": "php", "url": "http://host:8080/php", "storage": "default" } }
+ ```
+- php-proxy (Composer proxy):
+ ```
+ { "repo": { "type": "php-proxy", "remotes": [ { "url": "https://repo.packagist.org" } ], "storage": { "type": "fs", "path": "/var/cache/composer" } } }
+ ```
+- nuget:
+ ```
+ { "repo": { "type": "nuget", "url": "http://host:8080/nuget/index.json", "storage": "default" } }
+ ```
+- pypi:
+ ```
+ { "repo": { "type": "pypi", "storage": "default" } }
+ ```
+- pypi-proxy:
+ ```
+ { "repo": { "type": "pypi-proxy", "remotes": [ { "url": "https://pypi.org/simple" } ], "storage": { "type": "fs", "path": "/var/cache/pypi" } } }
+ ```
+- docker:
+ ```
+ { "repo": { "type": "docker", "storage": "default" } }
+ ```
+ With dedicated port and HTTP/3:
+ ```
+ { "repo": { "type": "docker", "storage": "default", "port": 5000, "http3": true } }
+ ```
+- docker-proxy:
+ ```
+ { "repo": { "type": "docker-proxy", "remotes": [ { "url": "https://registry-1.docker.io" } ], "storage": { "type": "fs", "path": "/var/cache/docker" } } }
+ ```
+- deb (Debian):
+ ```
+ { "repo": { "type": "deb", "storage": "default", "settings": { "Components": "main", "Architectures": "amd64" } } }
+ ```
+- conda (Anaconda):
+ ```
+ { "repo": { "type": "conda", "url": "http://host:8080/conda", "storage": "default" } }
+ ```
+- conan:
+ ```
+ { "repo": { "type": "conan", "storage": "default" } }
+ ```
+- hexpm:
+ ```
+ { "repo": { "type": "hexpm", "storage": "default" } }
+ ```
Rest API provides method to rename repository `PUT /api/v1/{repo_name}/move` (`{repo_name}` is the
name of the repository) and move all the data
@@ -29,6 +136,26 @@ request body, check Swagger docs to learn the format). Response is returned imme
manipulation is performed in asynchronous mode, so to make sure data transfer is complete,
call `HEAD /api/v1/{repo_name}` and verify status `404 NOT FOUND` is returned.
+### Cooldown management
+
+When the global cooldown feature is active, administrators can unblock artefacts through the repository
+API:
+
+- `POST /api/v1/repository/{repo_name}/cooldown/unblock` – body:
+
+ ```json
+ {
+ "artifact": "com.example.library",
+ "version": "1.2.3"
+ }
+ ```
+
+- `POST /api/v1/repository/{repo_name}/cooldown/unblock-all`
+
+Both operations require update permissions on the repository. The first endpoint unblocks the specified
+artefact version and any dependencies that were blocked alongside it. The second endpoint clears all
+pending cooldown entries for the repository. Successful calls return `204 No Content`.
+
## Storage aliases
[Storage aliases](./Configuration-Storage#Storage-Aliases) can also be managed with Rest API,
there are methods to read, create, update and remove aliases. Note, that concrete storage settings
@@ -48,4 +175,4 @@ Users API is available if either `artipie` credentials type or `artipie` policy
Rest API endpoint allow to create or update, obtain roles list or single role info details,
deactivate or remove roles. Roles API endpoints are available if `artipie` policy is used.
-Check [policy section](./Configuration-Policy) to learn more about users or roles info format.
\ No newline at end of file
+Check [policy section](./Configuration-Policy) to learn more about users or roles info format.
diff --git a/.wiki/repositories/docker-proxy.md b/.wiki/repositories/docker-proxy.md
index 70c2110e1..88ebb147d 100644
--- a/.wiki/repositories/docker-proxy.md
+++ b/.wiki/repositories/docker-proxy.md
@@ -9,6 +9,22 @@ repo:
storage:
type: fs
path: /tmp/artipie/data/my-docker
+ http_client: # optional, settings for the HttpClient that will be used in xxx-proxy repositories
+ connection_timeout: 25000 # optional, default 15000 ms
+ idle_timeout: 500 # optional, default 0
+ trust_all: true # optional, default false
+ follow_redirects: true # optional, default true
+ http3: true # optional, default false
+ jks: # optional
+ path: /var/artipie/keystore.jks
+ password: secret
+ proxies:
+ - url: http://proxy1.com
+ - url: https://proxy2.com
+ # the HTTP "Basic" authentication defined in RFC 2617
+ realm: user_realm # if this field is defined, then `username` and `password` are mandatory
+ username: user_name
+ password: user_password
remotes:
- url: registry-1.docker.io
- url: mcr.microsoft.com
diff --git a/.wiki/repositories/file-proxy-mirror.md b/.wiki/repositories/file-proxy-mirror.md
index b3d61eaef..624d2ba1b 100644
--- a/.wiki/repositories/file-proxy-mirror.md
+++ b/.wiki/repositories/file-proxy-mirror.md
@@ -13,6 +13,22 @@ repo:
storage: # optional storage to cache proxy data
type: fs
path: tmp/files-proxy/data
+ http_client: # optional, settings for the HttpClient that will be used in xxx-proxy repositories
+ connection_timeout: 25000 # optional, default 15000 ms
+ idle_timeout: 500 # optional, default 0
+ trust_all: true # optional, default false
+ follow_redirects: true # optional, default true
+ http3: true # optional, default false
+ jks: # optional
+ path: /var/artipie/keystore.jks
+ password: secret
+ proxies:
+ - url: http://proxy1.com
+ - url: https://proxy2.com
+ # the HTTP "Basic" authentication defined in RFC 2617
+ realm: user_realm # if this field is defined, then `username` and `password` are mandatory
+ username: user_name
+ password: user_password
remotes:
- url: "https://remote-server.com"
username: "alice" # optional username
diff --git a/.wiki/repositories/file.md b/.wiki/repositories/file.md
index 83797a956..c7cb4edff 100644
--- a/.wiki/repositories/file.md
+++ b/.wiki/repositories/file.md
@@ -7,7 +7,9 @@ To set up this repository, create config with `file` repository type and storage
```yaml
repo:
type: file
- storage: default
+ storage:
+ type: fs
+ path: /var/artipie/data
```
In order to upload a binary file to the storage, send a `PUT` HTTP request with file contents:
diff --git a/.wiki/repositories/maven-proxy.md b/.wiki/repositories/maven-proxy.md
index 25492ef48..260c63cf4 100644
--- a/.wiki/repositories/maven-proxy.md
+++ b/.wiki/repositories/maven-proxy.md
@@ -11,6 +11,22 @@ repo:
storage:
type: fs
path: /tmp/artipie/maven-central-cache
+ http_client: # optional, settings for the HttpClient that will be used in xxx-proxy repositories
+ connection_timeout: 25000 # optional, default 15000 ms
+ idle_timeout: 500 # optional, default 0
+ trust_all: true # optional, default false
+ follow_redirects: true # optional, default true
+ http3: true # optional, default false
+ jks: # optional
+ path: /var/artipie/keystore.jks
+ password: secret
+ proxies:
+ - url: http://proxy1.com
+ - url: https://proxy2.com
+ # the HTTP "Basic" authentication defined in RFC 2617
+ realm: user_realm # if this field is defined, then `username` and `password` are mandatory
+ username: user_name
+ password: user_password
remotes:
- url: https://repo.maven.apache.org/maven2
username: Aladdin # optional
@@ -31,4 +47,13 @@ it via [`~/.m2/settings.xml`](https://maven.apache.org/settings.html)):
```
where `{host}` and `{port}` are Artipie service host and port, `{repository-name}`
-is the name of maven repository.
\ No newline at end of file
+is the name of maven repository.
+
+### Cooldown behaviour
+
+Proxy repositories participate in the global cooldown policy (see [configuration](../Configuration.md#cooldown-settings)).
+If a requested Maven artefact is newer than the cached version, or if it was released within the configured
+fresh-release window, Artipie temporarily blocks the download and responds with HTTP 403 providing the
+expected unblock time. Administrators can unblock specific artefacts (and their dependency set) through the
+REST API endpoints exposed under `/api/v1/repository/{rname}/cooldown/`. After the cooldown finishes or a
+manual unblock occurs, the version is permanently served without further delay.
diff --git a/.wiki/repositories/npm-proxy.md b/.wiki/repositories/npm-proxy.md
index b22ee8d34..35def7448 100644
--- a/.wiki/repositories/npm-proxy.md
+++ b/.wiki/repositories/npm-proxy.md
@@ -10,6 +10,22 @@ repo:
storage:
type: fs
path: /var/artipie/data/
+ http_client: # optional, settings for the HttpClient that will be used in xxx-proxy repositories
+ connection_timeout: 25000 # optional, default 15000 ms
+ idle_timeout: 500 # optional, default 0
+ trust_all: true # optional, default false
+ follow_redirects: true #optional, default true
+ http3: true # optional, default false
+ jks: # optional
+ path: /var/artipie/keystore.jks
+ password: secret
+ proxies:
+ - url: http://proxy1.com
+ - url: https://proxy2.com
+ # the HTTP "Basic" authentication defined in RFC 2617
+ realm: user_realm # if this field is defined, then `username` and `password` are mandatory
+ username: user_name
+ password: user_password
settings:
remote:
url: http://npmjs-repo/
@@ -28,4 +44,13 @@ npm set registry http://{host}:{port}/{repository-name}
```
In the examples above `{host}` and `{port}` are Artipie service host and port, `{repository-name}`
-is the name of the repository (and repository name is the name of the repo config yaml file).
\ No newline at end of file
+is the name of the repository (and repository name is the name of the repo config yaml file).
+
+### Cooldown behaviour
+
+When the [global cooldown configuration](../Configuration.md#cooldown-settings) is enabled, NPM proxy
+repositories delay serving new artefact versions. The first request for a version that is newer than
+the cached one – or a version published within the configured release window – returns an HTTP 403 response
+with the expected unblock time. The version (and its dependencies) is automatically allowed after the
+cooldown expires or when an administrator unblocks it through the REST API (`/api/v1/repository/{rname}/cooldown/...`).
+Once a version is unblocked it is never cooled down again.
diff --git a/.wiki/repositories/pypi-proxy.md b/.wiki/repositories/pypi-proxy.md
index d6aa2139b..f0b89cc83 100644
--- a/.wiki/repositories/pypi-proxy.md
+++ b/.wiki/repositories/pypi-proxy.md
@@ -8,6 +8,22 @@ repo:
storage:
type: fs
path: /var/artipie/data
+ http_client: # optional, settings for the HttpClient that will be used in xxx-proxy repositories
+ connection_timeout: 25000 # optional, default 15000 ms
+ idle_timeout: 500 # optional, default 0
+ trust_all: true # optional, default false
+ follow_redirects: true # optional, default true
+ http3: true # optional, default false
+ jks: # optional
+ path: /var/artipie/keystore.jks
+ password: secret
+ proxies:
+ - url: http://proxy1.com
+ - url: https://proxy2.com
+ # the HTTP "Basic" authentication defined in RFC 2617
+ realm: user_realm # if this field is defined, then `username` and `password` are mandatory
+ username: user_name
+ password: user_password
remotes:
- url: https://pypi.org/simple/
username: alice
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 06618d058..e4aba7593 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -11,7 +11,7 @@ they don't violate our quality standards. To avoid frustration, before
sending us your pull request please run full Maven build:
```
-mvn clean verify -Pqulice
+mvn clean verify
```
After submitting pull-request check CI status checks. If any check with "required" label fails,
@@ -42,7 +42,7 @@ This is a build and test pipeline for artipie main assembly verification:
## Code style
-Code style is enforced by "qulice" Maven plugin which aggregates multiple rules for "checkstyle" and "PMD".
+Code style is enforced by "pmd-maven-plugin" Maven plugin which applies project PMD rule set.
There are some additional recommendation for code style which are not covered by automatic checks:
diff --git a/README.md b/README.md
index 1eec7cb81..0811a1adc 100644
--- a/README.md
+++ b/README.md
@@ -1,148 +1,481 @@
-[](http://t.me/artipie)
-
-[](https://www.elegantobjects.org)
-[](https://www.jetbrains.com/idea/)
-
-[](http://www.javadoc.io/doc/com.artipie/artipie)
-[](https://github.com/artipie/artipie/blob/master/LICENSE.txt)
-[](https://app.codecov.io/gh/artipie/artipie)
-[](https://hitsofcode.com/view/github/artipie/artipie)
-
-
-[](http://www.0pdd.com/p?name=artipie/artipie)
-
-Artipie is a binary artifact management tool, similar to
-[Artifactory](https://jfrog.com/artifactory/),
-[Nexus](https://www.sonatype.com/product-nexus-repository),
-[Archiva](https://archiva.apache.org/),
-[ProGet](https://inedo.com/proget),
-and many others.
-The following set of features makes Artipie unique among all others:
-
- * It is open source ([MIT license](https://github.com/artipie/artipie/blob/master/LICENSE.txt))
- * It is horizontally scalable, you can add servers easily
- * It is written in reactive Java (using [Vert.x](https://vertx.io/))
- * It supports
- [Maven](https://github.com/artipie/artipie/wiki/maven),
- [Docker](https://github.com/artipie/artipie/wiki/docker),
- [Rubygems](https://github.com/artipie/artipie/wiki/gem),
- [Go](https://github.com/artipie/artipie/wiki/go),
- [Helm](https://github.com/artipie/artipie/wiki/helm),
- [Npm](https://github.com/artipie/artipie/wiki/npm),
- [NuGet](https://github.com/artipie/artipie/wiki/nuget),
- [Composer](https://github.com/artipie/artipie/wiki/composer),
- [Pip](https://github.com/artipie/artipie/wiki/pypi),
- [Rpm](https://github.com/artipie/artipie/wiki/rpm),
- [Debian](https://github.com/artipie/artipie/wiki/debian),
- [Anaconda](https://github.com/artipie/artipie/wiki/anaconda)
- and [others](https://github.com/artipie/artipie/wiki/Configuration-Repository#supported-repository-types)
- * It is database-free
- * It can host the data in the file system, [Amazon S3](https://aws.amazon.com/s3/) or in a storage defined by user
- * Its quality of Java code is extraordinary high :)
-
-Learn more about Artipie in our [Wiki](https://github.com/artipie/artipie/wiki).
-
-**Publications about Artipie:**
-- [An Easy Way to Get Your Own Binary Repository](https://dzone.com/articles/easy-way-to-get-your-own-binary-repository#)
-- [Private Remote Maven Repository With Artipie](https://dzone.com/articles/private-remote-maven-repository-with-artipie-1)
-- [Deployment of NPM Repositories with Artipie](https://dev.to/andpopov/deployment-of-npm-repositories-with-artipie-30co)
-- [How I use Artipie, a PyPI repo](https://opensource.com/article/22/12/python-package-index-repository-artipie)
-- [Готовим приватные репозитории с помощью Artipie](https://habr.com/ru/post/687394/)
-
-
-# Quickstart
-
-Artipie is distributed as Docker container and as fat `jar`. The `jar` file can be downloaded on the
-GitHub [release page](https://github.com/artipie/artipie/releases) and here is a
-[Wiki page](https://github.com/artipie/artipie/wiki#how-to-start-artipie-service-with-a-maven-proxy-repository) describing how to start it.
-The fastest way to start Artipie is by using Docker container. First, make sure you have already installed [Docker Engine](https://docs.docker.com/get-docker/).
-Then, open command line and instruct Docker Engine to run Artipie container:
+# Artipie - Enterprise Binary Artifact Management (Auto1 Fork)
+
+[](LICENSE.txt)
+[](https://openjdk.org/)
+
+> **Auto1 Fork**: This is a production-hardened fork of the original [Artipie](https://github.com/artipie/artipie) project, significantly enhanced for enterprise-scale deployments. It includes performance optimizations, security features, and operational improvements developed for high-traffic production workloads.
+
+## What is Artipie?
+
+Artipie is a **binary artifact management platform** similar to [JFrog Artifactory](https://jfrog.com/artifactory/), [Sonatype Nexus](https://www.sonatype.com/product-nexus-repository), and [Apache Archiva](https://archiva.apache.org/). It provides a unified solution for hosting, proxying, and managing software packages across multiple ecosystems.
+
+### Key Features (Auto1 Fork)
+
+| Feature | Description |
+|---------|-------------|
+| **High Performance** | Built on reactive Java with [Vert.x](https://vertx.io/) for non-blocking I/O |
+| **Multi-Format Support** | 16+ package manager types in a single deployment |
+| **Supply Chain Security** | Cooldown system blocks fresh package versions to prevent attacks |
+| **Enterprise Auth** | OAuth/OIDC integration (Keycloak, Okta with MFA), JWT, RBAC |
+| **Cloud-Native Storage** | Optimized S3-compatible storage with memory-efficient streaming |
+| **Observability** | Prometheus metrics, ECS JSON structured logging, Elastic APM |
+| **Dynamic Configuration** | Create, update, delete repositories at runtime via REST API |
+| **Production-Ready** | Docker Compose stack with PostgreSQL, Valkey (Redis), Nginx, monitoring |
+
+### Fork-Specific Enhancements
+
+- **Cooldown System**: Configurable delay on new package versions (supply chain attack prevention)
+- **Okta OIDC Integration**: Full Okta authentication with MFA support (TOTP + push)
+- **S3 Performance**: Memory-optimized streaming, retry improvements, connection pooling
+- **File Descriptor Optimization**: High ulimit settings for many concurrent connections
+- **ECS JSON Logging**: Structured logging compatible with Elasticsearch/Kibana
+- **Docker Proxy Improvements**: Streaming optimization, timeout handling, multi-platform support
+- **NPM Proxy Deduplication**: Request deduplication for high-concurrency scenarios
+
+## Supported Repository Types
+
+| Type | Local | Proxy | Group | Description |
+|------|:-----:|:-----:|:-----:|-------------|
+| **Maven** | Yes | Yes | Yes | Java artifacts and dependencies |
+| **Gradle** | Yes | Yes | Yes | Gradle artifacts and plugins |
+| **Docker** | Yes | Yes | Yes | Container images registry |
+| **NPM** | Yes | Yes | Yes | JavaScript packages |
+| **PyPI** | Yes | Yes | Yes | Python packages |
+| **Go** | Yes | Yes | Yes | Go modules |
+| **Composer (PHP)** | Yes | Yes | Yes | PHP packages |
+| **Files** | Yes | Yes | Yes | Generic binary files |
+| **Gem** | Yes | — | Yes | Ruby gems |
+| **NuGet** | Yes | — | — | .NET packages |
+| **Helm** | Yes | — | — | Kubernetes charts |
+| **RPM** | Yes | — | — | Red Hat/CentOS packages |
+| **Debian** | Yes | — | — | Debian/Ubuntu packages |
+| **Conda** | Yes | — | — | Data science packages |
+| **Conan** | Yes | — | — | C/C++ packages |
+| **HexPM** | Yes | — | — | Elixir/Erlang packages |
+
+**Repository Modes:**
+- **Local**: Host your own packages (read/write)
+- **Proxy**: Cache packages from upstream registries with cooldown protection
+- **Group**: Aggregate multiple local and/or proxy repositories
+
+## Quick Start
+
+### Using Docker
```bash
-docker run -it -p 8080:8080 -p 8086:8086 artipie/artipie:latest
-```
-
-It'll start a new Docker container with latest Artipie version, the command includes mapping of two
-ports: on port `8080` repositories are served and on port `8086` Artipie Rest API and Swagger
-documentation is provided.
-A new image generate default configuration, prints a list of running repositories, test
-credentials and a link to the [Swagger](https://swagger.io/) documentation to console. To check
-existing repositories using Artipie Rest API:
-- go to Swagger documentation page `http://localhost:8086/api/index-org.html`,
-choose "Auth token" in "Select a definition" list,
-- generate and copy authentication token for user `artipie/artipie`,
-- switch to "Repositories" definition, press "Authorize" button and paste the token
-- then perform `GET /api/v1/repository/list` request.
-Response should be a json list with three default repositories:
-```json
-[
- "artipie/my-bin",
- "artipie/my-docker",
- "artipie/my-maven"
-]
+docker run -d \
+ --name artipie \
+ -p 8080:8080 \
+ -p 8086:8086 \
+ --ulimit nofile=1048576:1048576 \
+ artipie/artipie:latest
+```
+
+**Ports:**
+- `8080`: Repository endpoints
+- `8086`: REST API and Swagger documentation
+
+**Default Credentials:**
+- Username: `artipie`
+- Password: `artipie`
+
+### Verify Installation
+
+```bash
+# Check health
+curl http://localhost:8080/.health
+
+# Check version
+curl http://localhost:8080/.version
+
+# Open Swagger UI
+open http://localhost:8086/api/index.html
+```
+
+## Production Deployment (Recommended)
+
+For production, use the Docker Compose stack which includes all required services:
+
+```bash
+cd artipie-main/docker-compose
+
+# Copy and configure environment
+cp .env.example .env
+# Edit .env with your settings
+
+# Start all services
+docker-compose up -d
```
-Artipie server side (repositories) is served on `8080` port and is available on URI
-`http://localhost:8080/{username}/{reponame}`, where `{username}` is the name
-of the user and `{reponame}` is the name of the repository. Let's put some text data into binary repository:
-```commandline
-curl -X PUT -d 'Hello world!' http://localhost:8080/artipie/my-bin/test.txt
+
+### Included Services
+
+| Service | Port | Description |
+|---------|------|-------------|
+| **Artipie** | 8081 (via nginx) | Repository endpoints |
+| **Artipie API** | 8086 | REST API, Swagger docs |
+| **PostgreSQL** | 5432 | Artifact metadata, cooldown state |
+| **Valkey (Redis)** | 6379 | Caching layer |
+| **Keycloak** | 8080 | OAuth/OIDC authentication |
+| **Nginx** | 8081, 8443 | Reverse proxy with TLS |
+| **Prometheus** | 9090 | Metrics collection |
+| **Grafana** | 3000 | Dashboards and alerting |
+
+### Production Configuration
+
+The Docker Compose setup includes production-ready defaults:
+
+```yaml
+# docker-compose.yaml (artipie service)
+cpus: 4
+mem_limit: 6gb
+ulimits:
+ nofile:
+ soft: 1048576
+ hard: 1048576
+ nproc:
+ soft: 65536
+ hard: 65536
```
-With this request we added file `test.txt` containing text "Hello world!" into repository. Let's check
-it's really there:
-```commandline
-curl -X GET http://localhost:8080/artipie/my-bin/test.txt
+
+### Environment Variables
+
+Key environment variables (see [.env.example](artipie-main/docker-compose/.env.example) for complete list):
+
+```bash
+# Artipie
+ARTIPIE_VERSION=1.20.12
+ARTIPIE_USER_NAME=artipie
+ARTIPIE_USER_PASS=changeme
+
+# JVM (optimized for high concurrency)
+JVM_ARGS=-Xms3g -Xmx4g -XX:+UseG1GC ...
+
+# AWS/S3 (for S3 storage backend)
+AWS_PROFILE=your_profile_name
+AWS_REGION=eu-west-1
+
+# Okta OIDC (optional)
+OKTA_ISSUER=https://your-org.okta.com
+OKTA_CLIENT_ID=your_client_id
+OKTA_CLIENT_SECRET=your_client_secret
+
+# Database
+POSTGRES_USER=artipie
+POSTGRES_PASSWORD=changeme
```
-"Hello world!" should be printed in console.
-Do dive in dipper into Artipie configuration, features, explore repositories and storages settings,
-please, address our [Wiki](https://github.com/artipie/artipie/wiki).
+## Configuration
-Default server configuration in Docker Container refers to `/var/artipie/repos` to look up for
-repository configurations. You may want to mount local configurations ``
-to `/var/artipie/repos` to check and edit it manually.
+### Main Configuration (`artipie.yml`)
-> **Important:** check that `` has correct permissions, it should be `2020:2021`,
-to change it correctly use `chown -R 2020:2021 `.
+```yaml
+meta:
+ storage:
+ type: fs
+ path: /var/artipie/repo
-If you have any question or suggestions, do not hesitate to [create an issue](https://github.com/artipie/artipie/issues/new) or contact us in
-[Telegram](https://t.me/artipie).
-Artipie [roadmap](https://github.com/orgs/artipie/projects/3).
+ credentials:
+ - type: env
+ - type: artipie
+ - type: okta # Auto1 fork feature
+ issuer: ${OKTA_ISSUER}
+ client-id: ${OKTA_CLIENT_ID}
+ client-secret: ${OKTA_CLIENT_SECRET}
-## How to contribute
+ policy:
+ type: artipie
+ storage:
+ type: fs
+ path: /var/artipie/security
-Fork repository, make changes, send us a pull request. We will review
-your changes and apply them to the `master` branch shortly, provided
-they don't violate our quality standards. To avoid frustration, before
-sending us your pull request please run full Maven build:
+ # Cooldown system (Auto1 fork feature)
+ cooldown:
+ enabled: true
+ minimum_allowed_age: 7d
+ metrics:
+ endpoint: /metrics/vertx
+ port: 8087
```
-$ mvn clean install -Pqulice
+
+### Repository Configuration Examples
+
+**NPM Proxy with Cooldown:**
+```yaml
+repo:
+ type: npm-proxy
+ storage:
+ type: fs
+ path: /var/artipie/data/npm
+ remote:
+ url: https://registry.npmjs.org
+ # Cooldown blocks versions newer than configured age
```
-To avoid build errors use Maven 3.2+ and please read
-[contributing rules](https://github.com/artipie/artipie/blob/master/CONTRIBUTING.md).
+**Docker Registry Proxy:**
+```yaml
+repo:
+ type: docker-proxy
+ storage:
+ type: fs
+ path: /var/artipie/data/docker
+ remotes:
+ - url: https://registry-1.docker.io
+ cache:
+ storage:
+ type: fs
+ path: /var/artipie/cache/docker
+```
+
+**Maven Proxy with S3 Storage:**
+```yaml
+repo:
+ type: maven-proxy
+ storage:
+ type: s3
+ bucket: artipie-cache
+ region: eu-west-1
+ remotes:
+ - url: https://repo.maven.apache.org/maven2
+```
-Thanks to [FreePik](https://www.freepik.com/free-photos-vectors/party) for the logo.
+## Cooldown System (Supply Chain Security)
-## How to release
+The cooldown system blocks package versions that are too fresh (recently released) to prevent supply chain attacks:
-Artipie service is released in two formats:
-- [docker image in DockerHub](https://hub.docker.com/r/artipie/artipie)
-- jar archive with dependencies in GitHub release page ([example](https://github.com/artipie/artipie/releases/tag/v0.30.1))
+```yaml
+meta:
+ cooldown:
+ enabled: true
+ minimum_allowed_age: 7d # Block versions newer than 7 days
+```
-These two distributions are created by one GitHub action `[docker-release.yml](.github/workflows/docker-release.yml)`. To
-publish release, push tag (starting with `v` into this repository masted branch):
+**How it works:**
+1. Client requests a package (e.g., `npm install lodash`)
+2. Artipie filters metadata to hide versions newer than the cooldown period
+3. Fresh versions return `403 Forbidden` if requested directly
+4. Old versions are served normally from cache or upstream
+
+**Monitoring:**
```bash
-git tag v1.2.0
-git push --tags origin
+# Check active blocks
+docker exec artipie-db psql -U artipie -d artifacts -c \
+ "SELECT COUNT(*) FROM artifact_cooldowns WHERE status = 'ACTIVE';"
+
+# View blocked requests in logs
+docker logs artipie | grep "event.outcome=blocked"
```
-Also, each adapter can be released into Maven Central individually. To do that, push git tag of the following format:
-```text
-[adapter-name]_[version]
-rpm-adapter_v1.2.3
+
+See [Cooldown System Documentation](docs/cooldown-fallback/README.md) for complete details.
+
+## Documentation
+
+| Document | Description |
+|----------|-------------|
+| [User Guide](docs/USER_GUIDE.md) | Installation, configuration, and usage |
+| [Developer Guide](docs/DEVELOPER_GUIDE.md) | Architecture and contributing |
+| [API Routing](docs/API_ROUTING.md) | URL patterns and routing |
+| [Cooldown System](docs/cooldown-fallback/README.md) | Supply chain attack prevention |
+| [S3 Storage](docs/s3-optimizations/README.md) | S3 configuration and tuning |
+| [Okta OIDC](docs/OKTA_OIDC_INTEGRATION.md) | Okta authentication with MFA |
+| [JVM Optimization](docs/ARTIPIE_JVM_OPTIMIZATION.md) | JVM tuning for production |
+| [NPM CLI](docs/NPM_CLI_COMPATIBILITY.md) | NPM command reference |
+| [Logging](docs/LOGGING_CONFIGURATION.md) | Log4j2 and ECS JSON setup |
+
+See [docs/README.md](docs/README.md) for the complete documentation index.
+
+## REST API
+
+### Authentication
+
+```bash
+# Get auth token
+TOKEN=$(curl -s -X POST http://localhost:8086/api/auth/token \
+ -H "Content-Type: application/json" \
+ -d '{"name":"artipie","pass":"artipie"}' | jq -r '.token')
```
-On this tag, GitHub action [maven-adapter-release.yml](.github%2Fworkflows%2Fmaven-adapter-release.yml) will run to
-release specified adapter. Note, that some of the adapters should be compatible with java 8. To achieve that, we use
-[--release=8](https://www.baeldung.com/java-compiler-release-option) option for the main code.
+
+### Repository Management
+
+```bash
+# Create Maven repository
+curl -X PUT "http://localhost:8086/api/v1/repository/my-maven" \
+ -H "Authorization: Bearer ${TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d '{"repo":{"type":"maven","storage":"default"}}'
+
+# List repositories
+curl -H "Authorization: Bearer ${TOKEN}" \
+ http://localhost:8086/api/v1/repository/list
+
+# Delete repository
+curl -X DELETE "http://localhost:8086/api/v1/repository/my-maven" \
+ -H "Authorization: Bearer ${TOKEN}"
+```
+
+### API Endpoints
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| POST | `/api/auth/token` | Get authentication token |
+| PUT | `/api/v1/repository/{name}` | Create or update repository |
+| GET | `/api/v1/repository/{name}` | Get repository settings |
+| DELETE | `/api/v1/repository/{name}` | Remove repository |
+| GET | `/api/v1/repository/list` | List all repositories |
+
+## Building from Source
+
+### Prerequisites
+
+- JDK 21+
+- Maven 3.9+
+- Docker (for integration tests)
+
+### Build Commands
+
+```bash
+# Full build with tests
+mvn clean verify
+
+# Fast build (skip tests)
+mvn install -DskipTests -Dpmd.skip=true
+
+# Build Docker image
+cd artipie-main
+mvn package -DskipTests
+docker build -t auto1-artipie:local --build-arg JAR_FILE=artipie-main-*.jar .
+```
+
+### Running Tests
+
+```bash
+# Unit tests only
+mvn test
+
+# Integration tests
+mvn verify
+
+# Specific module
+mvn test -pl npm-adapter
+```
+
+## Architecture
+
+```
++-------------------------------------------------------------+
+| HTTP Layer (Vert.x) |
+| +---------+ +---------+ +---------+ +-----------------+ |
+| |MainSlice|--|TimeoutSl|--|AuthSlice|--|RepositorySlices | |
+| +---------+ +---------+ +---------+ +-----------------+ |
++-------------------------------------------------------------+
+ |
++-------------------------------------------------------------+
+| Repository Adapters |
+| +------+ +------+ +-----+ +-----+ +-----+ +-----+ |
+| |Maven | |Docker| | NPM | |PyPI | |Helm | | ... | |
+| +------+ +------+ +-----+ +-----+ +-----+ +-----+ |
++-------------------------------------------------------------+
+ |
++-------------------------------------------------------------+
+| Cooldown Layer (Auto1 Fork) |
+| +----------------+ +----------------+ +---------------+ |
+| |CooldownService | |MetadataService | |CooldownInspect| |
+| +----------------+ +----------------+ +---------------+ |
++-------------------------------------------------------------+
+ |
++-------------------------------------------------------------+
+| Storage Layer (Asto) |
+| +------------+ +--------+ +------+ +-------+ |
+| | FileSystem | | S3 | | etcd | | Redis | |
+| +------------+ +--------+ +------+ +-------+ |
++-------------------------------------------------------------+
+```
+
+### Key Design Principles
+
+1. **Reactive/Non-blocking**: All I/O operations are asynchronous using `CompletableFuture`
+2. **Slice Pattern**: HTTP handlers compose through the `Slice` interface
+3. **Storage Abstraction**: Pluggable storage backends via the Asto library
+4. **Hot Reload**: Configuration changes apply without restart
+5. **Defense in Depth**: Cooldown system adds supply chain security layer
+
+## Monitoring
+
+### Prometheus Metrics
+
+Artipie exposes metrics at `/metrics/vertx` (port 8087):
+
+```bash
+curl http://localhost:8087/metrics/vertx
+```
+
+Key metrics:
+- `artipie_http_requests_total` - Request count by path, method, status
+- `artipie_proxy_requests_total` - Proxy requests to upstream
+- `artipie_cooldown_blocks_total` - Blocked versions by cooldown
+- `artipie_cache_hits_total` - Cache hit/miss ratio
+
+### Grafana Dashboards
+
+Pre-configured dashboards are available in `artipie-main/docker-compose/grafana/dashboards/`:
+- Artipie Overview
+- Repository Performance
+- Cooldown Activity
+- JVM Metrics
+
+### Logging
+
+ECS JSON structured logging for Elasticsearch/Kibana:
+
+```json
+{
+ "@timestamp": "2026-01-19T10:30:00.000Z",
+ "log.level": "INFO",
+ "message": "Package version blocked by cooldown",
+ "event.category": "cooldown",
+ "event.action": "block",
+ "event.outcome": "blocked",
+ "package.name": "lodash",
+ "package.version": "4.18.0"
+}
+```
+
+## Version Information
+
+| Component | Version |
+|-----------|---------|
+| Artipie | 1.20.12 |
+| Java | 21+ |
+| Vert.x | 4.5.x |
+
+## Contributing
+
+Contributions are welcome. Please:
+
+1. Fork the repository
+2. Create a feature branch
+3. Run tests: `mvn clean verify`
+4. Submit a pull request
+
+### Code Style
+
+- PMD enforced code style
+- Checkstyle validation
+- Unit tests required for new features
+
+```bash
+# Before submitting a PR
+mvn clean verify
+```
+
+## License
+
+[MIT License](LICENSE.txt) - Copyright (c) Artipie Contributors
+
+---
+
+
+ Auto1 Fork - Production-hardened for enterprise scale
+
diff --git a/artipie-core/pom.xml b/artipie-core/pom.xml
index 99eb00d74..b110aca61 100644
--- a/artipie-core/pom.xml
+++ b/artipie-core/pom.xml
@@ -6,46 +6,61 @@
com.artipie
artipie
- 1.0-SNAPSHOT
+ 1.20.12
artipie-core
- 1.0-SNAPSHOT
+ 1.20.12
jar
-
- UTF-8
+ ${project.basedir}/../LICENSE.header
-
+
+ com.artipie
+ asto-core
+ 1.20.12
+ compile
+
+
+
+ org.testng
+ testng
+
+
+
com.github.akarnokd
rxjava2-jdk8-interop
+ 0.3.7
org.hamcrest
hamcrest
+ 2.2
true
org.apache.commons
commons-lang3
+ 3.14.0
+
- org.apache.commons
- commons-collections4
- 4.4
+ io.micrometer
+ micrometer-core
+ ${micrometer.version}
+
+
+ io.micrometer
+ micrometer-registry-prometheus
+ ${micrometer.version}
javax.json
javax.json-api
- provided
+ ${javax.json.version}
-
org.cqfn
rio
@@ -57,25 +72,44 @@
v2.3.2+java8
- com.google.guava
- guava
- 32.0.0-jre
-
-
- org.apache.httpcomponents
- httpclient
- 4.5.13
+ org.apache.httpcomponents.client5
+ httpclient5
+ ${httpclient.version}
org.quartz-scheduler
quartz
2.3.2
-
- javax.servlet
- javax.servlet-api
- 4.0.1
+ com.github.ben-manes.caffeine
+ caffeine
+ 3.1.8
+ compile
+
+
+
+ io.lettuce
+ lettuce-core
+ 6.4.0.RELEASE
+ compile
+
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ ${fasterxml.jackson.version}
+
+
+ jakarta.servlet
+ jakarta.servlet-api
+ 6.0.0
+ provided
+
+
+ net.jcip
+ jcip-annotations
+ 1.0
provided
@@ -92,18 +126,6 @@
test
-
- io.vertx
- vertx-web-client
- 4.3.2.1
- test
-
-
- org.slf4j
- slf4j-simple
- 1.7.32
- test
-
org.llorllale
cactoos-matchers
@@ -125,68 +147,14 @@
org.eclipse.jetty
jetty-server
- 10.0.15
+ 11.0.19
test
org.eclipse.jetty
jetty-servlet
- 10.0.15
- test
-
-
-
- org.apache.httpcomponents.client5
- httpclient5
- 5.1.2
- test
-
-
- org.apache.httpcomponents.client5
- httpclient5-fluent
- 5.1.3
+ 11.0.19
test
-
-
-
- org.apache.maven.plugins
- maven-compiler-plugin
-
- 21
-
- **/com/artipie/http/servlet/**
- **/com/artipie/http/slice/SliceITCase.java
-
-
-
-
-
-
-
-
- skip-itcases-old-jdk
-
- 1.8
-
-
-
-
-
- org.apache.maven.plugins
- maven-compiler-plugin
-
-
- **/com/artipie/http/servlet/**
- **/com/artipie/http/slice/SliceITCase.java
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/artipie-core/src/main/java/com/artipie/cache/CacheConfig.java b/artipie-core/src/main/java/com/artipie/cache/CacheConfig.java
new file mode 100644
index 000000000..a32ad90c1
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cache/CacheConfig.java
@@ -0,0 +1,517 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cache;
+
+import com.amihaiemil.eoyaml.YamlMapping;
+import java.time.Duration;
+import java.util.Optional;
+
+/**
+ * General cache configuration for all Artipie caches.
+ * Uses named profiles defined globally in _server.yaml.
+ *
+ * Example YAML configuration:
+ *
+ * # Global settings in _server.yaml
+ * caches:
+ * profiles:
+ * # Default profile
+ * default:
+ * maxSize: 10000
+ * ttl: 24h
+ *
+ * # Small short-lived cache
+ * small:
+ * maxSize: 1000
+ * ttl: 5m
+ *
+ * # Large long-lived cache
+ * large:
+ * maxSize: 50000
+ * ttl: 7d
+ *
+ * # Named cache configurations (reference profiles)
+ * storage:
+ * profile: small
+ * auth:
+ * profile: small
+ * policy:
+ * profile: default
+ * maven-metadata:
+ * profile: default
+ * maven-negative:
+ * profile: large
+ *
+ *
+ * @since 1.18.22
+ */
+public final class CacheConfig {
+
+ /**
+ * Default TTL (24 hours).
+ */
+ private static final Duration DEFAULT_TTL = Duration.ofHours(24);
+
+ /**
+ * Default max size (10,000 entries).
+ */
+ private static final int DEFAULT_MAX_SIZE = 10_000;
+
+ /**
+ * Default L1 max size for two-tier (10,000 entries - hot data).
+ */
+ private static final int DEFAULT_L1_MAX_SIZE = 10_000;
+
+ /**
+ * Default L2 max size for two-tier (1,000,000 entries - warm data).
+ */
+ private static final int DEFAULT_L2_MAX_SIZE = 1_000_000;
+
+ /**
+ * Cache TTL.
+ */
+ private final Duration ttl;
+
+ /**
+ * Cache max size (single-tier).
+ */
+ private final int maxSize;
+
+ /**
+ * Valkey/Redis enabled (two-tier cache).
+ */
+ private final boolean valkeyEnabled;
+
+ /**
+ * Valkey/Redis host.
+ */
+ private final String valkeyHost;
+
+ /**
+ * Valkey/Redis port.
+ */
+ private final int valkeyPort;
+
+ /**
+ * Valkey/Redis timeout.
+ */
+ private final Duration valkeyTimeout;
+
+ /**
+ * L1 (in-memory) max size for two-tier.
+ */
+ private final int l1MaxSize;
+
+ /**
+ * L2 (Valkey) max size for two-tier.
+ */
+ private final int l2MaxSize;
+
+ /**
+ * L1 (in-memory) TTL for two-tier.
+ * If null, defaults to 5 minutes for hot data.
+ */
+ private final Duration l1Ttl;
+
+ /**
+ * L2 (Valkey) TTL for two-tier.
+ * If null, uses main TTL value.
+ */
+ private final Duration l2Ttl;
+
+ /**
+ * Create config with defaults.
+ */
+ public CacheConfig() {
+ this(DEFAULT_TTL, DEFAULT_MAX_SIZE);
+ }
+
+ /**
+ * Create config with specific values (single-tier).
+ * @param ttl Cache TTL
+ * @param maxSize Cache max size
+ */
+ public CacheConfig(final Duration ttl, final int maxSize) {
+ this.ttl = ttl;
+ this.maxSize = maxSize;
+ this.valkeyEnabled = false;
+ this.valkeyHost = null;
+ this.valkeyPort = 6379;
+ this.valkeyTimeout = Duration.ofMillis(100);
+ this.l1MaxSize = DEFAULT_L1_MAX_SIZE;
+ this.l2MaxSize = DEFAULT_L2_MAX_SIZE;
+ this.l1Ttl = null;
+ this.l2Ttl = null;
+ }
+
+ /**
+ * Create config with Valkey two-tier support.
+ * @param ttl Cache TTL (default for both single-tier and L2)
+ * @param maxSize Cache max size (ignored if Valkey enabled, use l1/l2 sizes)
+ * @param valkeyEnabled Whether Valkey is enabled
+ * @param valkeyHost Valkey host
+ * @param valkeyPort Valkey port
+ * @param valkeyTimeout Valkey timeout
+ * @param l1MaxSize L1 (in-memory) max size
+ * @param l2MaxSize L2 (Valkey) max size
+ */
+ public CacheConfig(
+ final Duration ttl,
+ final int maxSize,
+ final boolean valkeyEnabled,
+ final String valkeyHost,
+ final int valkeyPort,
+ final Duration valkeyTimeout,
+ final int l1MaxSize,
+ final int l2MaxSize
+ ) {
+ this(ttl, maxSize, valkeyEnabled, valkeyHost, valkeyPort, valkeyTimeout,
+ l1MaxSize, l2MaxSize, null, null);
+ }
+
+ /**
+ * Create config with full Valkey two-tier support (including TTLs).
+ * @param ttl Cache TTL (default for both single-tier and L2)
+ * @param maxSize Cache max size (ignored if Valkey enabled, use l1/l2 sizes)
+ * @param valkeyEnabled Whether Valkey is enabled
+ * @param valkeyHost Valkey host
+ * @param valkeyPort Valkey port
+ * @param valkeyTimeout Valkey timeout
+ * @param l1MaxSize L1 (in-memory) max size
+ * @param l2MaxSize L2 (Valkey) max size
+ * @param l1Ttl L1 TTL (null = default 5 min)
+ * @param l2Ttl L2 TTL (null = use main ttl)
+ */
+ public CacheConfig(
+ final Duration ttl,
+ final int maxSize,
+ final boolean valkeyEnabled,
+ final String valkeyHost,
+ final int valkeyPort,
+ final Duration valkeyTimeout,
+ final int l1MaxSize,
+ final int l2MaxSize,
+ final Duration l1Ttl,
+ final Duration l2Ttl
+ ) {
+ this.ttl = ttl;
+ this.maxSize = maxSize;
+ this.valkeyEnabled = valkeyEnabled;
+ this.valkeyHost = valkeyHost;
+ this.valkeyPort = valkeyPort;
+ this.valkeyTimeout = valkeyTimeout;
+ this.l1MaxSize = l1MaxSize;
+ this.l2MaxSize = l2MaxSize;
+ this.l1Ttl = l1Ttl;
+ this.l2Ttl = l2Ttl;
+ }
+
+ /**
+ * Load cache configuration for a named cache.
+ * Looks up: caches.{cacheName}.profile → caches.profiles.{profileName}
+ *
+ * @param serverYaml Server settings YAML (_server.yaml)
+ * @param cacheName Cache name (e.g., "storage", "auth", "policy")
+ * @return Cache configuration
+ */
+ public static CacheConfig from(final YamlMapping serverYaml, final String cacheName) {
+ if (serverYaml == null || cacheName == null || cacheName.isEmpty()) {
+ return new CacheConfig();
+ }
+
+ // Look for caches section
+ final YamlMapping caches = serverYaml.yamlMapping("caches");
+ if (caches == null) {
+ System.out.printf(
+ "[CacheConfig] No 'caches' section in _server.yaml for '%s', using defaults%n",
+ cacheName
+ );
+ return new CacheConfig();
+ }
+
+ // Check if there's a specific config for this cache
+ final YamlMapping cacheMapping = caches.yamlMapping(cacheName);
+ if (cacheMapping != null) {
+ // Check if it references a profile
+ final String profileName = cacheMapping.string("profile");
+ if (profileName != null && !profileName.isEmpty()) {
+ // Load from profile
+ return fromProfile(caches, profileName, cacheName);
+ }
+
+ // Direct configuration (no profile reference)
+ return parseConfig(cacheMapping, cacheName);
+ }
+
+ // No specific config, try default profile
+ return fromProfile(caches, "default", cacheName);
+ }
+
+ /**
+ * Load configuration from a named profile.
+ * @param caches Caches YAML section
+ * @param profileName Profile name
+ * @param cacheName Cache name (for logging)
+ * @return Cache configuration
+ */
+ private static CacheConfig fromProfile(
+ final YamlMapping caches,
+ final String profileName,
+ final String cacheName
+ ) {
+ final YamlMapping profiles = caches.yamlMapping("profiles");
+ if (profiles == null) {
+ System.out.printf(
+ "[CacheConfig] No 'caches.profiles' section for '%s', using defaults%n",
+ cacheName
+ );
+ return new CacheConfig();
+ }
+
+ final YamlMapping profile = profiles.yamlMapping(profileName);
+ if (profile == null) {
+ System.out.printf(
+ "[CacheConfig] Profile '%s' not found for cache '%s', using defaults%n",
+ profileName, cacheName
+ );
+ return new CacheConfig();
+ }
+
+ System.out.printf(
+ "[CacheConfig] Loaded cache '%s' with profile '%s'%n",
+ cacheName, profileName
+ );
+ return parseConfig(profile, cacheName);
+ }
+
+ /**
+ * Parse configuration from YAML mapping.
+ * @param yaml YAML mapping
+ * @param cacheName Cache name (for logging)
+ * @return Cache configuration
+ */
+ private static CacheConfig parseConfig(final YamlMapping yaml, final String cacheName) {
+ final Duration ttl = parseDuration(
+ yaml.string("ttl"),
+ DEFAULT_TTL,
+ cacheName
+ );
+
+ final int maxSize = parseInt(
+ yaml.string("maxSize"),
+ DEFAULT_MAX_SIZE,
+ cacheName
+ );
+
+ // Check for Valkey configuration
+ final YamlMapping valkeyYaml = yaml.yamlMapping("valkey");
+ if (valkeyYaml != null) {
+ final boolean enabled = "true".equalsIgnoreCase(valkeyYaml.string("enabled"));
+ if (enabled) {
+ System.out.printf(
+ "[CacheConfig] Enabling Valkey two-tier cache for '%s'%n",
+ cacheName
+ );
+ // Parse L1/L2 TTLs (optional)
+ final Duration l1Ttl = valkeyYaml.string("l1Ttl") != null
+ ? parseDuration(valkeyYaml.string("l1Ttl"), Duration.ofMinutes(5), cacheName)
+ : null; // null = use default 5 min
+
+ final Duration l2Ttl = valkeyYaml.string("l2Ttl") != null
+ ? parseDuration(valkeyYaml.string("l2Ttl"), ttl, cacheName)
+ : null; // null = use main ttl
+
+ return new CacheConfig(
+ ttl,
+ maxSize,
+ true, // valkeyEnabled
+ valkeyYaml.string("host") != null ? valkeyYaml.string("host") : "localhost",
+ parseInt(valkeyYaml.string("port"), 6379, cacheName),
+ parseDuration(valkeyYaml.string("timeout"), Duration.ofMillis(100), cacheName),
+ parseInt(valkeyYaml.string("l1MaxSize"), DEFAULT_L1_MAX_SIZE, cacheName),
+ parseInt(valkeyYaml.string("l2MaxSize"), DEFAULT_L2_MAX_SIZE, cacheName),
+ l1Ttl,
+ l2Ttl
+ );
+ }
+ }
+
+ return new CacheConfig(ttl, maxSize);
+ }
+
+ /**
+ * Parse duration from string (e.g., "24h", "30m", "PT24H").
+ * @param value Duration string
+ * @param defaultValue Default if parsing fails
+ * @param cacheName Cache name (for logging)
+ * @return Parsed duration
+ */
+ private static Duration parseDuration(
+ final String value,
+ final Duration defaultValue,
+ final String cacheName
+ ) {
+ if (value == null || value.isEmpty()) {
+ return defaultValue;
+ }
+
+ try {
+ // Try ISO-8601 duration format first (PT24H)
+ return Duration.parse(value);
+ } catch (Exception e1) {
+ // Try simple format: 24h, 30m, 5s
+ try {
+ final String lower = value.toLowerCase().trim();
+ if (lower.endsWith("h")) {
+ return Duration.ofHours(Long.parseLong(lower.substring(0, lower.length() - 1)));
+ } else if (lower.endsWith("m")) {
+ return Duration.ofMinutes(Long.parseLong(lower.substring(0, lower.length() - 1)));
+ } else if (lower.endsWith("s")) {
+ return Duration.ofSeconds(Long.parseLong(lower.substring(0, lower.length() - 1)));
+ } else if (lower.endsWith("d")) {
+ return Duration.ofDays(Long.parseLong(lower.substring(0, lower.length() - 1)));
+ }
+ // Try parsing as seconds
+ return Duration.ofSeconds(Long.parseLong(lower));
+ } catch (Exception e2) {
+ System.err.printf(
+ "[CacheConfig] Failed to parse duration '%s' for cache '%s', using default: %s%n",
+ value, cacheName, defaultValue
+ );
+ return defaultValue;
+ }
+ }
+ }
+
+ /**
+ * Parse integer from string.
+ * @param value Integer string
+ * @param defaultValue Default if parsing fails
+ * @param cacheName Cache name (for logging)
+ * @return Parsed integer
+ */
+ private static int parseInt(
+ final String value,
+ final int defaultValue,
+ final String cacheName
+ ) {
+ if (value == null || value.isEmpty()) {
+ return defaultValue;
+ }
+
+ try {
+ return Integer.parseInt(value.trim());
+ } catch (NumberFormatException e) {
+ System.err.printf(
+ "[CacheConfig] Failed to parse maxSize '%s' for cache '%s', using default: %d%n",
+ value, cacheName, defaultValue
+ );
+ return defaultValue;
+ }
+ }
+
+ /**
+ * Get cache TTL.
+ * @return Cache TTL
+ */
+ public Duration ttl() {
+ return this.ttl;
+ }
+
+ /**
+ * Get cache max size.
+ * @return Cache max size
+ */
+ public int maxSize() {
+ return this.maxSize;
+ }
+
+ /**
+ * Check if Valkey two-tier caching is enabled.
+ * @return True if Valkey enabled
+ */
+ public boolean valkeyEnabled() {
+ return this.valkeyEnabled;
+ }
+
+ /**
+ * Get Valkey host.
+ * @return Valkey host
+ */
+ public Optional valkeyHost() {
+ return Optional.ofNullable(this.valkeyHost);
+ }
+
+ /**
+ * Get Valkey port.
+ * @return Valkey port
+ */
+ public Optional valkeyPort() {
+ return this.valkeyEnabled ? Optional.of(this.valkeyPort) : Optional.empty();
+ }
+
+ /**
+ * Get Valkey timeout.
+ * @return Valkey timeout
+ */
+ public Optional valkeyTimeout() {
+ return this.valkeyEnabled ? Optional.of(this.valkeyTimeout) : Optional.empty();
+ }
+
+ /**
+ * Get L1 (in-memory) max size for two-tier.
+ * @return L1 max size
+ */
+ public int l1MaxSize() {
+ return this.l1MaxSize;
+ }
+
+ /**
+ * Get L2 (Valkey) max size for two-tier.
+ * @return L2 max size
+ */
+ public int l2MaxSize() {
+ return this.l2MaxSize;
+ }
+
+ /**
+ * Get L1 (in-memory) TTL for two-tier.
+ * If not configured, returns default of 5 minutes.
+ * @return L1 TTL
+ */
+ public Duration l1Ttl() {
+ return this.l1Ttl != null ? this.l1Ttl : Duration.ofMinutes(5);
+ }
+
+ /**
+ * Get L2 (Valkey) TTL for two-tier.
+ * If not configured, returns main TTL.
+ * @return L2 TTL
+ */
+ public Duration l2Ttl() {
+ return this.l2Ttl != null ? this.l2Ttl : this.ttl;
+ }
+
+ @Override
+ public String toString() {
+ if (this.valkeyEnabled) {
+ return String.format(
+ "CacheConfig{ttl=%s, valkey=enabled, host=%s:%d, l1=%d/%s, l2=%d/%s}",
+ this.ttl,
+ this.valkeyHost,
+ this.valkeyPort,
+ this.l1MaxSize,
+ this.l1Ttl != null ? this.l1Ttl : "5m",
+ this.l2MaxSize,
+ this.l2Ttl != null ? this.l2Ttl : this.ttl
+ );
+ }
+ return String.format(
+ "CacheConfig{ttl=%s, maxSize=%d}",
+ this.ttl,
+ this.maxSize
+ );
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/cache/GlobalCacheConfig.java b/artipie-core/src/main/java/com/artipie/cache/GlobalCacheConfig.java
new file mode 100644
index 000000000..c0c6f11fe
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cache/GlobalCacheConfig.java
@@ -0,0 +1,69 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cache;
+
+import java.util.Optional;
+
+/**
+ * Global cache configuration holder.
+ * Provides shared Valkey connection for all caches across Artipie.
+ * Thread-safe singleton pattern.
+ *
+ * @since 1.0
+ */
+public final class GlobalCacheConfig {
+
+ /**
+ * Singleton instance.
+ */
+ private static volatile GlobalCacheConfig instance;
+
+ /**
+ * Shared Valkey connection.
+ */
+ private final ValkeyConnection valkey;
+
+ /**
+ * Private constructor for singleton.
+ * @param valkey Valkey connection
+ */
+ private GlobalCacheConfig(final ValkeyConnection valkey) {
+ this.valkey = valkey;
+ }
+
+ /**
+ * Initialize global cache configuration.
+ * Should be called once at startup by YamlSettings.
+ *
+ * @param valkey Optional Valkey connection
+ */
+ public static void initialize(final Optional valkey) {
+ if (instance == null) {
+ synchronized (GlobalCacheConfig.class) {
+ if (instance == null) {
+ instance = new GlobalCacheConfig(valkey.orElse(null));
+ }
+ }
+ }
+ }
+
+ /**
+ * Get the shared Valkey connection.
+ * @return Optional Valkey connection
+ */
+ public static Optional valkeyConnection() {
+ if (instance == null) {
+ return Optional.empty();
+ }
+ return Optional.ofNullable(instance.valkey);
+ }
+
+ /**
+ * Reset for testing purposes.
+ */
+ static void reset() {
+ instance = null;
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/cache/NegativeCacheConfig.java b/artipie-core/src/main/java/com/artipie/cache/NegativeCacheConfig.java
new file mode 100644
index 000000000..0864fd61e
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cache/NegativeCacheConfig.java
@@ -0,0 +1,360 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cache;
+
+import com.amihaiemil.eoyaml.YamlMapping;
+import java.time.Duration;
+import java.util.Optional;
+
+/**
+ * Unified configuration for all negative caches (404 caching).
+ * All negative caches serve the same purpose so they share configuration.
+ *
+ * Example YAML configuration in _server.yaml:
+ *
+ * caches:
+ * negative:
+ * ttl: 24h
+ * maxSize: 50000
+ * valkey:
+ * enabled: true
+ * l1MaxSize: 5000
+ * l1Ttl: 5m
+ * l2MaxSize: 5000000
+ * l2Ttl: 7d
+ *
+ *
+ * @since 1.18.22
+ */
+public final class NegativeCacheConfig {
+
+ /**
+ * Default TTL for negative cache (24 hours).
+ */
+ public static final Duration DEFAULT_TTL = Duration.ofHours(24);
+
+ /**
+ * Default maximum L1 cache size.
+ */
+ public static final int DEFAULT_MAX_SIZE = 50_000;
+
+ /**
+ * Default L1 TTL when L2 is enabled (5 minutes).
+ */
+ public static final Duration DEFAULT_L1_TTL = Duration.ofMinutes(5);
+
+ /**
+ * Default L2 TTL (7 days).
+ */
+ public static final Duration DEFAULT_L2_TTL = Duration.ofDays(7);
+
+ /**
+ * Default L1 max size when L2 enabled.
+ */
+ public static final int DEFAULT_L1_MAX_SIZE = 5_000;
+
+ /**
+ * Default L2 max size.
+ */
+ public static final int DEFAULT_L2_MAX_SIZE = 5_000_000;
+
+ /**
+ * Default L2 operation timeout (50ms - fail fast to avoid blocking).
+ */
+ public static final Duration DEFAULT_L2_TIMEOUT = Duration.ofMillis(50);
+
+ /**
+ * Global instance (singleton).
+ */
+ private static volatile NegativeCacheConfig instance;
+
+ /**
+ * TTL for single-tier or fallback.
+ */
+ private final Duration ttl;
+
+ /**
+ * Max size for single-tier.
+ */
+ private final int maxSize;
+
+ /**
+ * Whether Valkey L2 is enabled.
+ */
+ private final boolean valkeyEnabled;
+
+ /**
+ * L1 cache max size.
+ */
+ private final int l1MaxSize;
+
+ /**
+ * L1 cache TTL.
+ */
+ private final Duration l1Ttl;
+
+ /**
+ * L2 cache max size (for documentation, Valkey handles this).
+ */
+ private final int l2MaxSize;
+
+ /**
+ * L2 cache TTL.
+ */
+ private final Duration l2Ttl;
+
+ /**
+ * L2 operation timeout (for Redis/Valkey operations).
+ */
+ private final Duration l2Timeout;
+
+ /**
+ * Default constructor with sensible defaults.
+ */
+ public NegativeCacheConfig() {
+ this(DEFAULT_TTL, DEFAULT_MAX_SIZE, false,
+ DEFAULT_L1_MAX_SIZE, DEFAULT_L1_TTL,
+ DEFAULT_L2_MAX_SIZE, DEFAULT_L2_TTL, DEFAULT_L2_TIMEOUT);
+ }
+
+ /**
+ * Full constructor.
+ * @param ttl Default TTL
+ * @param maxSize Default max size
+ * @param valkeyEnabled Whether L2 is enabled
+ * @param l1MaxSize L1 max size
+ * @param l1Ttl L1 TTL
+ * @param l2MaxSize L2 max size
+ * @param l2Ttl L2 TTL
+ */
+ public NegativeCacheConfig(
+ final Duration ttl,
+ final int maxSize,
+ final boolean valkeyEnabled,
+ final int l1MaxSize,
+ final Duration l1Ttl,
+ final int l2MaxSize,
+ final Duration l2Ttl
+ ) {
+ this(ttl, maxSize, valkeyEnabled, l1MaxSize, l1Ttl, l2MaxSize, l2Ttl, DEFAULT_L2_TIMEOUT);
+ }
+
+ /**
+ * Full constructor with timeout.
+ * @param ttl Default TTL
+ * @param maxSize Default max size
+ * @param valkeyEnabled Whether L2 is enabled
+ * @param l1MaxSize L1 max size
+ * @param l1Ttl L1 TTL
+ * @param l2MaxSize L2 max size
+ * @param l2Ttl L2 TTL
+ * @param l2Timeout L2 operation timeout
+ */
+ public NegativeCacheConfig(
+ final Duration ttl,
+ final int maxSize,
+ final boolean valkeyEnabled,
+ final int l1MaxSize,
+ final Duration l1Ttl,
+ final int l2MaxSize,
+ final Duration l2Ttl,
+ final Duration l2Timeout
+ ) {
+ this.ttl = ttl;
+ this.maxSize = maxSize;
+ this.valkeyEnabled = valkeyEnabled;
+ this.l1MaxSize = l1MaxSize;
+ this.l1Ttl = l1Ttl;
+ this.l2MaxSize = l2MaxSize;
+ this.l2Ttl = l2Ttl;
+ this.l2Timeout = l2Timeout;
+ }
+
+ /**
+ * Get default TTL.
+ * @return TTL duration
+ */
+ public Duration ttl() {
+ return this.ttl;
+ }
+
+ /**
+ * Get max size for single-tier cache.
+ * @return Max size
+ */
+ public int maxSize() {
+ return this.maxSize;
+ }
+
+ /**
+ * Check if Valkey L2 is enabled.
+ * @return True if two-tier caching is enabled
+ */
+ public boolean isValkeyEnabled() {
+ return this.valkeyEnabled;
+ }
+
+ /**
+ * Get L1 max size.
+ * @return L1 max size
+ */
+ public int l1MaxSize() {
+ return this.l1MaxSize;
+ }
+
+ /**
+ * Get L1 TTL.
+ * @return L1 TTL
+ */
+ public Duration l1Ttl() {
+ return this.l1Ttl;
+ }
+
+ /**
+ * Get L2 max size.
+ * @return L2 max size
+ */
+ public int l2MaxSize() {
+ return this.l2MaxSize;
+ }
+
+ /**
+ * Get L2 TTL.
+ * @return L2 TTL
+ */
+ public Duration l2Ttl() {
+ return this.l2Ttl;
+ }
+
+ /**
+ * Get L2 operation timeout.
+ * @return L2 timeout duration
+ */
+ public Duration l2Timeout() {
+ return this.l2Timeout;
+ }
+
+ /**
+ * Initialize global instance from YAML.
+ * Should be called once at startup.
+ * @param caches The caches YAML mapping from _server.yaml
+ */
+ public static void initialize(final YamlMapping caches) {
+ if (instance == null) {
+ synchronized (NegativeCacheConfig.class) {
+ if (instance == null) {
+ instance = fromYaml(caches);
+ }
+ }
+ }
+ }
+
+ /**
+ * Get the global instance.
+ * @return Global config (defaults if not initialized)
+ */
+ public static NegativeCacheConfig getInstance() {
+ if (instance == null) {
+ return new NegativeCacheConfig();
+ }
+ return instance;
+ }
+
+ /**
+ * Reset for testing.
+ */
+ public static void reset() {
+ instance = null;
+ }
+
+ /**
+ * Parse configuration from YAML.
+ * @param caches The caches YAML mapping
+ * @return Parsed config
+ */
+ public static NegativeCacheConfig fromYaml(final YamlMapping caches) {
+ if (caches == null) {
+ return new NegativeCacheConfig();
+ }
+ final YamlMapping negative = caches.yamlMapping("negative");
+ if (negative == null) {
+ return new NegativeCacheConfig();
+ }
+
+ final Duration ttl = parseDuration(negative.string("ttl"), DEFAULT_TTL);
+ final int maxSize = parseInt(negative.string("maxSize"), DEFAULT_MAX_SIZE);
+
+ // Check for valkey sub-config
+ final YamlMapping valkey = negative.yamlMapping("valkey");
+ if (valkey != null && "true".equalsIgnoreCase(valkey.string("enabled"))) {
+ return new NegativeCacheConfig(
+ ttl,
+ maxSize,
+ true,
+ parseInt(valkey.string("l1MaxSize"), DEFAULT_L1_MAX_SIZE),
+ parseDuration(valkey.string("l1Ttl"), DEFAULT_L1_TTL),
+ parseInt(valkey.string("l2MaxSize"), DEFAULT_L2_MAX_SIZE),
+ parseDuration(valkey.string("l2Ttl"), DEFAULT_L2_TTL),
+ parseDuration(valkey.string("timeout"), DEFAULT_L2_TIMEOUT)
+ );
+ }
+
+ return new NegativeCacheConfig(ttl, maxSize, false,
+ DEFAULT_L1_MAX_SIZE, DEFAULT_L1_TTL, DEFAULT_L2_MAX_SIZE, DEFAULT_L2_TTL, DEFAULT_L2_TIMEOUT);
+ }
+
+ /**
+ * Parse duration string (e.g., "24h", "7d", "5m").
+ * @param value Duration string
+ * @param defaultVal Default value
+ * @return Parsed duration
+ */
+ private static Duration parseDuration(final String value, final Duration defaultVal) {
+ if (value == null || value.isEmpty()) {
+ return defaultVal;
+ }
+ try {
+ final String trimmed = value.trim().toLowerCase();
+ if (trimmed.endsWith("d")) {
+ return Duration.ofDays(Long.parseLong(trimmed.substring(0, trimmed.length() - 1)));
+ } else if (trimmed.endsWith("h")) {
+ return Duration.ofHours(Long.parseLong(trimmed.substring(0, trimmed.length() - 1)));
+ } else if (trimmed.endsWith("m")) {
+ return Duration.ofMinutes(Long.parseLong(trimmed.substring(0, trimmed.length() - 1)));
+ } else if (trimmed.endsWith("s")) {
+ return Duration.ofSeconds(Long.parseLong(trimmed.substring(0, trimmed.length() - 1)));
+ }
+ return Duration.ofSeconds(Long.parseLong(trimmed));
+ } catch (final NumberFormatException ex) {
+ return defaultVal;
+ }
+ }
+
+ /**
+ * Parse integer string.
+ * @param value Integer string
+ * @param defaultVal Default value
+ * @return Parsed integer
+ */
+ private static int parseInt(final String value, final int defaultVal) {
+ if (value == null || value.isEmpty()) {
+ return defaultVal;
+ }
+ try {
+ return Integer.parseInt(value.trim());
+ } catch (final NumberFormatException ex) {
+ return defaultVal;
+ }
+ }
+
+ @Override
+ public String toString() {
+ return String.format(
+ "NegativeCacheConfig{ttl=%s, maxSize=%d, valkeyEnabled=%s, l1MaxSize=%d, l1Ttl=%s, l2MaxSize=%d, l2Ttl=%s}",
+ this.ttl, this.maxSize, this.valkeyEnabled, this.l1MaxSize, this.l1Ttl, this.l2MaxSize, this.l2Ttl
+ );
+ }
+}
+
diff --git a/artipie-core/src/main/java/com/artipie/cache/StoragesCache.java b/artipie-core/src/main/java/com/artipie/cache/StoragesCache.java
new file mode 100644
index 000000000..83a107055
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cache/StoragesCache.java
@@ -0,0 +1,186 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cache;
+
+import com.amihaiemil.eoyaml.YamlMapping;
+import com.artipie.ArtipieException;
+import com.artipie.asto.Storage;
+import com.artipie.asto.factory.Config;
+import com.artipie.asto.factory.StoragesLoader;
+import com.artipie.asto.misc.Cleanable;
+import com.artipie.misc.ArtipieProperties;
+import com.artipie.misc.Property;
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.RemovalCause;
+import com.google.common.base.Strings;
+import org.apache.commons.lang3.NotImplementedException;
+import com.artipie.http.log.EcsLogger;
+
+import java.time.Duration;
+
+/**
+ * Implementation of cache for storages with similar configurations
+ * in Artipie settings using Caffeine.
+ * Properly closes Storage instances when evicted from cache to prevent resource leaks.
+ *
+ * Configuration in _server.yaml:
+ *
+ * caches:
+ * storage:
+ * profile: small # Or direct: maxSize: 1000, ttl: 3m
+ *
+ *
+ * @since 0.23
+ */
+public class StoragesCache implements Cleanable {
+
+ /**
+ * Cache for storages.
+ */
+ private final Cache cache;
+
+ /**
+ * Ctor with default configuration.
+ */
+ public StoragesCache() {
+ this(new CacheConfig(
+ Duration.ofMillis(
+ new Property(ArtipieProperties.STORAGE_TIMEOUT).asLongOrDefault(180_000L)
+ ),
+ 1000 // Default: 1000 storage instances max
+ ));
+ }
+
+ /**
+ * Ctor with custom configuration.
+ * @param config Cache configuration
+ */
+ public StoragesCache(final CacheConfig config) {
+ this.cache = Caffeine.newBuilder()
+ .maximumSize(config.maxSize())
+ .expireAfterWrite(config.ttl())
+ .recordStats()
+ .evictionListener(this::onEviction)
+ .build();
+ EcsLogger.info("com.artipie.cache")
+ .message("StoragesCache initialized with config: " + config.toString())
+ .eventCategory("cache")
+ .eventAction("cache_init")
+ .eventOutcome("success")
+ .log();
+
+
+ }
+
+ /**
+ * Finds storage by specified in settings configuration cache or creates
+ * a new item and caches it.
+ *
+ * @param yaml Storage settings
+ * @return Storage
+ */
+ public Storage storage(final YamlMapping yaml) {
+ final String type = yaml.string("type");
+ if (Strings.isNullOrEmpty(type)) {
+ throw new ArtipieException("Storage type cannot be null or empty.");
+ }
+
+ final long startNanos = System.nanoTime();
+ final Storage existing = this.cache.getIfPresent(yaml);
+
+ if (existing != null) {
+ // Cache HIT
+ final long durationMs = (System.nanoTime() - startNanos) / 1_000_000;
+ if (com.artipie.metrics.MicrometerMetrics.isInitialized()) {
+ com.artipie.metrics.MicrometerMetrics.getInstance().recordCacheHit("storage", "l1");
+ com.artipie.metrics.MicrometerMetrics.getInstance()
+ .recordCacheOperationDuration("storage", "l1", "get", durationMs);
+ }
+ return existing;
+ }
+
+ // Cache MISS - create new storage
+ final long durationMs = (System.nanoTime() - startNanos) / 1_000_000;
+ if (com.artipie.metrics.MicrometerMetrics.isInitialized()) {
+ com.artipie.metrics.MicrometerMetrics.getInstance().recordCacheMiss("storage", "l1");
+ com.artipie.metrics.MicrometerMetrics.getInstance()
+ .recordCacheOperationDuration("storage", "l1", "get", durationMs);
+ }
+
+ final long putStartNanos = System.nanoTime();
+ final Storage storage = this.cache.get(
+ yaml,
+ key -> {
+ // Direct storage without JfrStorage wrapper
+ // JFR profiling removed - adds 2-10% overhead and bypassed by optimized slices
+ // Request-level metrics still active via Vert.x HTTP
+ return StoragesLoader.STORAGES
+ .newObject(type, new Config.YamlStorageConfig(key));
+ }
+ );
+
+ // Record PUT latency
+ final long putDurationMs = (System.nanoTime() - putStartNanos) / 1_000_000;
+ if (com.artipie.metrics.MicrometerMetrics.isInitialized()) {
+ com.artipie.metrics.MicrometerMetrics.getInstance()
+ .recordCacheOperationDuration("storage", "l1", "put", putDurationMs);
+ }
+
+ return storage;
+ }
+
+ /**
+ * Returns the approximate number of entries in this cache.
+ *
+ * @return Number of entries
+ */
+ public long size() {
+ return this.cache.estimatedSize();
+ }
+
+ @Override
+ public String toString() {
+ return String.format("%s(size=%d)", this.getClass().getSimpleName(), this.cache.estimatedSize());
+ }
+
+ @Override
+ public void invalidate(final YamlMapping mapping) {
+ throw new NotImplementedException("This method is not supported in cached storages!");
+ }
+
+ @Override
+ public void invalidateAll() {
+ this.cache.invalidateAll();
+ }
+
+ /**
+ * Handle storage eviction - log eviction event and record metrics.
+ * Note: Storage interface doesn't extend AutoCloseable, so we just log.
+ * @param key Cache key
+ * @param storage Storage instance
+ * @param cause Eviction cause
+ */
+ private void onEviction(
+ final YamlMapping key,
+ final Storage storage,
+ final RemovalCause cause
+ ) {
+ if (storage != null && key != null) {
+ EcsLogger.debug("com.artipie.cache")
+ .message("Storage evicted from cache (type: " + key.string("type") + ", cause: " + cause.toString() + ")")
+ .eventCategory("cache")
+ .eventAction("cache_evict")
+ .eventOutcome("success")
+ .log();
+
+ // Record eviction metric
+ if (com.artipie.metrics.MicrometerMetrics.isInitialized()) {
+ com.artipie.metrics.MicrometerMetrics.getInstance()
+ .recordCacheEviction("storage", "l1", cause.toString().toLowerCase());
+ }
+ }
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/cache/ValkeyConnection.java b/artipie-core/src/main/java/com/artipie/cache/ValkeyConnection.java
new file mode 100644
index 000000000..8c94c41e7
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cache/ValkeyConnection.java
@@ -0,0 +1,130 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cache;
+
+import io.lettuce.core.RedisClient;
+import io.lettuce.core.RedisURI;
+import io.lettuce.core.api.StatefulRedisConnection;
+import io.lettuce.core.api.async.RedisAsyncCommands;
+import io.lettuce.core.codec.ByteArrayCodec;
+import io.lettuce.core.codec.RedisCodec;
+import io.lettuce.core.codec.StringCodec;
+import java.time.Duration;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Valkey/Redis connection for L2 cache across Artipie.
+ * Shared connection used by all two-tier caches.
+ * Thread-safe, async operations.
+ *
+ * @since 1.0
+ */
+public final class ValkeyConnection implements AutoCloseable {
+
+ /**
+ * Redis client.
+ */
+ private final RedisClient client;
+
+ /**
+ * Stateful connection.
+ */
+ private final StatefulRedisConnection connection;
+
+ /**
+ * Async commands interface.
+ */
+ private final RedisAsyncCommands async;
+
+ /**
+ * Constructor from configuration.
+ *
+ * @param config Cache configuration with Valkey settings
+ */
+ public ValkeyConnection(final CacheConfig config) {
+ this(
+ config.valkeyHost().orElse("localhost"),
+ config.valkeyPort().orElse(6379),
+ config.valkeyTimeout().orElse(Duration.ofMillis(100))
+ );
+ }
+
+ /**
+ * Constructor with explicit parameters.
+ *
+ * @param host Valkey/Redis host
+ * @param port Valkey/Redis port
+ * @param timeout Request timeout
+ */
+ public ValkeyConnection(
+ final String host,
+ final int port,
+ final Duration timeout
+ ) {
+ this.client = RedisClient.create(
+ RedisURI.builder()
+ .withHost(Objects.requireNonNull(host))
+ .withPort(port)
+ .withTimeout(timeout)
+ .build()
+ );
+ // Use String keys and byte[] values
+ final RedisCodec codec = RedisCodec.of(
+ StringCodec.UTF8,
+ ByteArrayCodec.INSTANCE
+ );
+ this.connection = this.client.connect(codec);
+ this.async = this.connection.async();
+ // Enable pipelining for better throughput
+ this.async.setAutoFlushCommands(true);
+ }
+
+ /**
+ * Get async commands interface.
+ *
+ * @return Redis async commands
+ */
+ public RedisAsyncCommands async() {
+ return this.async;
+ }
+
+ /**
+ * Ping to check connectivity (blocking).
+ * WARNING: Blocks calling thread. Use pingAsync() for non-blocking health checks.
+ *
+ * @return True if connected
+ * @deprecated Use pingAsync() to avoid blocking
+ */
+ @Deprecated
+ public boolean ping() {
+ try {
+ return "PONG".equals(this.async.ping().get());
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ /**
+ * Async ping to check connectivity (non-blocking).
+ * Preferred over blocking ping() method.
+ *
+ * @return Future with true if connected, false on timeout or error
+ */
+ public CompletableFuture pingAsync() {
+ return this.async.ping()
+ .toCompletableFuture()
+ .orTimeout(1000, TimeUnit.MILLISECONDS)
+ .thenApply(pong -> "PONG".equals(pong))
+ .exceptionally(err -> false);
+ }
+
+ @Override
+ public void close() {
+ this.connection.close();
+ this.client.shutdown();
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/CachedCooldownInspector.java b/artipie-core/src/main/java/com/artipie/cooldown/CachedCooldownInspector.java
new file mode 100644
index 000000000..86daa3954
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/CachedCooldownInspector.java
@@ -0,0 +1,304 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown;
+
+import com.artipie.cache.CacheConfig;
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+/**
+ * Caching wrapper for CooldownInspector implementations.
+ * Prevents redundant HTTP requests to upstream registries.
+ *
+ * @since 1.0
+ */
+public final class CachedCooldownInspector implements CooldownInspector {
+
+ /**
+ * Underlying inspector.
+ */
+ private final CooldownInspector delegate;
+
+ /**
+ * Release date cache.
+ */
+ private final Cache> releaseDates;
+
+ /**
+ * Dependency cache.
+ */
+ private final Cache> dependencies;
+
+ /**
+ * In-flight release date requests to prevent duplicate concurrent fetches.
+ */
+ private final ConcurrentMap>> inflightReleases;
+
+ /**
+ * In-flight dependency requests to prevent duplicate concurrent fetches.
+ */
+ private final ConcurrentMap>> inflightDeps;
+
+ /**
+ * Constructor with default cache settings.
+ * - Release dates: 50,000 entries, 30 minute TTL
+ * - Dependencies: 10,000 entries, 30 minute TTL
+ *
+ * @param delegate Underlying inspector
+ */
+ public CachedCooldownInspector(final CooldownInspector delegate) {
+ this(delegate, 50_000, Duration.ofMinutes(30), 10_000, Duration.ofMinutes(30));
+ }
+
+ /**
+ * Constructor with CacheConfig support.
+ * Uses configured TTL and size settings.
+ *
+ * @param delegate Underlying inspector
+ * @param config Cache configuration from "cooldown" cache section
+ */
+ public CachedCooldownInspector(final CooldownInspector delegate, final CacheConfig config) {
+ this(
+ delegate,
+ config.valkeyEnabled() ? config.l1MaxSize() : config.maxSize(),
+ config.valkeyEnabled() ? config.l1Ttl() : config.ttl(),
+ config.valkeyEnabled() ? config.l1MaxSize() / 5 : config.maxSize() / 5,
+ config.valkeyEnabled() ? config.l1Ttl() : config.ttl()
+ );
+ }
+
+ /**
+ * Constructor with custom cache settings.
+ *
+ * @param delegate Underlying inspector
+ * @param releaseDateMaxSize Maximum release date cache size
+ * @param releaseDateTtl Release date cache TTL
+ * @param dependencyMaxSize Maximum dependency cache size
+ * @param dependencyTtl Dependency cache TTL
+ */
+ public CachedCooldownInspector(
+ final CooldownInspector delegate,
+ final long releaseDateMaxSize,
+ final Duration releaseDateTtl,
+ final long dependencyMaxSize,
+ final Duration dependencyTtl
+ ) {
+ this.delegate = delegate;
+ this.releaseDates = Caffeine.newBuilder()
+ .maximumSize(releaseDateMaxSize)
+ .expireAfterWrite(releaseDateTtl)
+ .recordStats()
+ .evictionListener(this::onEviction)
+ .build();
+ this.dependencies = Caffeine.newBuilder()
+ .maximumSize(dependencyMaxSize)
+ .expireAfterWrite(dependencyTtl)
+ .recordStats()
+ .evictionListener(this::onEviction)
+ .build();
+ this.inflightReleases = new ConcurrentHashMap<>();
+ this.inflightDeps = new ConcurrentHashMap<>();
+ }
+
+ @Override
+ public CompletableFuture> releaseDate(
+ final String artifact,
+ final String version
+ ) {
+ final String key = key(artifact, version);
+
+ // Fast path: cached result with metrics
+ final long getStartNanos = System.nanoTime();
+ final Optional cached = this.releaseDates.getIfPresent(key);
+ final long getDurationMs = (System.nanoTime() - getStartNanos) / 1_000_000;
+
+ if (cached != null) {
+ // Cache HIT
+ if (com.artipie.metrics.MicrometerMetrics.isInitialized()) {
+ com.artipie.metrics.MicrometerMetrics.getInstance()
+ .recordCacheHit("cooldown_inspector", "l1");
+ com.artipie.metrics.MicrometerMetrics.getInstance()
+ .recordCacheOperationDuration("cooldown_inspector", "l1", "get", getDurationMs);
+ }
+ return CompletableFuture.completedFuture(cached);
+ }
+
+ // Cache MISS
+ if (com.artipie.metrics.MicrometerMetrics.isInitialized()) {
+ com.artipie.metrics.MicrometerMetrics.getInstance()
+ .recordCacheMiss("cooldown_inspector", "l1");
+ com.artipie.metrics.MicrometerMetrics.getInstance()
+ .recordCacheOperationDuration("cooldown_inspector", "l1", "get", getDurationMs);
+ }
+
+ // Deduplication: check if already fetching
+ final CompletableFuture> existing = this.inflightReleases.get(key);
+ if (existing != null) {
+ if (com.artipie.metrics.MicrometerMetrics.isInitialized()) {
+ com.artipie.metrics.MicrometerMetrics.getInstance()
+ .recordCacheDeduplication("cooldown_inspector", "l1");
+ }
+ return existing;
+ }
+
+ // Fetch from delegate
+ final CompletableFuture> future = this.delegate.releaseDate(artifact, version)
+ .whenComplete((result, error) -> {
+ this.inflightReleases.remove(key);
+ if (error == null && result != null) {
+ final long putStartNanos = System.nanoTime();
+ this.releaseDates.put(key, result);
+ final long putDurationMs = (System.nanoTime() - putStartNanos) / 1_000_000;
+
+ if (com.artipie.metrics.MicrometerMetrics.isInitialized()) {
+ com.artipie.metrics.MicrometerMetrics.getInstance()
+ .recordCacheOperationDuration("cooldown_inspector", "l1", "put", putDurationMs);
+ }
+ }
+ });
+
+ this.inflightReleases.put(key, future);
+ return future;
+ }
+
+ @Override
+ public CompletableFuture> dependencies(
+ final String artifact,
+ final String version
+ ) {
+ final String key = key(artifact, version);
+
+ // Fast path: cached result with metrics
+ final long getStartNanos = System.nanoTime();
+ final List cached = this.dependencies.getIfPresent(key);
+ final long getDurationMs = (System.nanoTime() - getStartNanos) / 1_000_000;
+
+ if (cached != null) {
+ // Cache HIT
+ if (com.artipie.metrics.MicrometerMetrics.isInitialized()) {
+ com.artipie.metrics.MicrometerMetrics.getInstance()
+ .recordCacheHit("cooldown_inspector", "l1");
+ com.artipie.metrics.MicrometerMetrics.getInstance()
+ .recordCacheOperationDuration("cooldown_inspector", "l1", "get", getDurationMs);
+ }
+ return CompletableFuture.completedFuture(cached);
+ }
+
+ // Cache MISS
+ if (com.artipie.metrics.MicrometerMetrics.isInitialized()) {
+ com.artipie.metrics.MicrometerMetrics.getInstance()
+ .recordCacheMiss("cooldown_inspector", "l1");
+ com.artipie.metrics.MicrometerMetrics.getInstance()
+ .recordCacheOperationDuration("cooldown_inspector", "l1", "get", getDurationMs);
+ }
+
+ // Deduplication: check if already fetching
+ final CompletableFuture> existing = this.inflightDeps.get(key);
+ if (existing != null) {
+ if (com.artipie.metrics.MicrometerMetrics.isInitialized()) {
+ com.artipie.metrics.MicrometerMetrics.getInstance()
+ .recordCacheDeduplication("cooldown_inspector", "l1");
+ }
+ return existing;
+ }
+
+ // Fetch from delegate
+ final CompletableFuture> future = this.delegate.dependencies(artifact, version)
+ .whenComplete((result, error) -> {
+ this.inflightDeps.remove(key);
+ if (error == null && result != null) {
+ final long putStartNanos = System.nanoTime();
+ this.dependencies.put(key, result);
+ final long putDurationMs = (System.nanoTime() - putStartNanos) / 1_000_000;
+
+ if (com.artipie.metrics.MicrometerMetrics.isInitialized()) {
+ com.artipie.metrics.MicrometerMetrics.getInstance()
+ .recordCacheOperationDuration("cooldown_inspector", "l1", "put", putDurationMs);
+ }
+ }
+ });
+
+ this.inflightDeps.put(key, future);
+ return future;
+ }
+
+ @Override
+ public CompletableFuture>> releaseDatesBatch(
+ final Collection deps
+ ) {
+ // Use delegate's batch implementation if available
+ return this.delegate.releaseDatesBatch(deps)
+ .whenComplete((results, error) -> {
+ if (error == null && results != null) {
+ // Cache all batch results with metrics
+ final long putStartNanos = System.nanoTime();
+ results.forEach((dep, date) -> {
+ final String key = key(dep.artifact(), dep.version());
+ this.releaseDates.put(key, date);
+ });
+ final long putDurationMs = (System.nanoTime() - putStartNanos) / 1_000_000;
+
+ if (com.artipie.metrics.MicrometerMetrics.isInitialized()) {
+ com.artipie.metrics.MicrometerMetrics.getInstance()
+ .recordCacheOperationDuration("cooldown_inspector", "l1", "put", putDurationMs);
+ }
+ }
+ });
+ }
+
+ /**
+ * Get cache statistics.
+ *
+ * @return Statistics string
+ */
+ public String stats() {
+ return String.format(
+ "CachedInspector[releases=%d, deps=%d]",
+ this.releaseDates.estimatedSize(),
+ this.dependencies.estimatedSize()
+ );
+ }
+
+ /**
+ * Clear all caches.
+ */
+ public void clear() {
+ this.releaseDates.invalidateAll();
+ this.dependencies.invalidateAll();
+ this.inflightReleases.clear();
+ this.inflightDeps.clear();
+ }
+
+ private static String key(final String artifact, final String version) {
+ return artifact + ":" + version;
+ }
+
+ /**
+ * Handle cache eviction - record metrics.
+ * This listener is used by both releaseDates and dependencies caches.
+ * @param key Cache key
+ * @param value Cached value
+ * @param cause Eviction cause
+ */
+ private void onEviction(
+ final String key,
+ final Object value,
+ final com.github.benmanes.caffeine.cache.RemovalCause cause
+ ) {
+ if (com.artipie.metrics.MicrometerMetrics.isInitialized()) {
+ com.artipie.metrics.MicrometerMetrics.getInstance()
+ .recordCacheEviction("cooldown_inspector", "l1", cause.toString().toLowerCase());
+ }
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/CooldownBlock.java b/artipie-core/src/main/java/com/artipie/cooldown/CooldownBlock.java
new file mode 100644
index 000000000..1ef7ab760
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/CooldownBlock.java
@@ -0,0 +1,77 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown;
+
+import java.time.Instant;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Information about an active cooldown block.
+ */
+public final class CooldownBlock {
+
+ private final String repoType;
+ private final String repoName;
+ private final String artifact;
+ private final String version;
+ private final CooldownReason reason;
+ private final Instant blockedAt;
+ private final Instant blockedUntil;
+ private final List dependencies;
+
+ public CooldownBlock(
+ final String repoType,
+ final String repoName,
+ final String artifact,
+ final String version,
+ final CooldownReason reason,
+ final Instant blockedAt,
+ final Instant blockedUntil,
+ final List dependencies
+ ) {
+ this.repoType = Objects.requireNonNull(repoType);
+ this.repoName = Objects.requireNonNull(repoName);
+ this.artifact = Objects.requireNonNull(artifact);
+ this.version = Objects.requireNonNull(version);
+ this.reason = Objects.requireNonNull(reason);
+ this.blockedAt = Objects.requireNonNull(blockedAt);
+ this.blockedUntil = Objects.requireNonNull(blockedUntil);
+ this.dependencies = List.copyOf(Objects.requireNonNull(dependencies));
+ }
+
+ public String repoType() {
+ return this.repoType;
+ }
+
+ public String repoName() {
+ return this.repoName;
+ }
+
+ public String artifact() {
+ return this.artifact;
+ }
+
+ public String version() {
+ return this.version;
+ }
+
+ public CooldownReason reason() {
+ return this.reason;
+ }
+
+ public Instant blockedAt() {
+ return this.blockedAt;
+ }
+
+ public Instant blockedUntil() {
+ return this.blockedUntil;
+ }
+
+ public List dependencies() {
+ return Collections.unmodifiableList(this.dependencies);
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/CooldownCache.java b/artipie-core/src/main/java/com/artipie/cooldown/CooldownCache.java
new file mode 100644
index 000000000..5676568f5
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/CooldownCache.java
@@ -0,0 +1,446 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown;
+
+import com.artipie.cache.ValkeyConnection;
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.Expiry;
+import io.lettuce.core.RedisFuture;
+import io.lettuce.core.api.async.RedisAsyncCommands;
+import java.nio.ByteBuffer;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.TimeUnit;
+import org.checkerframework.checker.index.qual.NonNegative;
+
+/**
+ * High-performance cache for cooldown block decisions.
+ * Three-tier architecture:
+ * - L1 (in-memory): Hot data, sub-millisecond lookups
+ * - L2 (Valkey/Redis): Warm data, shared across instances
+ * - L3 (Database): Source of truth, persistent storage
+ *
+ * Cache Key Format: cooldown:{repo_name}:{artifact}:{version}:block
+ * Cache Value: true (blocked) | false (allowed)
+ *
+ * @since 1.0
+ */
+public final class CooldownCache {
+
+ /**
+ * L1 cache for block decisions (in-memory, hot data).
+ * Key: cooldown:{repo_name}:{artifact}:{version}:block
+ * Value: true (blocked) | false (allowed)
+ */
+ private final Cache decisions;
+
+ /**
+ * L2 cache (Valkey/Redis, warm data) - optional.
+ */
+ private final RedisAsyncCommands l2;
+
+ /**
+ * Whether two-tier caching is enabled.
+ */
+ private final boolean twoTier;
+
+ /**
+ * TTL for allowed (false) entries in L1 cache.
+ */
+ private final Duration l1AllowedTtl;
+
+ /**
+ * TTL for allowed (false) entries in L2 cache (seconds).
+ */
+ private final long l2AllowedTtlSeconds;
+
+ /**
+ * In-flight requests to prevent duplicate concurrent evaluations.
+ * Key: cooldown:{repoName}:{artifact}:{version}:block
+ * Value: CompletableFuture
+ */
+ private final ConcurrentMap> inflight;
+
+ /**
+ * Cache statistics.
+ */
+ private volatile long hits;
+ private volatile long misses;
+ private volatile long deduplications;
+
+ /**
+ * Constructor with default settings.
+ * Auto-connects to Valkey if GlobalCacheConfig is initialized.
+ * - Decision cache: 10,000 entries
+ * - Default TTL: 24 hours (single-tier) or 5min/1h (two-tier)
+ */
+ public CooldownCache() {
+ this(
+ 10_000,
+ Duration.ofHours(24), // Default single-tier TTL
+ com.artipie.cache.GlobalCacheConfig.valkeyConnection().orElse(null)
+ );
+ }
+
+ /**
+ * Constructor with Valkey connection (two-tier) and default TTLs.
+ * - L1 (in-memory): 10,000 entries, 5min TTL for allowed
+ * - L2 (Valkey): unlimited entries, 1h TTL for allowed
+ *
+ * @param valkey Valkey connection for L2 cache
+ */
+ public CooldownCache(final ValkeyConnection valkey) {
+ this(10_000, Duration.ofMinutes(5), Duration.ofHours(1), valkey);
+ }
+
+ /**
+ * Constructor with custom settings (single-tier).
+ *
+ * @param decisionMaxSize Maximum decision cache size
+ * @param allowedTtl TTL for allowed (false) cache entries
+ * @param valkey Valkey connection for L2 cache (null for single-tier)
+ */
+ public CooldownCache(
+ final long decisionMaxSize,
+ final Duration allowedTtl,
+ final ValkeyConnection valkey
+ ) {
+ this(
+ decisionMaxSize,
+ allowedTtl,
+ valkey != null ? Duration.ofHours(1) : allowedTtl, // L2 defaults to 1h or same as L1
+ valkey
+ );
+ }
+
+ /**
+ * Constructor with custom settings (two-tier).
+ *
+ * @param decisionMaxSize Maximum decision cache size (L1)
+ * @param l1AllowedTtl TTL for allowed (false) entries in L1
+ * @param l2AllowedTtl TTL for allowed (false) entries in L2
+ * @param valkey Valkey connection for L2 cache (null for single-tier)
+ */
+ public CooldownCache(
+ final long decisionMaxSize,
+ final Duration l1AllowedTtl,
+ final Duration l2AllowedTtl,
+ final ValkeyConnection valkey
+ ) {
+ this.twoTier = (valkey != null);
+ this.l2 = this.twoTier ? valkey.async() : null;
+ this.l1AllowedTtl = l1AllowedTtl;
+ this.l2AllowedTtlSeconds = l2AllowedTtl.getSeconds();
+
+ // L1 Boolean cache: Simple true/false for block decisions
+ // TTL: Configurable for allowed entries
+ this.decisions = Caffeine.newBuilder()
+ .maximumSize(decisionMaxSize)
+ .expireAfterWrite(l1AllowedTtl)
+ .recordStats()
+ .build();
+
+ this.inflight = new ConcurrentHashMap<>();
+ this.hits = 0;
+ this.misses = 0;
+ this.deduplications = 0;
+ }
+
+ /**
+ * Check if artifact is blocked (3-tier lookup).
+ * Returns true if blocked, false if allowed.
+ *
+ * @param repoName Repository name
+ * @param artifact Artifact name
+ * @param version Version
+ * @param dbQuery Database query function (only called on L1+L2 miss)
+ * @return CompletableFuture with true (blocked) or false (allowed)
+ */
+ public CompletableFuture isBlocked(
+ final String repoName,
+ final String artifact,
+ final String version,
+ final java.util.function.Supplier> dbQuery
+ ) {
+ final String key = blockKey(repoName, artifact, version);
+ final long l1StartNanos = System.nanoTime();
+
+ // L1: Fast path - check in-memory cache
+ final Boolean l1Cached = this.decisions.getIfPresent(key);
+ if (l1Cached != null) {
+ this.hits++;
+ if (com.artipie.metrics.MicrometerMetrics.isInitialized()) {
+ final long durationMs = (System.nanoTime() - l1StartNanos) / 1_000_000;
+ com.artipie.metrics.MicrometerMetrics.getInstance().recordCacheHit("cooldown", "l1");
+ com.artipie.metrics.MicrometerMetrics.getInstance().recordCacheOperationDuration("cooldown", "l1", "get", durationMs);
+ }
+ return CompletableFuture.completedFuture(l1Cached);
+ }
+
+ // L1 MISS
+ this.misses++;
+ if (com.artipie.metrics.MicrometerMetrics.isInitialized()) {
+ final long durationMs = (System.nanoTime() - l1StartNanos) / 1_000_000;
+ com.artipie.metrics.MicrometerMetrics.getInstance().recordCacheMiss("cooldown", "l1");
+ com.artipie.metrics.MicrometerMetrics.getInstance().recordCacheOperationDuration("cooldown", "l1", "get", durationMs);
+ }
+
+ // Two-tier: Check L2 (Valkey) before database
+ if (this.twoTier) {
+ final long l2StartNanos = System.nanoTime();
+
+ return this.l2.get(key)
+ .toCompletableFuture()
+ .orTimeout(100, TimeUnit.MILLISECONDS)
+ .exceptionally(err -> {
+ // Track L2 error - metrics handled elsewhere
+ return null; // L2 failure → skip to database
+ })
+ .thenCompose(l2Bytes -> {
+ final long durationMs = (System.nanoTime() - l2StartNanos) / 1_000_000;
+
+ if (l2Bytes != null) {
+ // L2 HIT
+ if (com.artipie.metrics.MicrometerMetrics.isInitialized()) {
+ com.artipie.metrics.MicrometerMetrics.getInstance().recordCacheHit("cooldown", "l2");
+ com.artipie.metrics.MicrometerMetrics.getInstance().recordCacheOperationDuration("cooldown", "l2", "get", durationMs);
+ }
+
+ // Parse boolean and promote to L1
+ final boolean blocked = "true".equals(new String(l2Bytes));
+ this.decisions.put(key, blocked); // Promote to L1
+ return CompletableFuture.completedFuture(blocked);
+ }
+
+ // L2 MISS
+ if (com.artipie.metrics.MicrometerMetrics.isInitialized()) {
+ com.artipie.metrics.MicrometerMetrics.getInstance().recordCacheMiss("cooldown", "l2");
+ com.artipie.metrics.MicrometerMetrics.getInstance().recordCacheOperationDuration("cooldown", "l2", "get", durationMs);
+ }
+
+ // Query database
+ return this.queryAndCache(key, dbQuery);
+ });
+ }
+
+ // Single-tier: Query database
+ return this.queryAndCache(key, dbQuery);
+ }
+
+ /**
+ * Query database and cache result.
+ * Only caches ALLOWED (false) entries to L2 with configured TTL.
+ * BLOCKED (true) entries must be cached by caller using putBlocked() with dynamic TTL.
+ */
+ private CompletableFuture queryAndCache(
+ final String key,
+ final java.util.function.Supplier> dbQuery
+ ) {
+ // Deduplication: check if already querying
+ final CompletableFuture existing = this.inflight.get(key);
+ if (existing != null) {
+ this.deduplications++;
+ // Deduplication metrics can be added if needed
+ return existing;
+ }
+
+ // Query database
+ final CompletableFuture future = dbQuery.get()
+ .whenComplete((blocked, error) -> {
+ this.inflight.remove(key);
+ if (error == null && blocked != null) {
+ // Cache in L1
+ this.decisions.put(key, blocked);
+ // Cache in L2 only for ALLOWED entries (false)
+ // BLOCKED entries (true) are cached by service layer with dynamic TTL
+ if (this.twoTier && !blocked) {
+ this.putL2Boolean(key, false, this.l2AllowedTtlSeconds);
+ }
+ }
+ });
+
+ // Register inflight to deduplicate concurrent requests
+ this.inflight.put(key, future);
+
+ return future;
+ }
+
+ /**
+ * Cache a block decision with configured TTL for allowed entries.
+ *
+ * @param repoName Repository name
+ * @param artifact Artifact name
+ * @param version Version
+ * @param blocked True if blocked, false if allowed
+ */
+ public void put(
+ final String repoName,
+ final String artifact,
+ final String version,
+ final boolean blocked
+ ) {
+ final String key = blockKey(repoName, artifact, version);
+ // Store in L1
+ this.decisions.put(key, blocked);
+
+ // Store in L2 if two-tier (uses configured TTL)
+ if (this.twoTier) {
+ this.putL2Boolean(key, blocked, this.l2AllowedTtlSeconds);
+ }
+ }
+
+ /**
+ * Cache a blocked decision with dynamic TTL (until block expires).
+ * Only call this for blocked=true entries.
+ *
+ * @param repoName Repository name
+ * @param artifact Artifact name
+ * @param version Version
+ * @param blockedUntil When the block expires
+ */
+ public void putBlocked(
+ final String repoName,
+ final String artifact,
+ final String version,
+ final Instant blockedUntil
+ ) {
+ final String key = blockKey(repoName, artifact, version);
+ // Store in L1
+ this.decisions.put(key, true);
+
+ // Store in L2 with dynamic TTL (until block expires)
+ if (this.twoTier) {
+ final long ttlSeconds = Duration.between(Instant.now(), blockedUntil).getSeconds();
+ if (ttlSeconds > 0) {
+ this.putL2Boolean(key, true, ttlSeconds);
+ }
+ }
+ }
+
+ /**
+ * Unblock specific artifact (set cache to false).
+ * Called when artifact is manually unblocked.
+ *
+ * @param repoName Repository name
+ * @param artifact Artifact name
+ * @param version Version
+ */
+ public void unblock(
+ final String repoName,
+ final String artifact,
+ final String version
+ ) {
+ final String key = blockKey(repoName, artifact, version);
+
+ // Set L1 to false (allowed)
+ this.decisions.put(key, false);
+
+ // Set L2 to false with configured TTL
+ if (this.twoTier) {
+ this.l2.setex(key, this.l2AllowedTtlSeconds, "false".getBytes());
+ }
+ }
+
+ /**
+ * Unblock all artifacts in repository (set all to false).
+ * Called when all artifacts are manually unblocked.
+ *
+ * @param repoName Repository name
+ */
+ public void unblockAll(final String repoName) {
+ final String prefix = "cooldown:" + repoName + ":";
+
+ // L1: Set all matching keys to false
+ this.decisions.asMap().keySet().stream()
+ .filter(key -> key.startsWith(prefix))
+ .forEach(key -> this.decisions.put(key, false));
+
+ // L2: Pattern update (SCAN is expensive but unblockAll is rare)
+ if (this.twoTier) {
+ final String pattern = prefix + "*";
+ this.l2.keys(pattern).thenAccept(keys -> {
+ if (keys != null && !keys.isEmpty()) {
+ for (final String key : keys) {
+ // Set each key to false with configured TTL
+ this.l2.setex(key, this.l2AllowedTtlSeconds, "false".getBytes());
+ }
+ }
+ });
+ }
+ }
+
+ /**
+ * Get cache statistics.
+ *
+ * @return Statistics string
+ */
+ public String stats() {
+ final long total = this.hits + this.misses;
+ if (total == 0) {
+ return this.twoTier
+ ? "CooldownCache[two-tier, empty]"
+ : "CooldownCache[single-tier, empty]";
+ }
+
+ final double hitRate = 100.0 * this.hits / total;
+ final long dedup = this.deduplications;
+ final long decisions = this.decisions.estimatedSize();
+
+ if (this.twoTier) {
+ return String.format(
+ "CooldownCache[two-tier, L1: decisions=%d, hits=%d, misses=%d, hitRate=%.1f%%, dedup=%d]",
+ decisions, this.hits, this.misses, hitRate, dedup
+ );
+ }
+
+ return String.format(
+ "CooldownCache[single-tier, decisions=%d, hits=%d, misses=%d, hitRate=%.1f%%, dedup=%d]",
+ decisions, this.hits, this.misses, hitRate, dedup
+ );
+ }
+
+ /**
+ * Clear all caches.
+ */
+ public void clear() {
+ this.decisions.invalidateAll();
+ this.inflight.clear();
+ this.hits = 0;
+ this.misses = 0;
+ this.deduplications = 0;
+ }
+
+ /**
+ * Generate cache key for block decision.
+ * Format: cooldown:{repo_name}:{artifact}:{version}:block
+ */
+ private String blockKey(
+ final String repoName,
+ final String artifact,
+ final String version
+ ) {
+ return String.format(
+ "cooldown:%s:%s:%s:block",
+ repoName,
+ artifact,
+ version
+ );
+ }
+
+ /**
+ * Put boolean value in L2 cache with custom TTL.
+ */
+ private void putL2Boolean(final String key, final boolean blocked, final long ttlSeconds) {
+ final byte[] value = (blocked ? "true" : "false").getBytes();
+ this.l2.setex(key, ttlSeconds, value);
+ }
+
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/CooldownCircuitBreaker.java b/artipie-core/src/main/java/com/artipie/cooldown/CooldownCircuitBreaker.java
new file mode 100644
index 000000000..4e1d3a6b0
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/CooldownCircuitBreaker.java
@@ -0,0 +1,239 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Circuit breaker for cooldown service to prevent cascading failures.
+ * Automatically degrades to ALLOW mode if cooldown service is slow or failing.
+ *
+ * States:
+ * - CLOSED: Normal operation, all requests evaluated
+ * - OPEN: Too many failures, automatically allow all requests
+ * - HALF_OPEN: Testing if service recovered
+ *
+ * @since 1.0
+ */
+public final class CooldownCircuitBreaker {
+
+ /**
+ * Circuit breaker state.
+ */
+ public enum State {
+ CLOSED,
+ OPEN,
+ HALF_OPEN
+ }
+
+ /**
+ * Current state.
+ */
+ private final AtomicReference state;
+
+ /**
+ * Consecutive failure count.
+ */
+ private final AtomicInteger failures;
+
+ /**
+ * Time when circuit was opened.
+ */
+ private final AtomicLong openedAt;
+
+ /**
+ * Total requests processed.
+ */
+ private final AtomicLong totalRequests;
+
+ /**
+ * Total requests allowed due to open circuit.
+ */
+ private final AtomicLong autoAllowed;
+
+ /**
+ * Failure threshold before opening circuit.
+ */
+ private final int failureThreshold;
+
+ /**
+ * Duration to wait before attempting recovery.
+ */
+ private final Duration recoveryTimeout;
+
+ /**
+ * Success threshold in HALF_OPEN state before closing circuit.
+ */
+ private final int successThreshold;
+
+ /**
+ * Consecutive successes in HALF_OPEN state.
+ */
+ private final AtomicInteger halfOpenSuccesses;
+
+ /**
+ * Constructor with default settings.
+ * - Failure threshold: 5
+ * - Recovery timeout: 30 seconds
+ * - Success threshold: 2
+ */
+ public CooldownCircuitBreaker() {
+ this(5, Duration.ofSeconds(30), 2);
+ }
+
+ /**
+ * Constructor with custom settings.
+ *
+ * @param failureThreshold Failures before opening circuit
+ * @param recoveryTimeout Time to wait before recovery attempt
+ * @param successThreshold Successes in HALF_OPEN before closing
+ */
+ public CooldownCircuitBreaker(
+ final int failureThreshold,
+ final Duration recoveryTimeout,
+ final int successThreshold
+ ) {
+ this.state = new AtomicReference<>(State.CLOSED);
+ this.failures = new AtomicInteger(0);
+ this.openedAt = new AtomicLong(0);
+ this.totalRequests = new AtomicLong(0);
+ this.autoAllowed = new AtomicLong(0);
+ this.failureThreshold = failureThreshold;
+ this.recoveryTimeout = recoveryTimeout;
+ this.successThreshold = successThreshold;
+ this.halfOpenSuccesses = new AtomicInteger(0);
+ }
+
+ /**
+ * Check if request should be evaluated or auto-allowed.
+ *
+ * @return True if should evaluate, false if should auto-allow
+ */
+ public boolean shouldEvaluate() {
+ this.totalRequests.incrementAndGet();
+
+ final State current = this.state.get();
+
+ if (current == State.CLOSED) {
+ return true;
+ }
+
+ if (current == State.OPEN) {
+ // Check if recovery timeout elapsed
+ final long openTime = this.openedAt.get();
+ if (openTime > 0) {
+ final long elapsed = System.currentTimeMillis() - openTime;
+ if (elapsed >= this.recoveryTimeout.toMillis()) {
+ // Transition to HALF_OPEN
+ if (this.state.compareAndSet(State.OPEN, State.HALF_OPEN)) {
+ this.halfOpenSuccesses.set(0);
+ return true;
+ }
+ }
+ }
+ // Still open, auto-allow
+ this.autoAllowed.incrementAndGet();
+ return false;
+ }
+
+ // HALF_OPEN: Allow some requests through to test
+ return true;
+ }
+
+ /**
+ * Record successful evaluation.
+ */
+ public void recordSuccess() {
+ final State current = this.state.get();
+
+ if (current == State.CLOSED) {
+ // Reset failure counter on success
+ this.failures.set(0);
+ return;
+ }
+
+ if (current == State.HALF_OPEN) {
+ final int successes = this.halfOpenSuccesses.incrementAndGet();
+ if (successes >= this.successThreshold) {
+ // Recovered! Close circuit
+ if (this.state.compareAndSet(State.HALF_OPEN, State.CLOSED)) {
+ this.failures.set(0);
+ this.openedAt.set(0);
+ }
+ }
+ }
+ }
+
+ /**
+ * Record failed evaluation.
+ */
+ public void recordFailure() {
+ final State current = this.state.get();
+
+ if (current == State.CLOSED) {
+ final int count = this.failures.incrementAndGet();
+ if (count >= this.failureThreshold) {
+ // Open circuit
+ if (this.state.compareAndSet(State.CLOSED, State.OPEN)) {
+ this.openedAt.set(System.currentTimeMillis());
+ }
+ }
+ return;
+ }
+
+ if (current == State.HALF_OPEN) {
+ // Failed during recovery test, reopen circuit
+ if (this.state.compareAndSet(State.HALF_OPEN, State.OPEN)) {
+ this.openedAt.set(System.currentTimeMillis());
+ this.failures.set(this.failureThreshold);
+ }
+ }
+ }
+
+ /**
+ * Get current state.
+ *
+ * @return Current state
+ */
+ public State getState() {
+ return this.state.get();
+ }
+
+ /**
+ * Get statistics.
+ *
+ * @return Statistics string
+ */
+ public String stats() {
+ final long total = this.totalRequests.get();
+ final long allowed = this.autoAllowed.get();
+ final double allowRate = total == 0 ? 0.0 : (double) allowed / total * 100;
+
+ return String.format(
+ "CircuitBreaker[state=%s, failures=%d, total=%d, autoAllowed=%d (%.1f%%)]",
+ this.state.get(),
+ this.failures.get(),
+ total,
+ allowed,
+ allowRate
+ );
+ }
+
+ /**
+ * Reset circuit breaker.
+ */
+ public void reset() {
+ this.state.set(State.CLOSED);
+ this.failures.set(0);
+ this.openedAt.set(0);
+ this.halfOpenSuccesses.set(0);
+ this.totalRequests.set(0);
+ this.autoAllowed.set(0);
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/CooldownDependency.java b/artipie-core/src/main/java/com/artipie/cooldown/CooldownDependency.java
new file mode 100644
index 000000000..5ae530409
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/CooldownDependency.java
@@ -0,0 +1,29 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown;
+
+import java.util.Objects;
+
+/**
+ * Dependency that was blocked together with the main artifact.
+ */
+public final class CooldownDependency {
+
+ private final String artifact;
+ private final String version;
+
+ public CooldownDependency(final String artifact, final String version) {
+ this.artifact = Objects.requireNonNull(artifact);
+ this.version = Objects.requireNonNull(version);
+ }
+
+ public String artifact() {
+ return this.artifact;
+ }
+
+ public String version() {
+ return this.version;
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/CooldownInspector.java b/artipie-core/src/main/java/com/artipie/cooldown/CooldownInspector.java
new file mode 100644
index 000000000..050cdb88f
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/CooldownInspector.java
@@ -0,0 +1,62 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown;
+
+import java.time.Instant;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.stream.Collectors;
+
+/**
+ * Provides repository specific data required to evaluate cooldown decisions.
+ */
+public interface CooldownInspector {
+
+ /**
+ * Resolve release timestamp for artifact version if available.
+ *
+ * @param artifact Artifact identifier
+ * @param version Artifact version
+ * @return Future with optional release timestamp
+ */
+ CompletableFuture> releaseDate(String artifact, String version);
+
+ /**
+ * Resolve dependencies for the artifact version.
+ *
+ * @param artifact Artifact identifier
+ * @param version Artifact version
+ * @return Future with dependencies
+ */
+ CompletableFuture> dependencies(String artifact, String version);
+
+ /**
+ * Batch resolve release timestamps for dependency coordinates.
+ * Default implementation parallelizes single-item {@link #releaseDate(String, String)} calls.
+ * Implementations may override for efficiency.
+ *
+ * @param deps Dependency coordinates
+ * @return Future with a map of dependency -> optional release timestamp
+ */
+ default CompletableFuture>> releaseDatesBatch(
+ final Collection deps
+ ) {
+ if (deps == null || deps.isEmpty()) {
+ return CompletableFuture.completedFuture(java.util.Collections.emptyMap());
+ }
+ final List>>> futures =
+ deps.stream()
+ .map(dep -> this.releaseDate(dep.artifact(), dep.version())
+ .thenApply(ts -> Map.entry(dep, ts)))
+ .collect(Collectors.toList());
+ return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
+ .thenApply(v -> futures.stream()
+ .map(CompletableFuture::join)
+ .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)));
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/CooldownMetrics.java b/artipie-core/src/main/java/com/artipie/cooldown/CooldownMetrics.java
new file mode 100644
index 000000000..7d944dcb1
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/CooldownMetrics.java
@@ -0,0 +1,243 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Metrics for cooldown service to expose to Prometheus/Micrometer.
+ *
+ * @since 1.0
+ */
+public final class CooldownMetrics {
+
+ /**
+ * Total requests evaluated.
+ */
+ private final AtomicLong totalRequests = new AtomicLong(0);
+
+ /**
+ * Total requests blocked.
+ */
+ private final AtomicLong totalBlocked = new AtomicLong(0);
+
+ /**
+ * Total requests allowed.
+ */
+ private final AtomicLong totalAllowed = new AtomicLong(0);
+
+ /**
+ * Total requests auto-allowed due to circuit breaker.
+ */
+ private final AtomicLong circuitBreakerAutoAllowed = new AtomicLong(0);
+
+ /**
+ * Total cache hits.
+ */
+ private final AtomicLong cacheHits = new AtomicLong(0);
+
+ /**
+ * Total cache misses.
+ */
+ private final AtomicLong cacheMisses = new AtomicLong(0);
+
+ /**
+ * Blocked count per repository type.
+ * Key: repoType
+ */
+ private final ConcurrentMap blockedByRepoType = new ConcurrentHashMap<>();
+
+ /**
+ * Blocked count per repository.
+ * Key: repoType:repoName
+ */
+ private final ConcurrentMap blockedByRepo = new ConcurrentHashMap<>();
+
+ /**
+ * Record a cooldown evaluation.
+ *
+ * @param result Evaluation result
+ */
+ public void recordEvaluation(final CooldownResult result) {
+ this.totalRequests.incrementAndGet();
+ if (result.blocked()) {
+ this.totalBlocked.incrementAndGet();
+ } else {
+ this.totalAllowed.incrementAndGet();
+ }
+ }
+
+ /**
+ * Record a block by repository.
+ *
+ * @param repoType Repository type (maven, npm, etc.)
+ * @param repoName Repository name
+ */
+ public void recordBlock(final String repoType, final String repoName) {
+ this.blockedByRepoType.computeIfAbsent(repoType, k -> new AtomicLong(0)).incrementAndGet();
+ final String repoKey = repoType + ":" + repoName;
+ this.blockedByRepo.computeIfAbsent(repoKey, k -> new AtomicLong(0)).incrementAndGet();
+ }
+
+ /**
+ * Record circuit breaker auto-allow.
+ */
+ public void recordCircuitBreakerAutoAllow() {
+ this.circuitBreakerAutoAllowed.incrementAndGet();
+ }
+
+ /**
+ * Record cache hit.
+ */
+ public void recordCacheHit() {
+ this.cacheHits.incrementAndGet();
+ }
+
+ /**
+ * Record cache miss.
+ */
+ public void recordCacheMiss() {
+ this.cacheMisses.incrementAndGet();
+ }
+
+ /**
+ * Get total requests evaluated.
+ *
+ * @return Total requests
+ */
+ public long getTotalRequests() {
+ return this.totalRequests.get();
+ }
+
+ /**
+ * Get total requests blocked.
+ *
+ * @return Total blocked
+ */
+ public long getTotalBlocked() {
+ return this.totalBlocked.get();
+ }
+
+ /**
+ * Get total requests allowed.
+ *
+ * @return Total allowed
+ */
+ public long getTotalAllowed() {
+ return this.totalAllowed.get();
+ }
+
+ /**
+ * Get circuit breaker auto-allowed count.
+ *
+ * @return Auto-allowed count
+ */
+ public long getCircuitBreakerAutoAllowed() {
+ return this.circuitBreakerAutoAllowed.get();
+ }
+
+ /**
+ * Get cache hits.
+ *
+ * @return Cache hits
+ */
+ public long getCacheHits() {
+ return this.cacheHits.get();
+ }
+
+ /**
+ * Get cache misses.
+ *
+ * @return Cache misses
+ */
+ public long getCacheMisses() {
+ return this.cacheMisses.get();
+ }
+
+ /**
+ * Get cache hit rate as percentage.
+ *
+ * @return Hit rate (0-100)
+ */
+ public double getCacheHitRate() {
+ final long total = this.cacheHits.get() + this.cacheMisses.get();
+ return total == 0 ? 0.0 : (double) this.cacheHits.get() / total * 100.0;
+ }
+
+ /**
+ * Get blocked count for repository type.
+ *
+ * @param repoType Repository type
+ * @return Blocked count
+ */
+ public long getBlockedByRepoType(final String repoType) {
+ final AtomicLong counter = this.blockedByRepoType.get(repoType);
+ return counter == null ? 0 : counter.get();
+ }
+
+ /**
+ * Get blocked count for specific repository.
+ *
+ * @param repoType Repository type
+ * @param repoName Repository name
+ * @return Blocked count
+ */
+ public long getBlockedByRepo(final String repoType, final String repoName) {
+ final String repoKey = repoType + ":" + repoName;
+ final AtomicLong counter = this.blockedByRepo.get(repoKey);
+ return counter == null ? 0 : counter.get();
+ }
+
+ /**
+ * Get all repository types with blocks.
+ *
+ * @return Repository type names
+ */
+ public java.util.Set getRepoTypes() {
+ return this.blockedByRepoType.keySet();
+ }
+
+ /**
+ * Get all repositories with blocks.
+ *
+ * @return Repository keys (repoType:repoName)
+ */
+ public java.util.Set getRepos() {
+ return this.blockedByRepo.keySet();
+ }
+
+ /**
+ * Get metrics summary.
+ *
+ * @return Metrics string
+ */
+ public String summary() {
+ return String.format(
+ "CooldownMetrics[total=%d, blocked=%d (%.1f%%), allowed=%d, cacheHitRate=%.1f%%, circuitBreakerAutoAllowed=%d]",
+ this.totalRequests.get(),
+ this.totalBlocked.get(),
+ this.totalRequests.get() == 0 ? 0.0 : (double) this.totalBlocked.get() / this.totalRequests.get() * 100.0,
+ this.totalAllowed.get(),
+ getCacheHitRate(),
+ this.circuitBreakerAutoAllowed.get()
+ );
+ }
+
+ /**
+ * Reset all metrics.
+ */
+ public void reset() {
+ this.totalRequests.set(0);
+ this.totalBlocked.set(0);
+ this.totalAllowed.set(0);
+ this.circuitBreakerAutoAllowed.set(0);
+ this.cacheHits.set(0);
+ this.cacheMisses.set(0);
+ this.blockedByRepoType.clear();
+ this.blockedByRepo.clear();
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/CooldownReason.java b/artipie-core/src/main/java/com/artipie/cooldown/CooldownReason.java
new file mode 100644
index 000000000..849d21ab6
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/CooldownReason.java
@@ -0,0 +1,19 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown;
+
+/**
+ * Reasons for triggering a cooldown block.
+ */
+public enum CooldownReason {
+ /**
+ * Requested version is newer than the currently cached version.
+ */
+ NEWER_THAN_CACHE,
+ /**
+ * Requested version was released recently and has never been cached before.
+ */
+ FRESH_RELEASE
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/CooldownRequest.java b/artipie-core/src/main/java/com/artipie/cooldown/CooldownRequest.java
new file mode 100644
index 000000000..861ce5b3f
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/CooldownRequest.java
@@ -0,0 +1,61 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown;
+
+import java.time.Instant;
+import java.util.Objects;
+
+/**
+ * Input describing an artifact download request subject to cooldown checks.
+ */
+public final class CooldownRequest {
+
+ private final String repoType;
+ private final String repoName;
+ private final String artifact;
+ private final String version;
+ private final String requestedBy;
+ private final Instant requestedAt;
+
+ public CooldownRequest(
+ final String repoType,
+ final String repoName,
+ final String artifact,
+ final String version,
+ final String requestedBy,
+ final Instant requestedAt
+ ) {
+ this.repoType = Objects.requireNonNull(repoType);
+ this.repoName = Objects.requireNonNull(repoName);
+ this.artifact = Objects.requireNonNull(artifact);
+ this.version = Objects.requireNonNull(version);
+ this.requestedBy = Objects.requireNonNull(requestedBy);
+ this.requestedAt = Objects.requireNonNull(requestedAt);
+ }
+
+ public String repoType() {
+ return this.repoType;
+ }
+
+ public String repoName() {
+ return this.repoName;
+ }
+
+ public String artifact() {
+ return this.artifact;
+ }
+
+ public String version() {
+ return this.version;
+ }
+
+ public String requestedBy() {
+ return this.requestedBy;
+ }
+
+ public Instant requestedAt() {
+ return this.requestedAt;
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/CooldownResponses.java b/artipie-core/src/main/java/com/artipie/cooldown/CooldownResponses.java
new file mode 100644
index 000000000..e464db5ea
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/CooldownResponses.java
@@ -0,0 +1,97 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown;
+
+import com.artipie.http.Response;
+import com.artipie.http.ResponseBuilder;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.Locale;
+import javax.json.Json;
+import javax.json.JsonArrayBuilder;
+import javax.json.JsonObjectBuilder;
+
+/**
+ * Helper to build cooldown HTTP responses.
+ */
+public final class CooldownResponses {
+
+ private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
+
+ private CooldownResponses() {
+ }
+
+ public static Response forbidden(final CooldownBlock block) {
+ // Calculate human-readable message
+ // Note: blockedAt is when the block was created, blockedUntil is when it expires
+ // The release date can be inferred: releaseDate = blockedUntil - cooldownPeriod
+ final String message = String.format(
+ "Security Policy: Package %s@%s is blocked due to %s. "
+ + "Block created: %s. Available after: %s (remaining: %s). "
+ + "This is a security measure to protect against supply chain attacks on fresh releases.",
+ block.artifact(),
+ block.version(),
+ formatReason(block.reason()),
+ ISO.format(block.blockedAt().atOffset(ZoneOffset.UTC)),
+ ISO.format(block.blockedUntil().atOffset(ZoneOffset.UTC)),
+ formatRemainingTime(block.blockedUntil())
+ );
+
+ final JsonObjectBuilder json = Json.createObjectBuilder()
+ .add("error", "COOLDOWN_BLOCKED")
+ .add("message", message)
+ .add("repository", block.repoName())
+ .add("repositoryType", block.repoType())
+ .add("artifact", block.artifact())
+ .add("version", block.version())
+ .add("reason", block.reason().name().toLowerCase(Locale.US))
+ .add("reasonDescription", formatReason(block.reason()))
+ .add("blockedAt", ISO.format(block.blockedAt().atOffset(ZoneOffset.UTC)))
+ .add("blockedUntil", ISO.format(block.blockedUntil().atOffset(ZoneOffset.UTC)))
+ .add("remainingTime", formatRemainingTime(block.blockedUntil()));
+ final JsonArrayBuilder deps = Json.createArrayBuilder();
+ block.dependencies().forEach(dep -> deps.add(
+ Json.createObjectBuilder()
+ .add("artifact", dep.artifact())
+ .add("version", dep.version())
+ ));
+ json.add("dependencies", deps);
+ return ResponseBuilder.forbidden()
+ .jsonBody(json.build().toString())
+ .build();
+ }
+
+ /**
+ * Format reason enum to human-readable string.
+ */
+ private static String formatReason(final CooldownReason reason) {
+ return switch (reason) {
+ case FRESH_RELEASE -> "fresh release (package was published recently)";
+ case NEWER_THAN_CACHE -> "newer than cached version";
+ default -> reason.name().toLowerCase(Locale.US).replace('_', ' ');
+ };
+ }
+
+ /**
+ * Format remaining time until block expires.
+ */
+ private static String formatRemainingTime(final Instant until) {
+ final Instant now = Instant.now();
+ if (until.isBefore(now)) {
+ return "expired";
+ }
+ final long hours = java.time.Duration.between(now, until).toHours();
+ if (hours < 1) {
+ final long minutes = java.time.Duration.between(now, until).toMinutes();
+ return minutes + " minutes";
+ }
+ if (hours < 24) {
+ return hours + " hours";
+ }
+ final long days = hours / 24;
+ return days + " days";
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/CooldownResult.java b/artipie-core/src/main/java/com/artipie/cooldown/CooldownResult.java
new file mode 100644
index 000000000..255db51ce
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/CooldownResult.java
@@ -0,0 +1,37 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown;
+
+import java.util.Optional;
+
+/**
+ * Outcome of a cooldown evaluation.
+ */
+public final class CooldownResult {
+
+ private final boolean blocked;
+ private final CooldownBlock block;
+
+ private CooldownResult(final boolean blocked, final CooldownBlock block) {
+ this.blocked = blocked;
+ this.block = block;
+ }
+
+ public static CooldownResult allowed() {
+ return new CooldownResult(false, null);
+ }
+
+ public static CooldownResult blocked(final CooldownBlock block) {
+ return new CooldownResult(true, block);
+ }
+
+ public boolean blocked() {
+ return this.blocked;
+ }
+
+ public Optional block() {
+ return Optional.ofNullable(this.block);
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/CooldownService.java b/artipie-core/src/main/java/com/artipie/cooldown/CooldownService.java
new file mode 100644
index 000000000..38a41160c
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/CooldownService.java
@@ -0,0 +1,73 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown;
+
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Cooldown evaluation and management service.
+ */
+public interface CooldownService {
+
+ /**
+ * Evaluate request and either allow it or produce cooldown information.
+ *
+ * @param request Request details
+ * @param inspector Repository-specific inspector to fetch data such as release date
+ * @return Evaluation result
+ */
+ CompletableFuture evaluate(CooldownRequest request, CooldownInspector inspector);
+
+ /**
+ * Manually unblock specific artifact version.
+ *
+ * @param repoType Repository type
+ * @param repoName Repository name
+ * @param artifact Artifact identifier
+ * @param version Artifact version
+ * @param actor Username who performed unblock
+ * @return Completion future
+ */
+ CompletableFuture unblock(
+ String repoType,
+ String repoName,
+ String artifact,
+ String version,
+ String actor
+ );
+
+ /**
+ * Manually unblock all artifacts for repository.
+ *
+ * @param repoType Repository type
+ * @param repoName Repository name
+ * @param actor Username who performed unblock
+ * @return Completion future
+ */
+ CompletableFuture unblockAll(String repoType, String repoName, String actor);
+
+ /**
+ * List currently active blocks for repository.
+ *
+ * @param repoType Repository type
+ * @param repoName Repository name
+ * @return Future with active blocks list
+ */
+ CompletableFuture> activeBlocks(String repoType, String repoName);
+
+ /**
+ * Mark a package as "all versions blocked".
+ * Called when all versions of a package are blocked during metadata filtering.
+ * Persists to database and updates metrics.
+ *
+ * @param repoType Repository type
+ * @param repoName Repository name
+ * @param artifact Artifact/package name
+ */
+ default void markAllBlocked(String repoType, String repoName, String artifact) {
+ // Default no-op for NoopCooldownService
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/CooldownSettings.java b/artipie-core/src/main/java/com/artipie/cooldown/CooldownSettings.java
new file mode 100644
index 000000000..7e242ebdc
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/CooldownSettings.java
@@ -0,0 +1,147 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown;
+
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Global and per-repo-type cooldown configuration.
+ */
+public final class CooldownSettings {
+
+ /**
+ * Default cooldown in hours when configuration is absent.
+ */
+ public static final long DEFAULT_HOURS = 72L;
+
+ /**
+ * Whether cooldown logic is enabled globally.
+ */
+ private final boolean enabled;
+
+ /**
+ * Minimum allowed age for an artifact release. If an artifact's release time
+ * is within this window (i.e. too fresh), it will be blocked until it reaches
+ * the minimum allowed age.
+ */
+ private final Duration minimumAllowedAge;
+
+ /**
+ * Per-repo-type overrides.
+ * Key: repository type (maven, npm, docker, etc.)
+ * Value: RepoTypeConfig with enabled flag and minimum age
+ */
+ private final Map repoTypeOverrides;
+
+ /**
+ * Ctor with global settings only.
+ *
+ * @param enabled Whether cooldown logic is enabled
+ * @param minimumAllowedAge Minimum allowed age duration for fresh releases
+ */
+ public CooldownSettings(final boolean enabled, final Duration minimumAllowedAge) {
+ this(enabled, minimumAllowedAge, new HashMap<>());
+ }
+
+ /**
+ * Ctor with per-repo-type overrides.
+ *
+ * @param enabled Whether cooldown logic is enabled globally
+ * @param minimumAllowedAge Global minimum allowed age duration
+ * @param repoTypeOverrides Per-repo-type configuration overrides
+ */
+ public CooldownSettings(
+ final boolean enabled,
+ final Duration minimumAllowedAge,
+ final Map repoTypeOverrides
+ ) {
+ this.enabled = enabled;
+ this.minimumAllowedAge = Objects.requireNonNull(minimumAllowedAge);
+ this.repoTypeOverrides = Objects.requireNonNull(repoTypeOverrides);
+ }
+
+ /**
+ * Check if cooldown is enabled globally.
+ *
+ * @return {@code true} if cooldown is enabled globally
+ */
+ public boolean enabled() {
+ return this.enabled;
+ }
+
+ /**
+ * Check if cooldown is enabled for specific repository type.
+ * Uses per-repo-type override if present, otherwise falls back to global.
+ *
+ * @param repoType Repository type (maven, npm, docker, etc.)
+ * @return {@code true} if cooldown is enabled for this repo type
+ */
+ public boolean enabledFor(final String repoType) {
+ final RepoTypeConfig override = this.repoTypeOverrides.get(repoType.toLowerCase());
+ return override != null ? override.enabled() : this.enabled;
+ }
+
+ /**
+ * Get global minimum allowed age duration for releases.
+ *
+ * @return Duration of minimum allowed age
+ */
+ public Duration minimumAllowedAge() {
+ return this.minimumAllowedAge;
+ }
+
+ /**
+ * Get minimum allowed age for specific repository type.
+ * Uses per-repo-type override if present, otherwise falls back to global.
+ *
+ * @param repoType Repository type (maven, npm, docker, etc.)
+ * @return Minimum allowed age for this repo type
+ */
+ public Duration minimumAllowedAgeFor(final String repoType) {
+ final RepoTypeConfig override = this.repoTypeOverrides.get(repoType.toLowerCase());
+ return override != null ? override.minimumAllowedAge() : this.minimumAllowedAge;
+ }
+
+ /**
+ * Creates default configuration (enabled, 72 hours minimum allowed age).
+ *
+ * @return Default cooldown settings
+ */
+ public static CooldownSettings defaults() {
+ final Duration duration = Duration.ofHours(DEFAULT_HOURS);
+ return new CooldownSettings(true, duration);
+ }
+
+ /**
+ * Per-repository-type configuration.
+ */
+ public static final class RepoTypeConfig {
+ private final boolean enabled;
+ private final Duration minimumAllowedAge;
+
+ /**
+ * Constructor.
+ *
+ * @param enabled Whether cooldown is enabled for this repo type
+ * @param minimumAllowedAge Minimum allowed age for this repo type
+ */
+ public RepoTypeConfig(final boolean enabled, final Duration minimumAllowedAge) {
+ this.enabled = enabled;
+ this.minimumAllowedAge = Objects.requireNonNull(minimumAllowedAge);
+ }
+
+ public boolean enabled() {
+ return this.enabled;
+ }
+
+ public Duration minimumAllowedAge() {
+ return this.minimumAllowedAge;
+ }
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/InspectorRegistry.java b/artipie-core/src/main/java/com/artipie/cooldown/InspectorRegistry.java
new file mode 100644
index 000000000..ba5eb404b
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/InspectorRegistry.java
@@ -0,0 +1,132 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown;
+
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+/**
+ * Global registry of cooldown inspectors for cache invalidation during unblock.
+ * Allows unblock operations to invalidate inspector internal caches across all adapters.
+ *
+ * @since 1.19
+ */
+public final class InspectorRegistry {
+
+ /**
+ * Singleton instance.
+ */
+ private static final InspectorRegistry INSTANCE = new InspectorRegistry();
+
+ /**
+ * Registry of invalidatable inspectors by repository type and name.
+ * Key format: "{repoType}:{repoName}" (e.g., "docker:docker_proxy")
+ */
+ private final ConcurrentMap inspectors;
+
+ private InspectorRegistry() {
+ this.inspectors = new ConcurrentHashMap<>();
+ }
+
+ /**
+ * Get singleton instance.
+ *
+ * @return Registry instance
+ */
+ public static InspectorRegistry instance() {
+ return INSTANCE;
+ }
+
+ /**
+ * Register inspector for repository.
+ *
+ * @param repoType Repository type (docker, npm, maven, etc.)
+ * @param repoName Repository name
+ * @param inspector Inspector instance
+ */
+ public void register(
+ final String repoType,
+ final String repoName,
+ final InvalidatableInspector inspector
+ ) {
+ this.inspectors.put(key(repoType, repoName), inspector);
+ }
+
+ /**
+ * Unregister inspector for repository.
+ *
+ * @param repoType Repository type
+ * @param repoName Repository name
+ */
+ public void unregister(final String repoType, final String repoName) {
+ this.inspectors.remove(key(repoType, repoName));
+ }
+
+ /**
+ * Get inspector for repository.
+ *
+ * @param repoType Repository type
+ * @param repoName Repository name
+ * @return Inspector if registered
+ */
+ public Optional get(final String repoType, final String repoName) {
+ return Optional.ofNullable(this.inspectors.get(key(repoType, repoName)));
+ }
+
+ /**
+ * Invalidate specific artifact in repository inspector cache.
+ * Called when artifact is manually unblocked.
+ *
+ * @param repoType Repository type
+ * @param repoName Repository name
+ * @param artifact Artifact name
+ * @param version Version
+ */
+ public void invalidate(
+ final String repoType,
+ final String repoName,
+ final String artifact,
+ final String version
+ ) {
+ this.get(repoType, repoName).ifPresent(
+ inspector -> inspector.invalidate(artifact, version)
+ );
+ }
+
+ /**
+ * Clear all cached data for repository inspector.
+ * Called when all artifacts for a repository are unblocked.
+ *
+ * @param repoType Repository type
+ * @param repoName Repository name
+ */
+ public void clearAll(final String repoType, final String repoName) {
+ this.get(repoType, repoName).ifPresent(InvalidatableInspector::clearAll);
+ }
+
+ private static String key(final String repoType, final String repoName) {
+ return String.format("%s:%s", repoType, repoName);
+ }
+
+ /**
+ * Interface for inspectors that support cache invalidation.
+ * All inspectors with internal caches should implement this.
+ */
+ public interface InvalidatableInspector {
+ /**
+ * Invalidate cached data for specific artifact version.
+ *
+ * @param artifact Artifact name
+ * @param version Version
+ */
+ void invalidate(String artifact, String version);
+
+ /**
+ * Clear all cached data.
+ */
+ void clearAll();
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/NoopCooldownService.java b/artipie-core/src/main/java/com/artipie/cooldown/NoopCooldownService.java
new file mode 100644
index 000000000..0f2820e7a
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/NoopCooldownService.java
@@ -0,0 +1,56 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Cooldown service that always allows requests.
+ */
+public final class NoopCooldownService implements CooldownService {
+
+ public static final NoopCooldownService INSTANCE = new NoopCooldownService();
+
+ private NoopCooldownService() {
+ }
+
+ @Override
+ public CompletableFuture evaluate(
+ final CooldownRequest request,
+ final CooldownInspector inspector
+ ) {
+ return CompletableFuture.completedFuture(CooldownResult.allowed());
+ }
+
+ @Override
+ public CompletableFuture unblock(
+ final String repoType,
+ final String repoName,
+ final String artifact,
+ final String version,
+ final String actor
+ ) {
+ return CompletableFuture.completedFuture(null);
+ }
+
+ @Override
+ public CompletableFuture unblockAll(
+ final String repoType,
+ final String repoName,
+ final String actor
+ ) {
+ return CompletableFuture.completedFuture(null);
+ }
+
+ @Override
+ public CompletableFuture> activeBlocks(
+ final String repoType,
+ final String repoName
+ ) {
+ return CompletableFuture.completedFuture(Collections.emptyList());
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/metadata/AllVersionsBlockedException.java b/artipie-core/src/main/java/com/artipie/cooldown/metadata/AllVersionsBlockedException.java
new file mode 100644
index 000000000..993e1efc0
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/metadata/AllVersionsBlockedException.java
@@ -0,0 +1,66 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown.metadata;
+
+import java.util.Collections;
+import java.util.Set;
+
+/**
+ * Exception thrown when all versions of a package are blocked by cooldown.
+ * Callers should handle this by returning HTTP 403 with appropriate error details.
+ *
+ * @since 1.0
+ */
+public final class AllVersionsBlockedException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Package name.
+ */
+ private final String packageName;
+
+ /**
+ * Set of blocked versions.
+ */
+ private final Set blockedVersions;
+
+ /**
+ * Constructor.
+ *
+ * @param packageName Package name
+ * @param blockedVersions Set of all blocked versions
+ */
+ public AllVersionsBlockedException(
+ final String packageName,
+ final Set blockedVersions
+ ) {
+ super(String.format(
+ "All %d versions of package '%s' are blocked by cooldown",
+ blockedVersions.size(),
+ packageName
+ ));
+ this.packageName = packageName;
+ this.blockedVersions = Collections.unmodifiableSet(blockedVersions);
+ }
+
+ /**
+ * Get package name.
+ *
+ * @return Package name
+ */
+ public String packageName() {
+ return this.packageName;
+ }
+
+ /**
+ * Get blocked versions.
+ *
+ * @return Unmodifiable set of blocked versions
+ */
+ public Set blockedVersions() {
+ return this.blockedVersions;
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/metadata/CooldownMetadataService.java b/artipie-core/src/main/java/com/artipie/cooldown/metadata/CooldownMetadataService.java
new file mode 100644
index 000000000..551d4a17e
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/metadata/CooldownMetadataService.java
@@ -0,0 +1,81 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown.metadata;
+
+import com.artipie.cooldown.CooldownInspector;
+
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Service for filtering package metadata to remove blocked versions.
+ * This is the main entry point for cooldown-based metadata filtering.
+ *
+ * The service:
+ *
+ * Parses raw metadata using the provided parser
+ * Extracts all versions from metadata
+ * Evaluates cooldown for each version (bounded to latest N)
+ * Filters out blocked versions
+ * Updates "latest" tag if needed
+ * Serializes filtered metadata
+ * Caches the result
+ *
+ *
+ * @since 1.0
+ */
+public interface CooldownMetadataService {
+
+ /**
+ * Filter metadata to remove blocked versions.
+ *
+ * @param repoType Repository type (e.g., "npm", "maven")
+ * @param repoName Repository name
+ * @param packageName Package name
+ * @param rawMetadata Raw metadata bytes from upstream
+ * @param parser Parser for this metadata format
+ * @param filter Filter for this metadata format
+ * @param rewriter Rewriter for this metadata format
+ * @param inspector Optional cooldown inspector for release date lookups
+ * @param Type of parsed metadata
+ * @return CompletableFuture with filtered metadata bytes
+ * @throws AllVersionsBlockedException If all versions are blocked
+ */
+ CompletableFuture filterMetadata(
+ String repoType,
+ String repoName,
+ String packageName,
+ byte[] rawMetadata,
+ MetadataParser parser,
+ MetadataFilter filter,
+ MetadataRewriter rewriter,
+ Optional inspector
+ );
+
+ /**
+ * Invalidate cached metadata for a package.
+ * Called when a version is blocked or unblocked.
+ *
+ * @param repoType Repository type
+ * @param repoName Repository name
+ * @param packageName Package name
+ */
+ void invalidate(String repoType, String repoName, String packageName);
+
+ /**
+ * Invalidate all cached metadata for a repository.
+ *
+ * @param repoType Repository type
+ * @param repoName Repository name
+ */
+ void invalidateAll(String repoType, String repoName);
+
+ /**
+ * Get cache statistics.
+ *
+ * @return Statistics string
+ */
+ String stats();
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/metadata/CooldownMetadataServiceImpl.java b/artipie-core/src/main/java/com/artipie/cooldown/metadata/CooldownMetadataServiceImpl.java
new file mode 100644
index 000000000..c30e200e7
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/metadata/CooldownMetadataServiceImpl.java
@@ -0,0 +1,700 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown.metadata;
+
+import com.artipie.cooldown.CooldownCache;
+import com.artipie.cooldown.CooldownInspector;
+import com.artipie.cooldown.CooldownRequest;
+import com.artipie.cooldown.CooldownService;
+import com.artipie.cooldown.CooldownSettings;
+import com.artipie.cooldown.metrics.CooldownMetrics;
+import com.artipie.http.log.EcsLogger;
+import org.slf4j.MDC;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ForkJoinPool;
+import java.util.stream.Collectors;
+
+/**
+ * Implementation of {@link CooldownMetadataService}.
+ * Filters package metadata to remove blocked versions before serving to clients.
+ *
+ * Performance characteristics:
+ *
+ * Cache hit: < 1ms (L1 Caffeine cache)
+ * Cache miss: 20-200ms depending on metadata size and version count
+ * Bounded evaluation: Only evaluates latest N versions (configurable)
+ *
+ *
+ * @since 1.0
+ */
+public final class CooldownMetadataServiceImpl implements CooldownMetadataService {
+
+ /**
+ * Default maximum versions to evaluate for cooldown.
+ * Older versions are implicitly allowed.
+ */
+ private static final int DEFAULT_MAX_VERSIONS = 50;
+
+ /**
+ * Default max TTL for cache entries when no versions are blocked.
+ * Since release dates don't change, we can cache for a long time.
+ */
+ private static final Duration DEFAULT_MAX_TTL = Duration.ofHours(24);
+
+ /**
+ * Cooldown service for block decisions.
+ */
+ private final CooldownService cooldown;
+
+ /**
+ * Cooldown settings.
+ */
+ private final CooldownSettings settings;
+
+ /**
+ * Per-version cooldown cache.
+ */
+ private final CooldownCache cooldownCache;
+
+ /**
+ * Filtered metadata cache.
+ */
+ private final FilteredMetadataCache metadataCache;
+
+ /**
+ * Executor for async operations.
+ */
+ private final Executor executor;
+
+ /**
+ * Maximum versions to evaluate.
+ */
+ private final int maxVersionsToEvaluate;
+
+ /**
+ * Version comparators by repo type.
+ */
+ private final Map> versionComparators;
+
+ /**
+ * Maximum TTL for cache entries.
+ */
+ private final Duration maxTtl;
+
+ /**
+ * Constructor with defaults.
+ *
+ * @param cooldown Cooldown service
+ * @param settings Cooldown settings
+ * @param cooldownCache Per-version cooldown cache
+ */
+ public CooldownMetadataServiceImpl(
+ final CooldownService cooldown,
+ final CooldownSettings settings,
+ final CooldownCache cooldownCache
+ ) {
+ this(
+ cooldown,
+ settings,
+ cooldownCache,
+ new FilteredMetadataCache(),
+ ForkJoinPool.commonPool(),
+ DEFAULT_MAX_VERSIONS
+ );
+ }
+
+ /**
+ * Full constructor.
+ *
+ * @param cooldown Cooldown service
+ * @param settings Cooldown settings
+ * @param cooldownCache Per-version cooldown cache
+ * @param metadataCache Filtered metadata cache
+ * @param executor Executor for async operations
+ * @param maxVersionsToEvaluate Maximum versions to evaluate
+ */
+ public CooldownMetadataServiceImpl(
+ final CooldownService cooldown,
+ final CooldownSettings settings,
+ final CooldownCache cooldownCache,
+ final FilteredMetadataCache metadataCache,
+ final Executor executor,
+ final int maxVersionsToEvaluate
+ ) {
+ this.cooldown = Objects.requireNonNull(cooldown);
+ this.settings = Objects.requireNonNull(settings);
+ this.cooldownCache = Objects.requireNonNull(cooldownCache);
+ this.metadataCache = Objects.requireNonNull(metadataCache);
+ this.executor = Objects.requireNonNull(executor);
+ this.maxVersionsToEvaluate = maxVersionsToEvaluate;
+ this.versionComparators = Map.of(
+ "npm", VersionComparators.semver(),
+ "composer", VersionComparators.semver(),
+ "maven", VersionComparators.maven(),
+ "gradle", VersionComparators.maven(),
+ "pypi", VersionComparators.semver(),
+ "go", VersionComparators.lexical()
+ );
+ this.maxTtl = DEFAULT_MAX_TTL;
+ }
+
+ @Override
+ public CompletableFuture filterMetadata(
+ final String repoType,
+ final String repoName,
+ final String packageName,
+ final byte[] rawMetadata,
+ final MetadataParser parser,
+ final MetadataFilter filter,
+ final MetadataRewriter rewriter,
+ final Optional inspectorOpt
+ ) {
+ // Check if cooldown is enabled for this repo type
+ if (!this.settings.enabledFor(repoType)) {
+ EcsLogger.debug("com.artipie.cooldown.metadata")
+ .message("Cooldown disabled for repo type, returning raw metadata")
+ .eventCategory("cooldown")
+ .eventAction("metadata_filter")
+ .field("repository.type", repoType)
+ .field("package.name", packageName)
+ .log();
+ return CompletableFuture.completedFuture(rawMetadata);
+ }
+
+ final long startTime = System.nanoTime();
+
+ // Try cache first
+ return this.metadataCache.get(
+ repoType,
+ repoName,
+ packageName,
+ () -> this.computeFilteredMetadata(
+ repoType, repoName, packageName, rawMetadata,
+ parser, filter, rewriter, inspectorOpt, startTime
+ )
+ );
+ }
+
+ /**
+ * Compute filtered metadata (called on cache miss).
+ * Returns CacheEntry with dynamic TTL based on earliest blockedUntil.
+ */
+ private CompletableFuture computeFilteredMetadata(
+ final String repoType,
+ final String repoName,
+ final String packageName,
+ final byte[] rawMetadata,
+ final MetadataParser parser,
+ final MetadataFilter filter,
+ final MetadataRewriter rewriter,
+ final Optional inspectorOpt,
+ final long startTime
+ ) {
+ return CompletableFuture.supplyAsync(() -> {
+ // Step 1: Parse metadata
+ final T parsed = parser.parse(rawMetadata);
+ final List allVersions = parser.extractVersions(parsed);
+
+ if (allVersions.isEmpty()) {
+ EcsLogger.debug("com.artipie.cooldown.metadata")
+ .message("No versions in metadata")
+ .eventCategory("cooldown")
+ .eventAction("metadata_filter")
+ .field("repository.type", repoType)
+ .field("package.name", packageName)
+ .log();
+ // No versions - cache with max TTL
+ return FilteredMetadataCache.CacheEntry.noBlockedVersions(rawMetadata, this.maxTtl);
+ }
+
+ // Step 2: Get release dates from metadata (if available)
+ final Map releaseDates;
+ if (parser instanceof ReleaseDateProvider) {
+ @SuppressWarnings("unchecked")
+ final ReleaseDateProvider provider = (ReleaseDateProvider) parser;
+ releaseDates = provider.releaseDates(parsed);
+ } else {
+ releaseDates = Collections.emptyMap();
+ }
+
+ // Step 2b: Preload release dates into inspector for later use
+ this.preloadReleaseDates(parser, parsed, inspectorOpt);
+
+ // Step 3: Select versions to evaluate based on RELEASE DATE, not semver
+ // Only versions released within the cooldown period could possibly be blocked
+ final Duration cooldownPeriod = this.settings.minimumAllowedAge();
+ final Instant cutoffTime = Instant.now().minus(cooldownPeriod);
+
+ final List versionsToEvaluate;
+ final List sortedVersions;
+
+ if (!releaseDates.isEmpty()) {
+ // RELEASE DATE BASED: Sort by release date, then binary search for cutoff
+ // O(n log n) sort + O(log n) binary search - more efficient than O(n) filter
+ sortedVersions = new ArrayList<>(allVersions);
+ sortedVersions.sort((v1, v2) -> {
+ final Instant d1 = releaseDates.getOrDefault(v1, Instant.EPOCH);
+ final Instant d2 = releaseDates.getOrDefault(v2, Instant.EPOCH);
+ return d2.compareTo(d1); // Newest first (descending by date)
+ });
+
+ // Binary search: find first version older than cutoff
+ // Since sorted newest-first, we find first index where releaseDate <= cutoffTime
+ int cutoffIndex = Collections.binarySearch(
+ sortedVersions,
+ null, // dummy search key
+ (v1, v2) -> {
+ // v1 is from list, v2 is our dummy (null)
+ // We want to find where releaseDate crosses cutoffTime
+ if (v1 == null) {
+ return 0; // dummy comparison
+ }
+ final Instant d1 = releaseDates.getOrDefault(v1, Instant.EPOCH);
+ // Return negative if d1 > cutoff (keep searching right)
+ // Return positive if d1 <= cutoff (found boundary)
+ return d1.isAfter(cutoffTime) ? -1 : 1;
+ }
+ );
+ // binarySearch returns -(insertionPoint + 1) when not found
+ // insertionPoint is where cutoff would be inserted to maintain order
+ if (cutoffIndex < 0) {
+ cutoffIndex = -(cutoffIndex + 1);
+ }
+
+ // Take all versions from index 0 to cutoffIndex (exclusive) - these are newer than cutoff
+ versionsToEvaluate = cutoffIndex > 0
+ ? sortedVersions.subList(0, cutoffIndex)
+ : Collections.emptyList();
+ } else {
+ // FALLBACK: No release dates available, use semver-based limit
+ // This is less accurate but better than nothing
+ final Comparator comparator = this.versionComparators
+ .getOrDefault(repoType.toLowerCase(), VersionComparators.semver());
+ sortedVersions = new ArrayList<>(allVersions);
+ sortedVersions.sort(comparator.reversed()); // Newest first by semver
+
+ versionsToEvaluate = sortedVersions.stream()
+ .limit(this.maxVersionsToEvaluate)
+ .collect(Collectors.toList());
+ }
+
+ EcsLogger.debug("com.artipie.cooldown.metadata")
+ .message("Evaluating cooldown for versions")
+ .eventCategory("cooldown")
+ .eventAction("metadata_filter")
+ .field("repository.type", repoType)
+ .field("package.name", packageName)
+ .field("versions.total", allVersions.size())
+ .field("versions.evaluating", versionsToEvaluate.size())
+ .log();
+
+ return new FilterContext<>(
+ repoType, repoName, packageName, parsed,
+ allVersions, sortedVersions, versionsToEvaluate,
+ parser, filter, rewriter, inspectorOpt, startTime
+ );
+ }, this.executor).thenCompose(ctx -> {
+ if (ctx instanceof FilteredMetadataCache.CacheEntry) {
+ return CompletableFuture.completedFuture((FilteredMetadataCache.CacheEntry) ctx);
+ }
+ @SuppressWarnings("unchecked")
+ final FilterContext context = (FilterContext) ctx;
+ return this.evaluateAndFilter(context);
+ });
+ }
+
+ /**
+ * Evaluate cooldown for versions and filter metadata.
+ * Returns CacheEntry with TTL based on earliest blockedUntil.
+ */
+ private CompletableFuture evaluateAndFilter(final FilterContext ctx) {
+ // Step 4: Evaluate cooldown for each version in parallel
+ final List> futures = ctx.versionsToEvaluate.stream()
+ .map(version -> this.evaluateVersion(
+ ctx.repoType, ctx.repoName, ctx.packageName, version, ctx.inspectorOpt
+ ))
+ .collect(Collectors.toList());
+
+ return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
+ .thenApply(ignored -> {
+ // Step 5: Collect blocked versions and find earliest blockedUntil
+ final Set blockedVersions = new HashSet<>();
+ Instant earliestBlockedUntil = null;
+ for (final CompletableFuture future : futures) {
+ final VersionBlockResult result = future.join();
+ if (result.blocked) {
+ blockedVersions.add(result.version);
+ // Track earliest blockedUntil for cache TTL
+ if (result.blockedUntil != null) {
+ if (earliestBlockedUntil == null || result.blockedUntil.isBefore(earliestBlockedUntil)) {
+ earliestBlockedUntil = result.blockedUntil;
+ }
+ }
+ }
+ }
+
+ EcsLogger.debug("com.artipie.cooldown.metadata")
+ .message("Cooldown evaluation complete")
+ .eventCategory("cooldown")
+ .eventAction("metadata_filter")
+ .field("repository.type", ctx.repoType)
+ .field("package.name", ctx.packageName)
+ .field("versions.blocked", blockedVersions.size())
+ .log();
+
+ // Note: Blocked versions gauge is updated by JdbcCooldownService on block/unblock
+ // We don't increment counters here as that would count evaluations, not actual blocks
+
+ // Step 6: Check if all versions are blocked
+ if (blockedVersions.size() == ctx.allVersions.size()) {
+ // Mark as all-blocked in database and update gauge metric
+ this.cooldown.markAllBlocked(ctx.repoType, ctx.repoName, ctx.packageName);
+ throw new AllVersionsBlockedException(ctx.packageName, blockedVersions);
+ }
+
+ // Step 7: Filter metadata
+ T filtered = ctx.filter.filter(ctx.parsed, blockedVersions);
+
+ // Step 8: Update latest if needed
+ final Optional currentLatest = ctx.parser.getLatestVersion(ctx.parsed);
+ if (currentLatest.isPresent() && blockedVersions.contains(currentLatest.get())) {
+ // Find new latest by RELEASE DATE (most recent unblocked version)
+ // This respects the package author's intent - if they set a lower version as latest,
+ // we should fallback to the next most recently released version, not the highest semver
+ // Pass sortedVersions (sorted by semver desc) for fallback when no release dates
+ final Optional newLatest = this.findLatestByReleaseDate(
+ ctx.parser, ctx.parsed, ctx.sortedVersions, blockedVersions
+ );
+ if (newLatest.isPresent()) {
+ filtered = ctx.filter.updateLatest(filtered, newLatest.get());
+ EcsLogger.debug("com.artipie.cooldown.metadata")
+ .message("Updated latest version (by release date)")
+ .eventCategory("cooldown")
+ .eventAction("metadata_filter")
+ .field("package.name", ctx.packageName)
+ .field("latest.old", currentLatest.get())
+ .field("latest.new", newLatest.get())
+ .log();
+ }
+ }
+
+ // Step 9: Rewrite metadata
+ final byte[] resultBytes = ctx.rewriter.rewrite(filtered);
+
+ // Log performance
+ final long durationMs = (System.nanoTime() - ctx.startTime) / 1_000_000;
+ EcsLogger.info("com.artipie.cooldown.metadata")
+ .message("Metadata filtering complete")
+ .eventCategory("cooldown")
+ .eventAction("metadata_filter")
+ .eventOutcome("success")
+ .field("repository.type", ctx.repoType)
+ .field("package.name", ctx.packageName)
+ .field("versions.total", ctx.allVersions.size())
+ .field("versions.blocked", blockedVersions.size())
+ .field("duration_ms", durationMs)
+ .log();
+
+ // Record metrics via CooldownMetrics
+ if (CooldownMetrics.isAvailable()) {
+ CooldownMetrics.getInstance().recordFilterDuration(
+ ctx.repoType, durationMs, ctx.allVersions.size(), blockedVersions.size()
+ );
+ }
+
+ // Step 10: Create cache entry with dynamic TTL
+ // TTL = min(blockedUntil) - now, or max TTL if no blocked versions
+ if (earliestBlockedUntil != null) {
+ return FilteredMetadataCache.CacheEntry.withBlockedVersions(
+ resultBytes, earliestBlockedUntil, this.maxTtl
+ );
+ }
+ return FilteredMetadataCache.CacheEntry.noBlockedVersions(resultBytes, this.maxTtl);
+ }).whenComplete((result, error) -> {
+ // Clear preloaded dates
+ ctx.inspectorOpt.ifPresent(inspector -> {
+ if (inspector instanceof MetadataAwareInspector) {
+ ((MetadataAwareInspector) inspector).clearPreloadedDates();
+ }
+ });
+ });
+ }
+
+ /**
+ * Evaluate cooldown for a single version.
+ * Returns block status and blockedUntil timestamp for cache TTL calculation.
+ */
+ private CompletableFuture evaluateVersion(
+ final String repoType,
+ final String repoName,
+ final String packageName,
+ final String version,
+ final Optional inspectorOpt
+ ) {
+ // On cache miss, evaluate via cooldown service
+ if (inspectorOpt.isEmpty()) {
+ // No inspector - can't evaluate, allow by default
+ return CompletableFuture.completedFuture(new VersionBlockResult(version, false, null));
+ }
+ // Get real user from MDC (set by auth middleware), fallback to "metadata-filter"
+ String requester = MDC.get("user.name");
+ if (requester == null || requester.isEmpty()) {
+ requester = "metadata-filter";
+ }
+ final CooldownRequest request = new CooldownRequest(
+ repoType,
+ repoName,
+ packageName,
+ version,
+ requester,
+ Instant.now()
+ );
+ return this.cooldown.evaluate(request, inspectorOpt.get())
+ .thenApply(result -> {
+ if (result.blocked()) {
+ // Extract blockedUntil from the block info
+ final Instant blockedUntil = result.block()
+ .map(block -> block.blockedUntil())
+ .orElse(null);
+ return new VersionBlockResult(version, true, blockedUntil);
+ }
+ return new VersionBlockResult(version, false, null);
+ });
+ }
+
+ /**
+ * Preload release dates from metadata into inspector if supported.
+ */
+ @SuppressWarnings("unchecked")
+ private void preloadReleaseDates(
+ final MetadataParser parser,
+ final T parsed,
+ final Optional inspectorOpt
+ ) {
+ if (inspectorOpt.isEmpty()) {
+ return;
+ }
+ final CooldownInspector inspector = inspectorOpt.get();
+ if (!(inspector instanceof MetadataAwareInspector)) {
+ return;
+ }
+ if (!(parser instanceof ReleaseDateProvider)) {
+ return;
+ }
+ final ReleaseDateProvider provider = (ReleaseDateProvider) parser;
+ final Map releaseDates = provider.releaseDates(parsed);
+ if (!releaseDates.isEmpty()) {
+ ((MetadataAwareInspector) inspector).preloadReleaseDates(releaseDates);
+ EcsLogger.debug("com.artipie.cooldown.metadata")
+ .message("Preloaded release dates from metadata")
+ .eventCategory("cooldown")
+ .eventAction("metadata_filter")
+ .field("dates.count", releaseDates.size())
+ .log();
+ }
+ }
+
+ @Override
+ public void invalidate(
+ final String repoType,
+ final String repoName,
+ final String packageName
+ ) {
+ this.metadataCache.invalidate(repoType, repoName, packageName);
+ if (CooldownMetrics.isAvailable()) {
+ CooldownMetrics.getInstance().recordInvalidation(repoType, "unblock");
+ }
+ EcsLogger.debug("com.artipie.cooldown.metadata")
+ .message("Invalidated metadata cache")
+ .eventCategory("cooldown")
+ .eventAction("cache_invalidate")
+ .field("repository.type", repoType)
+ .field("repository.name", repoName)
+ .field("package.name", packageName)
+ .log();
+ }
+
+ @Override
+ public void invalidateAll(final String repoType, final String repoName) {
+ this.metadataCache.invalidateAll(repoType, repoName);
+ if (CooldownMetrics.isAvailable()) {
+ CooldownMetrics.getInstance().recordInvalidation(repoType, "unblock_all");
+ }
+ EcsLogger.debug("com.artipie.cooldown.metadata")
+ .message("Invalidated all metadata cache for repository")
+ .eventCategory("cooldown")
+ .eventAction("cache_invalidate")
+ .field("repository.type", repoType)
+ .field("repository.name", repoName)
+ .log();
+ }
+
+ @Override
+ public String stats() {
+ return this.metadataCache.stats();
+ }
+
+ /**
+ * Find the most recent unblocked STABLE version by release date.
+ * This respects package author's intent - if they set a lower semver version as latest
+ * (e.g., deprecating a major version branch), we fallback to the next most recently
+ * released STABLE version, not a prerelease.
+ *
+ * @param parser Metadata parser (must implement ReleaseDateProvider)
+ * @param parsed Parsed metadata
+ * @param allVersions All available versions (sorted by semver desc)
+ * @param blockedVersions Set of blocked versions to exclude
+ * @param Metadata type
+ * @return Most recent unblocked stable version by release date, or empty if none found
+ */
+ @SuppressWarnings("unchecked")
+ private Optional findLatestByReleaseDate(
+ final MetadataParser parser,
+ final T parsed,
+ final List allVersions,
+ final Set blockedVersions
+ ) {
+ // Get release dates if parser supports it
+ if (!(parser instanceof ReleaseDateProvider)) {
+ // Fallback to first unblocked STABLE version
+ return allVersions.stream()
+ .filter(ver -> !blockedVersions.contains(ver))
+ .filter(ver -> !isPrerelease(ver))
+ .findFirst()
+ .or(() -> allVersions.stream()
+ .filter(ver -> !blockedVersions.contains(ver))
+ .findFirst()); // If no stable, use any unblocked
+ }
+
+ final ReleaseDateProvider dateProvider = (ReleaseDateProvider) parser;
+ final Map releaseDates = dateProvider.releaseDates(parsed);
+
+ if (releaseDates.isEmpty()) {
+ // No release dates available - fallback to first unblocked STABLE version
+ return allVersions.stream()
+ .filter(ver -> !blockedVersions.contains(ver))
+ .filter(ver -> !isPrerelease(ver))
+ .findFirst()
+ .or(() -> allVersions.stream()
+ .filter(ver -> !blockedVersions.contains(ver))
+ .findFirst()); // If no stable, use any unblocked
+ }
+
+ // Sort unblocked STABLE versions by release date (most recent first)
+ final Optional stableLatest = allVersions.stream()
+ .filter(ver -> !blockedVersions.contains(ver))
+ .filter(ver -> !isPrerelease(ver))
+ .filter(ver -> releaseDates.containsKey(ver))
+ .sorted((v1, v2) -> {
+ final Instant d1 = releaseDates.get(v1);
+ final Instant d2 = releaseDates.get(v2);
+ return d2.compareTo(d1); // Descending (most recent first)
+ })
+ .findFirst();
+
+ if (stableLatest.isPresent()) {
+ return stableLatest;
+ }
+
+ // No stable versions - fallback to any unblocked version by release date
+ return allVersions.stream()
+ .filter(ver -> !blockedVersions.contains(ver))
+ .filter(ver -> releaseDates.containsKey(ver))
+ .sorted((v1, v2) -> {
+ final Instant d1 = releaseDates.get(v1);
+ final Instant d2 = releaseDates.get(v2);
+ return d2.compareTo(d1);
+ })
+ .findFirst();
+ }
+
+ /**
+ * Check if version is a prerelease (alpha, beta, rc, canary, etc.).
+ *
+ * @param version Version string
+ * @return true if prerelease
+ */
+ private static boolean isPrerelease(final String version) {
+ final String v = version.toLowerCase(java.util.Locale.ROOT);
+ return v.contains("-") || v.contains("alpha") || v.contains("beta")
+ || v.contains("rc") || v.contains("canary") || v.contains("next")
+ || v.contains("dev") || v.contains("snapshot");
+ }
+
+ /**
+ * Context for filtering operation.
+ */
+ private static final class FilterContext {
+ final String repoType;
+ final String repoName;
+ final String packageName;
+ final T parsed;
+ final List allVersions;
+ final List sortedVersions;
+ final List versionsToEvaluate;
+ final MetadataParser parser;
+ final MetadataFilter filter;
+ final MetadataRewriter rewriter;
+ final Optional inspectorOpt;
+ final long startTime;
+
+ FilterContext(
+ final String repoType,
+ final String repoName,
+ final String packageName,
+ final T parsed,
+ final List allVersions,
+ final List sortedVersions,
+ final List versionsToEvaluate,
+ final MetadataParser parser,
+ final MetadataFilter filter,
+ final MetadataRewriter rewriter,
+ final Optional inspectorOpt,
+ final long startTime
+ ) {
+ this.repoType = repoType;
+ this.repoName = repoName;
+ this.packageName = packageName;
+ this.parsed = parsed;
+ this.allVersions = allVersions;
+ this.sortedVersions = sortedVersions;
+ this.versionsToEvaluate = versionsToEvaluate;
+ this.parser = parser;
+ this.filter = filter;
+ this.rewriter = rewriter;
+ this.inspectorOpt = inspectorOpt;
+ this.startTime = startTime;
+ }
+ }
+
+ /**
+ * Result of version block evaluation.
+ * Includes blockedUntil timestamp for cache TTL calculation.
+ */
+ private static final class VersionBlockResult {
+ final String version;
+ final boolean blocked;
+ final Instant blockedUntil;
+
+ VersionBlockResult(final String version, final boolean blocked, final Instant blockedUntil) {
+ this.version = version;
+ this.blocked = blocked;
+ this.blockedUntil = blockedUntil;
+ }
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/metadata/FilteredMetadataCache.java b/artipie-core/src/main/java/com/artipie/cooldown/metadata/FilteredMetadataCache.java
new file mode 100644
index 000000000..b5097607c
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/metadata/FilteredMetadataCache.java
@@ -0,0 +1,603 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown.metadata;
+
+import com.artipie.cache.ValkeyConnection;
+import com.artipie.cooldown.metrics.CooldownMetrics;
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.Expiry;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Cache for filtered metadata bytes with dynamic TTL based on cooldown expiration.
+ *
+ * Two-tier architecture:
+ *
+ * L1 (in-memory): Fast access, limited size, dynamic TTL per entry
+ * L2 (Valkey/Redis): Shared across instances, larger capacity
+ * L2-only mode: Set l1MaxSize=0 in config to disable L1 (for large metadata)
+ *
+ *
+ * TTL Strategy:
+ *
+ * If any version is blocked: TTL = min(blockedUntil) - now (cache until earliest block expires)
+ * If no versions blocked: TTL = max allowed (release dates don't change)
+ * On manual unblock: Cache is invalidated immediately
+ *
+ *
+ * Cache key format: {@code metadata:{repoType}:{repoName}:{packageName}}
+ *
+ * Configuration via YAML (artipie.yaml):
+ *
+ * meta:
+ * caches:
+ * cooldown-metadata:
+ * ttl: 24h
+ * maxSize: 5000
+ * valkey:
+ * enabled: true
+ * l1MaxSize: 500 # 0 for L2-only mode
+ * l1Ttl: 5m
+ * l2Ttl: 24h
+ *
+ *
+ * @since 1.0
+ */
+public final class FilteredMetadataCache {
+
+ /**
+ * Default L1 cache size (number of packages).
+ */
+ private static final int DEFAULT_L1_SIZE = 5_000;
+
+ /**
+ * Default max TTL when no versions are blocked (24 hours).
+ * Since release dates don't change, we can cache for a long time.
+ */
+ private static final Duration DEFAULT_MAX_TTL = Duration.ofHours(24);
+
+ /**
+ * Minimum TTL to avoid excessive cache churn (1 minute).
+ */
+ private static final Duration MIN_TTL = Duration.ofMinutes(1);
+
+ /**
+ * L1 cache (in-memory) with per-entry dynamic TTL.
+ * May be null in L2-only mode.
+ */
+ private final Cache l1Cache;
+
+ /**
+ * Whether L2-only mode is enabled (no L1 cache).
+ */
+ private final boolean l2OnlyMode;
+
+ /**
+ * L2 cache connection (Valkey/Redis), may be null.
+ */
+ private final ValkeyConnection l2Connection;
+
+ /**
+ * L1 cache TTL (max TTL for in-memory entries).
+ */
+ private final Duration l1Ttl;
+
+ /**
+ * L2 cache TTL (max TTL for Valkey entries).
+ */
+ private final Duration l2Ttl;
+
+ /**
+ * In-flight requests to prevent stampede.
+ */
+ private final ConcurrentMap> inflight;
+
+ /**
+ * Statistics.
+ */
+ private volatile long l1Hits;
+ private volatile long l2Hits;
+ private volatile long misses;
+
+ /**
+ * Constructor with defaults.
+ */
+ public FilteredMetadataCache() {
+ this(DEFAULT_L1_SIZE, DEFAULT_MAX_TTL, DEFAULT_MAX_TTL, null);
+ }
+
+ /**
+ * Constructor with Valkey connection.
+ *
+ * @param valkey Valkey connection for L2 cache
+ */
+ public FilteredMetadataCache(final ValkeyConnection valkey) {
+ this(DEFAULT_L1_SIZE, DEFAULT_MAX_TTL, DEFAULT_MAX_TTL, valkey);
+ }
+
+ /**
+ * Constructor from configuration.
+ *
+ * @param config Cache configuration
+ * @param valkey Valkey connection for L2 cache (null for single-tier)
+ */
+ public FilteredMetadataCache(
+ final FilteredMetadataCacheConfig config,
+ final ValkeyConnection valkey
+ ) {
+ this(
+ config.isValkeyEnabled() ? config.l1MaxSize() : config.maxSize(),
+ config.isValkeyEnabled() ? config.l1Ttl() : config.ttl(),
+ config.isValkeyEnabled() ? config.l2Ttl() : config.ttl(),
+ valkey
+ );
+ }
+
+ /**
+ * Full constructor.
+ *
+ * @param l1Size Maximum L1 cache size (0 for L2-only mode)
+ * @param l1Ttl L1 cache TTL
+ * @param l2Ttl L2 cache TTL
+ * @param valkey Valkey connection (null for single-tier)
+ */
+ public FilteredMetadataCache(
+ final int l1Size,
+ final Duration l1Ttl,
+ final Duration l2Ttl,
+ final ValkeyConnection valkey
+ ) {
+ // L2-only mode: l1Size == 0 AND valkey is available
+ this.l2OnlyMode = (l1Size == 0 && valkey != null);
+
+ // L1 cache with dynamic per-entry expiration based on blockedUntil
+ // Skip L1 cache creation in L2-only mode
+ if (this.l2OnlyMode) {
+ this.l1Cache = null;
+ } else {
+ // Use scheduler for more timely eviction of expired entries
+ this.l1Cache = Caffeine.newBuilder()
+ .maximumSize(l1Size > 0 ? l1Size : DEFAULT_L1_SIZE)
+ .scheduler(com.github.benmanes.caffeine.cache.Scheduler.systemScheduler())
+ .expireAfter(new Expiry() {
+ @Override
+ public long expireAfterCreate(String key, CacheEntry entry, long currentTime) {
+ return entry.ttlNanos();
+ }
+
+ @Override
+ public long expireAfterUpdate(String key, CacheEntry entry, long currentTime, long currentDuration) {
+ return entry.ttlNanos();
+ }
+
+ @Override
+ public long expireAfterRead(String key, CacheEntry entry, long currentTime, long currentDuration) {
+ // Recalculate remaining TTL on read to handle time-based expiry
+ return entry.ttlNanos();
+ }
+ })
+ .recordStats()
+ .build();
+ }
+ this.l2Connection = valkey;
+ this.l1Ttl = l1Ttl;
+ this.l2Ttl = l2Ttl;
+ this.inflight = new ConcurrentHashMap<>();
+ this.l1Hits = 0;
+ this.l2Hits = 0;
+ this.misses = 0;
+
+ // Register cache size gauge with metrics
+ if (CooldownMetrics.isAvailable()) {
+ CooldownMetrics.getInstance().setCacheSizeSupplier(this::size);
+ }
+ }
+
+ /**
+ * Get filtered metadata from cache, or compute if missing.
+ *
+ * @param repoType Repository type
+ * @param repoName Repository name
+ * @param packageName Package name
+ * @param loader Function to compute filtered metadata and earliest blockedUntil on cache miss
+ * @return CompletableFuture with filtered metadata bytes
+ */
+ public CompletableFuture get(
+ final String repoType,
+ final String repoName,
+ final String packageName,
+ final java.util.function.Supplier> loader
+ ) {
+ final String key = cacheKey(repoType, repoName, packageName);
+
+ // L1 check - skip in L2-only mode
+ if (!this.l2OnlyMode && this.l1Cache != null) {
+ final CacheEntry l1Cached = this.l1Cache.getIfPresent(key);
+ if (l1Cached != null) {
+ // Check if entry has expired (blockedUntil has passed)
+ if (l1Cached.isExpired()) {
+ // Entry expired - invalidate and reload
+ this.l1Cache.invalidate(key);
+ } else {
+ this.l1Hits++;
+ if (CooldownMetrics.isAvailable()) {
+ CooldownMetrics.getInstance().recordCacheHit("l1");
+ }
+ return CompletableFuture.completedFuture(l1Cached.data());
+ }
+ }
+ }
+
+ // L2 check (if available)
+ if (this.l2Connection != null) {
+ return this.l2Connection.async().get(key)
+ .toCompletableFuture()
+ .orTimeout(100, TimeUnit.MILLISECONDS)
+ .exceptionally(err -> null)
+ .thenCompose(l2Bytes -> {
+ if (l2Bytes != null) {
+ this.l2Hits++;
+ if (CooldownMetrics.isAvailable()) {
+ CooldownMetrics.getInstance().recordCacheHit("l2");
+ }
+ // Promote to L1 with remaining TTL from L2 (skip in L2-only mode)
+ if (!this.l2OnlyMode && this.l1Cache != null) {
+ // Note: L2 doesn't store TTL info, so use L1 TTL for promoted entries
+ final CacheEntry entry = new CacheEntry(l2Bytes, Optional.empty(), this.l1Ttl);
+ this.l1Cache.put(key, entry);
+ }
+ return CompletableFuture.completedFuture(l2Bytes);
+ }
+ // Miss - load and cache
+ this.misses++;
+ if (CooldownMetrics.isAvailable()) {
+ CooldownMetrics.getInstance().recordCacheMiss();
+ }
+ return this.loadAndCache(key, loader);
+ });
+ }
+
+ // Single-tier: load and cache
+ this.misses++;
+ if (CooldownMetrics.isAvailable()) {
+ CooldownMetrics.getInstance().recordCacheMiss();
+ }
+ return this.loadAndCache(key, loader);
+ }
+
+ /**
+ * Load metadata and cache in both tiers with dynamic TTL.
+ * Uses single-flight pattern to prevent stampede.
+ */
+ private CompletableFuture loadAndCache(
+ final String key,
+ final java.util.function.Supplier> loader
+ ) {
+ // Check if already loading (stampede prevention)
+ final CompletableFuture existing = this.inflight.get(key);
+ if (existing != null) {
+ return existing.thenApply(CacheEntry::data);
+ }
+
+ // Start loading
+ final CompletableFuture future = loader.get()
+ .whenComplete((entry, error) -> {
+ this.inflight.remove(key);
+ if (error == null && entry != null) {
+ // Cache in L1 with L1 TTL (skip in L2-only mode)
+ if (!this.l2OnlyMode && this.l1Cache != null) {
+ // Wrap entry with L1 TTL for proper expiration
+ final CacheEntry l1Entry = new CacheEntry(
+ entry.data(),
+ entry.earliestBlockedUntil(),
+ this.l1Ttl
+ );
+ this.l1Cache.put(key, l1Entry);
+ }
+ // Cache in L2 with L2 TTL (use configured l2Ttl, capped by blockedUntil if present)
+ if (this.l2Connection != null) {
+ final long ttlSeconds = this.calculateL2Ttl(entry);
+ if (ttlSeconds > 0) {
+ this.l2Connection.async().setex(key, ttlSeconds, entry.data());
+ }
+ }
+ }
+ });
+
+ this.inflight.put(key, future);
+ return future.thenApply(CacheEntry::data);
+ }
+
+ /**
+ * Invalidate cached metadata for a package.
+ * Called when a version is blocked or unblocked.
+ *
+ * @param repoType Repository type
+ * @param repoName Repository name
+ * @param packageName Package name
+ */
+ public void invalidate(
+ final String repoType,
+ final String repoName,
+ final String packageName
+ ) {
+ final String key = cacheKey(repoType, repoName, packageName);
+ if (this.l1Cache != null) {
+ this.l1Cache.invalidate(key);
+ }
+ this.inflight.remove(key);
+ if (this.l2Connection != null) {
+ this.l2Connection.async().del(key);
+ }
+ }
+
+ /**
+ * Invalidate all cached metadata for a repository.
+ *
+ * @param repoType Repository type
+ * @param repoName Repository name
+ */
+ public void invalidateAll(final String repoType, final String repoName) {
+ final String prefix = "metadata:" + repoType + ":" + repoName + ":";
+
+ // L1: Invalidate matching keys (skip in L2-only mode)
+ if (this.l1Cache != null) {
+ this.l1Cache.asMap().keySet().stream()
+ .filter(key -> key.startsWith(prefix))
+ .forEach(key -> {
+ this.l1Cache.invalidate(key);
+ this.inflight.remove(key);
+ });
+ }
+
+ // Also clear any inflight requests for this repo
+ this.inflight.keySet().stream()
+ .filter(key -> key.startsWith(prefix))
+ .forEach(this.inflight::remove);
+
+ // L2: Pattern delete (expensive but rare)
+ if (this.l2Connection != null) {
+ this.l2Connection.async().keys(prefix + "*")
+ .thenAccept(keys -> {
+ if (keys != null && !keys.isEmpty()) {
+ this.l2Connection.async().del(keys.toArray(new String[0]));
+ }
+ });
+ }
+ }
+
+ /**
+ * Clear all caches.
+ */
+ public void clear() {
+ if (this.l1Cache != null) {
+ this.l1Cache.invalidateAll();
+ }
+ this.inflight.clear();
+ this.l1Hits = 0;
+ this.l2Hits = 0;
+ this.misses = 0;
+ }
+
+ /**
+ * Get cache statistics.
+ *
+ * @return Statistics string
+ */
+ public String stats() {
+ final long total = this.l1Hits + this.l2Hits + this.misses;
+ if (total == 0) {
+ return this.l2OnlyMode
+ ? "FilteredMetadataCache[L2-only, empty]"
+ : "FilteredMetadataCache[empty]";
+ }
+ final double hitRate = 100.0 * (this.l1Hits + this.l2Hits) / total;
+ if (this.l2OnlyMode) {
+ return String.format(
+ "FilteredMetadataCache[L2-only, l2Hits=%d, misses=%d, hitRate=%.1f%%]",
+ this.l2Hits,
+ this.misses,
+ hitRate
+ );
+ }
+ return String.format(
+ "FilteredMetadataCache[size=%d, l1Hits=%d, l2Hits=%d, misses=%d, hitRate=%.1f%%]",
+ this.l1Cache != null ? this.l1Cache.estimatedSize() : 0,
+ this.l1Hits,
+ this.l2Hits,
+ this.misses,
+ hitRate
+ );
+ }
+
+ /**
+ * Get estimated cache size.
+ *
+ * @return Number of cached entries in L1
+ */
+ public long size() {
+ return this.l1Cache != null ? this.l1Cache.estimatedSize() : 0;
+ }
+
+ /**
+ * Check if running in L2-only mode.
+ *
+ * @return True if L2-only mode is enabled
+ */
+ public boolean isL2OnlyMode() {
+ return this.l2OnlyMode;
+ }
+
+ /**
+ * Force cleanup of expired entries.
+ * Caffeine doesn't actively evict entries - this forces a check.
+ * Primarily useful for testing.
+ */
+ public void cleanUp() {
+ if (this.l1Cache != null) {
+ this.l1Cache.cleanUp();
+ }
+ }
+
+ /**
+ * Generate cache key.
+ */
+ private String cacheKey(
+ final String repoType,
+ final String repoName,
+ final String packageName
+ ) {
+ return String.format("metadata:%s:%s:%s", repoType, repoName, packageName);
+ }
+
+ /**
+ * Calculate L2 TTL for a cache entry.
+ * Uses the configured l2Ttl, but caps it by blockedUntil if present.
+ *
+ * @param entry Cache entry
+ * @return TTL in seconds for L2 cache
+ */
+ private long calculateL2Ttl(final CacheEntry entry) {
+ if (entry.earliestBlockedUntil().isPresent()) {
+ // If versions are blocked, TTL = min(l2Ttl, time until earliest block expires)
+ final Duration remaining = Duration.between(Instant.now(), entry.earliestBlockedUntil().get());
+ if (remaining.isNegative() || remaining.isZero()) {
+ return MIN_TTL.getSeconds();
+ }
+ // Use the smaller of remaining time and configured l2Ttl
+ return Math.min(remaining.getSeconds(), this.l2Ttl.getSeconds());
+ }
+ // No blocked versions - use configured l2Ttl
+ return this.l2Ttl.getSeconds();
+ }
+
+ /**
+ * Cache entry with filtered metadata and dynamic TTL.
+ * TTL is calculated based on the earliest blockedUntil timestamp.
+ */
+ public static final class CacheEntry {
+ private final byte[] data;
+ private final Optional earliestBlockedUntil;
+ private final Duration maxTtl;
+ private final Instant createdAt;
+
+ /**
+ * Constructor.
+ *
+ * @param data Filtered metadata bytes
+ * @param earliestBlockedUntil Earliest blockedUntil among blocked versions (empty if none blocked)
+ * @param maxTtl Maximum TTL when no versions are blocked
+ */
+ public CacheEntry(
+ final byte[] data,
+ final Optional earliestBlockedUntil,
+ final Duration maxTtl
+ ) {
+ this.data = data;
+ this.earliestBlockedUntil = earliestBlockedUntil;
+ this.maxTtl = maxTtl;
+ this.createdAt = Instant.now();
+ }
+
+ /**
+ * Get filtered metadata bytes.
+ *
+ * @return Metadata bytes
+ */
+ public byte[] data() {
+ return this.data;
+ }
+
+ /**
+ * Get earliest blockedUntil timestamp.
+ *
+ * @return Earliest blockedUntil or empty if no versions blocked
+ */
+ public Optional earliestBlockedUntil() {
+ return this.earliestBlockedUntil;
+ }
+
+ /**
+ * Check if this entry has expired.
+ * An entry is expired if blockedUntil has passed.
+ *
+ * @return true if expired
+ */
+ public boolean isExpired() {
+ if (this.earliestBlockedUntil.isPresent()) {
+ return Instant.now().isAfter(this.earliestBlockedUntil.get());
+ }
+ // No blocked versions - check if max TTL has passed since creation
+ return Duration.between(this.createdAt, Instant.now()).compareTo(this.maxTtl) > 0;
+ }
+
+ /**
+ * Calculate TTL in nanoseconds for Caffeine expiry.
+ * If versions are blocked: TTL = earliestBlockedUntil - now
+ * If no versions blocked: TTL = maxTtl (release dates don't change)
+ *
+ * @return TTL in nanoseconds
+ */
+ public long ttlNanos() {
+ if (this.earliestBlockedUntil.isPresent()) {
+ final Duration remaining = Duration.between(Instant.now(), this.earliestBlockedUntil.get());
+ if (remaining.isNegative() || remaining.isZero()) {
+ // Already expired - use minimum TTL
+ return MIN_TTL.toNanos();
+ }
+ return remaining.toNanos();
+ }
+ // No blocked versions - cache for max TTL
+ return this.maxTtl.toNanos();
+ }
+
+ /**
+ * Calculate TTL in seconds for L2 cache.
+ *
+ * @return TTL in seconds
+ */
+ public long ttlSeconds() {
+ return Math.max(MIN_TTL.getSeconds(), this.ttlNanos() / 1_000_000_000L);
+ }
+
+ /**
+ * Create entry for metadata with no blocked versions.
+ * Uses maximum TTL since release dates don't change.
+ *
+ * @param data Filtered metadata bytes
+ * @param maxTtl Maximum TTL
+ * @return Cache entry
+ */
+ public static CacheEntry noBlockedVersions(final byte[] data, final Duration maxTtl) {
+ return new CacheEntry(data, Optional.empty(), maxTtl);
+ }
+
+ /**
+ * Create entry for metadata with blocked versions.
+ * TTL is set to expire when the earliest block expires.
+ *
+ * @param data Filtered metadata bytes
+ * @param earliestBlockedUntil When the earliest block expires
+ * @param maxTtl Maximum TTL (used as fallback)
+ * @return Cache entry
+ */
+ public static CacheEntry withBlockedVersions(
+ final byte[] data,
+ final Instant earliestBlockedUntil,
+ final Duration maxTtl
+ ) {
+ return new CacheEntry(data, Optional.of(earliestBlockedUntil), maxTtl);
+ }
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/metadata/FilteredMetadataCacheConfig.java b/artipie-core/src/main/java/com/artipie/cooldown/metadata/FilteredMetadataCacheConfig.java
new file mode 100644
index 000000000..5b9a1273b
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/metadata/FilteredMetadataCacheConfig.java
@@ -0,0 +1,333 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown.metadata;
+
+import com.amihaiemil.eoyaml.YamlMapping;
+import java.time.Duration;
+
+/**
+ * Configuration for FilteredMetadataCache (cooldown metadata caching).
+ *
+ * Example YAML configuration in artipie.yaml:
+ *
+ * meta:
+ * caches:
+ * cooldown-metadata:
+ * ttl: 24h
+ * maxSize: 5000
+ * valkey:
+ * enabled: true
+ * l1MaxSize: 500
+ * l1Ttl: 5m
+ * l2Ttl: 24h
+ *
+ *
+ * For large NPM metadata (3-38MB per package), consider:
+ *
+ * Reducing l1MaxSize to avoid memory pressure
+ * Setting l1MaxSize to 0 for L2-only mode (Valkey only)
+ *
+ *
+ * @since 1.18.22
+ */
+public final class FilteredMetadataCacheConfig {
+
+ /**
+ * Default TTL for cache entries (24 hours).
+ */
+ public static final Duration DEFAULT_TTL = Duration.ofHours(24);
+
+ /**
+ * Default maximum L1 cache size (5,000 packages).
+ */
+ public static final int DEFAULT_MAX_SIZE = 5_000;
+
+ /**
+ * Default L1 TTL when L2 is enabled (5 minutes).
+ */
+ public static final Duration DEFAULT_L1_TTL = Duration.ofMinutes(5);
+
+ /**
+ * Default L2 TTL (24 hours).
+ */
+ public static final Duration DEFAULT_L2_TTL = Duration.ofHours(24);
+
+ /**
+ * Default L1 max size when L2 enabled (500 - reduced for large metadata).
+ */
+ public static final int DEFAULT_L1_MAX_SIZE = 500;
+
+ /**
+ * Default L2 max size (not enforced by Valkey, informational only).
+ */
+ public static final int DEFAULT_L2_MAX_SIZE = 100_000;
+
+ /**
+ * Global instance (singleton).
+ */
+ private static volatile FilteredMetadataCacheConfig instance;
+
+ /**
+ * TTL for single-tier or fallback.
+ */
+ private final Duration ttl;
+
+ /**
+ * Max size for single-tier.
+ */
+ private final int maxSize;
+
+ /**
+ * Whether Valkey L2 is enabled.
+ */
+ private final boolean valkeyEnabled;
+
+ /**
+ * L1 cache max size.
+ */
+ private final int l1MaxSize;
+
+ /**
+ * L1 cache TTL.
+ */
+ private final Duration l1Ttl;
+
+ /**
+ * L2 cache TTL.
+ */
+ private final Duration l2Ttl;
+
+ /**
+ * L2 cache max size (informational, not enforced by Valkey).
+ */
+ private final int l2MaxSize;
+
+ /**
+ * Default constructor with sensible defaults.
+ */
+ public FilteredMetadataCacheConfig() {
+ this(DEFAULT_TTL, DEFAULT_MAX_SIZE, false,
+ DEFAULT_L1_MAX_SIZE, DEFAULT_L1_TTL, DEFAULT_L2_TTL, DEFAULT_L2_MAX_SIZE);
+ }
+
+ /**
+ * Full constructor.
+ * @param ttl Default TTL
+ * @param maxSize Default max size (single-tier)
+ * @param valkeyEnabled Whether L2 is enabled
+ * @param l1MaxSize L1 max size (0 for L2-only mode)
+ * @param l1Ttl L1 TTL
+ * @param l2Ttl L2 TTL
+ * @param l2MaxSize L2 max size (informational)
+ */
+ public FilteredMetadataCacheConfig(
+ final Duration ttl,
+ final int maxSize,
+ final boolean valkeyEnabled,
+ final int l1MaxSize,
+ final Duration l1Ttl,
+ final Duration l2Ttl,
+ final int l2MaxSize
+ ) {
+ this.ttl = ttl;
+ this.maxSize = maxSize;
+ this.valkeyEnabled = valkeyEnabled;
+ this.l1MaxSize = l1MaxSize;
+ this.l1Ttl = l1Ttl;
+ this.l2Ttl = l2Ttl;
+ this.l2MaxSize = l2MaxSize;
+ }
+
+ /**
+ * Get default TTL.
+ * @return TTL duration
+ */
+ public Duration ttl() {
+ return this.ttl;
+ }
+
+ /**
+ * Get max size for single-tier cache.
+ * @return Max size
+ */
+ public int maxSize() {
+ return this.maxSize;
+ }
+
+ /**
+ * Check if Valkey L2 is enabled.
+ * @return True if two-tier caching is enabled
+ */
+ public boolean isValkeyEnabled() {
+ return this.valkeyEnabled;
+ }
+
+ /**
+ * Get L1 max size.
+ * A value of 0 indicates L2-only mode (no in-memory caching).
+ * @return L1 max size
+ */
+ public int l1MaxSize() {
+ return this.l1MaxSize;
+ }
+
+ /**
+ * Get L1 TTL.
+ * @return L1 TTL
+ */
+ public Duration l1Ttl() {
+ return this.l1Ttl;
+ }
+
+ /**
+ * Get L2 TTL.
+ * @return L2 TTL
+ */
+ public Duration l2Ttl() {
+ return this.l2Ttl;
+ }
+
+ /**
+ * Get L2 max size.
+ * @return L2 max size
+ */
+ public int l2MaxSize() {
+ return this.l2MaxSize;
+ }
+
+ /**
+ * Check if L2-only mode is enabled (l1MaxSize == 0 and valkey enabled).
+ * @return True if L2-only mode
+ */
+ public boolean isL2OnlyMode() {
+ return this.valkeyEnabled && this.l1MaxSize == 0;
+ }
+
+ /**
+ * Initialize global instance from YAML.
+ * Should be called once at startup.
+ * @param caches The caches YAML mapping from artipie.yaml
+ */
+ public static void initialize(final YamlMapping caches) {
+ if (instance == null) {
+ synchronized (FilteredMetadataCacheConfig.class) {
+ if (instance == null) {
+ instance = fromYaml(caches);
+ }
+ }
+ }
+ }
+
+ /**
+ * Get the global instance.
+ * @return Global config (defaults if not initialized)
+ */
+ public static FilteredMetadataCacheConfig getInstance() {
+ if (instance == null) {
+ return new FilteredMetadataCacheConfig();
+ }
+ return instance;
+ }
+
+ /**
+ * Reset for testing.
+ */
+ public static void reset() {
+ instance = null;
+ }
+
+ /**
+ * Parse configuration from YAML.
+ * @param caches The caches YAML mapping
+ * @return Parsed config
+ */
+ public static FilteredMetadataCacheConfig fromYaml(final YamlMapping caches) {
+ if (caches == null) {
+ return new FilteredMetadataCacheConfig();
+ }
+ final YamlMapping cooldownMetadata = caches.yamlMapping("cooldown-metadata");
+ if (cooldownMetadata == null) {
+ return new FilteredMetadataCacheConfig();
+ }
+
+ final Duration ttl = parseDuration(cooldownMetadata.string("ttl"), DEFAULT_TTL);
+ final int maxSize = parseInt(cooldownMetadata.string("maxSize"), DEFAULT_MAX_SIZE);
+
+ // Check for valkey sub-config
+ final YamlMapping valkey = cooldownMetadata.yamlMapping("valkey");
+ if (valkey != null && "true".equalsIgnoreCase(valkey.string("enabled"))) {
+ return new FilteredMetadataCacheConfig(
+ ttl,
+ maxSize,
+ true,
+ parseInt(valkey.string("l1MaxSize"), DEFAULT_L1_MAX_SIZE),
+ parseDuration(valkey.string("l1Ttl"), DEFAULT_L1_TTL),
+ parseDuration(valkey.string("l2Ttl"), DEFAULT_L2_TTL),
+ parseInt(valkey.string("l2MaxSize"), DEFAULT_L2_MAX_SIZE)
+ );
+ }
+
+ return new FilteredMetadataCacheConfig(ttl, maxSize, false,
+ DEFAULT_L1_MAX_SIZE, DEFAULT_L1_TTL, DEFAULT_L2_TTL, DEFAULT_L2_MAX_SIZE);
+ }
+
+ /**
+ * Parse duration string (e.g., "24h", "7d", "5m").
+ * @param value Duration string
+ * @param defaultVal Default value
+ * @return Parsed duration
+ */
+ private static Duration parseDuration(final String value, final Duration defaultVal) {
+ if (value == null || value.isEmpty()) {
+ return defaultVal;
+ }
+ try {
+ final String trimmed = value.trim().toLowerCase();
+ if (trimmed.endsWith("d")) {
+ return Duration.ofDays(Long.parseLong(trimmed.substring(0, trimmed.length() - 1)));
+ } else if (trimmed.endsWith("h")) {
+ return Duration.ofHours(Long.parseLong(trimmed.substring(0, trimmed.length() - 1)));
+ } else if (trimmed.endsWith("m")) {
+ return Duration.ofMinutes(Long.parseLong(trimmed.substring(0, trimmed.length() - 1)));
+ } else if (trimmed.endsWith("s")) {
+ return Duration.ofSeconds(Long.parseLong(trimmed.substring(0, trimmed.length() - 1)));
+ }
+ return Duration.ofSeconds(Long.parseLong(trimmed));
+ } catch (final NumberFormatException ex) {
+ return defaultVal;
+ }
+ }
+
+ /**
+ * Parse integer string.
+ * @param value Integer string
+ * @param defaultVal Default value
+ * @return Parsed integer
+ */
+ private static int parseInt(final String value, final int defaultVal) {
+ if (value == null || value.isEmpty()) {
+ return defaultVal;
+ }
+ try {
+ return Integer.parseInt(value.trim());
+ } catch (final NumberFormatException ex) {
+ return defaultVal;
+ }
+ }
+
+ @Override
+ public String toString() {
+ if (this.valkeyEnabled) {
+ return String.format(
+ "FilteredMetadataCacheConfig{ttl=%s, valkeyEnabled=true, l1MaxSize=%d, l1Ttl=%s, l2Ttl=%s, l2Only=%s}",
+ this.ttl, this.l1MaxSize, this.l1Ttl, this.l2Ttl, this.isL2OnlyMode()
+ );
+ }
+ return String.format(
+ "FilteredMetadataCacheConfig{ttl=%s, maxSize=%d, valkeyEnabled=false}",
+ this.ttl, this.maxSize
+ );
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/metadata/MetadataAwareInspector.java b/artipie-core/src/main/java/com/artipie/cooldown/metadata/MetadataAwareInspector.java
new file mode 100644
index 000000000..39ddd508c
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/metadata/MetadataAwareInspector.java
@@ -0,0 +1,49 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown.metadata;
+
+import java.time.Instant;
+import java.util.Map;
+
+/**
+ * Extension interface for {@link com.artipie.cooldown.CooldownInspector} implementations
+ * that can accept preloaded release dates from metadata.
+ *
+ * When metadata contains release timestamps (e.g., NPM's {@code time} object),
+ * the cooldown metadata service can preload these dates into the inspector.
+ * This avoids additional upstream HTTP requests when evaluating cooldown for versions.
+ *
+ * Inspectors implementing this interface should:
+ *
+ * Store preloaded dates in a thread-safe manner
+ * Check preloaded dates first in {@code releaseDate()} before hitting upstream
+ * Clear preloaded dates after processing to avoid stale data
+ *
+ *
+ * @since 1.0
+ */
+public interface MetadataAwareInspector {
+
+ /**
+ * Preload release dates extracted from metadata.
+ * Called by the cooldown metadata service before evaluating versions.
+ *
+ * @param releaseDates Map of version string → release timestamp
+ */
+ void preloadReleaseDates(Map releaseDates);
+
+ /**
+ * Clear preloaded release dates.
+ * Called after metadata processing is complete.
+ */
+ void clearPreloadedDates();
+
+ /**
+ * Check if release dates are currently preloaded.
+ *
+ * @return {@code true} if preloaded dates are available
+ */
+ boolean hasPreloadedDates();
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/metadata/MetadataFilter.java b/artipie-core/src/main/java/com/artipie/cooldown/metadata/MetadataFilter.java
new file mode 100644
index 000000000..4b1415610
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/metadata/MetadataFilter.java
@@ -0,0 +1,45 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown.metadata;
+
+import java.util.Set;
+
+/**
+ * Filters blocked versions from parsed metadata.
+ * Each adapter implements this to remove blocked versions from its metadata format.
+ *
+ * Implementations must:
+ *
+ * Remove blocked versions from version lists/objects
+ * Remove associated data (timestamps, checksums, download URLs) for blocked versions
+ * Preserve all other metadata unchanged
+ *
+ *
+ * @param Type of parsed metadata object (must match {@link MetadataParser})
+ * @since 1.0
+ */
+public interface MetadataFilter {
+
+ /**
+ * Filter blocked versions from metadata.
+ * Returns a new or modified metadata object with blocked versions removed.
+ *
+ * @param metadata Parsed metadata object
+ * @param blockedVersions Set of version strings to remove
+ * @return Filtered metadata (may be same instance if mutable, or new instance)
+ */
+ T filter(T metadata, Set blockedVersions);
+
+ /**
+ * Update the "latest" version tag in metadata.
+ * Called when the current latest version is blocked and needs to be updated
+ * to point to the highest unblocked version.
+ *
+ * @param metadata Filtered metadata object
+ * @param newLatest New latest version string
+ * @return Updated metadata (may be same instance if mutable, or new instance)
+ */
+ T updateLatest(T metadata, String newLatest);
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/metadata/MetadataParseException.java b/artipie-core/src/main/java/com/artipie/cooldown/metadata/MetadataParseException.java
new file mode 100644
index 000000000..a8cd60ab0
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/metadata/MetadataParseException.java
@@ -0,0 +1,34 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown.metadata;
+
+/**
+ * Exception thrown when metadata parsing fails.
+ *
+ * @since 1.0
+ */
+public final class MetadataParseException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Constructor with message.
+ *
+ * @param message Error message
+ */
+ public MetadataParseException(final String message) {
+ super(message);
+ }
+
+ /**
+ * Constructor with message and cause.
+ *
+ * @param message Error message
+ * @param cause Underlying cause
+ */
+ public MetadataParseException(final String message, final Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/metadata/MetadataParser.java b/artipie-core/src/main/java/com/artipie/cooldown/metadata/MetadataParser.java
new file mode 100644
index 000000000..d65c347ca
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/metadata/MetadataParser.java
@@ -0,0 +1,61 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown.metadata;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Parses package metadata from raw bytes into a structured representation.
+ * Each adapter implements this for its specific metadata format (JSON, XML, HTML, text).
+ *
+ * The type parameter {@code T} represents the parsed metadata structure:
+ *
+ * NPM/Composer: Jackson {@code JsonNode}
+ * Maven: DOM {@code Document}
+ * PyPI: Jsoup {@code Document}
+ * Go: {@code List}
+ *
+ *
+ * @param Type of parsed metadata object
+ * @since 1.0
+ */
+public interface MetadataParser {
+
+ /**
+ * Parse raw metadata bytes into structured representation.
+ *
+ * @param bytes Raw metadata bytes
+ * @return Parsed metadata object
+ * @throws MetadataParseException If parsing fails
+ */
+ T parse(byte[] bytes) throws MetadataParseException;
+
+ /**
+ * Extract all version strings from parsed metadata.
+ * Versions should be returned in their natural order from the metadata
+ * (typically newest first for NPM/Composer, or as listed for Maven).
+ *
+ * @param metadata Parsed metadata object
+ * @return List of all version strings
+ */
+ List extractVersions(T metadata);
+
+ /**
+ * Get the "latest" version tag if the format supports it.
+ * For NPM this is {@code dist-tags.latest}, for Maven it's {@code }.
+ *
+ * @param metadata Parsed metadata object
+ * @return Latest version if present, empty otherwise
+ */
+ Optional getLatestVersion(T metadata);
+
+ /**
+ * Get the content type for this metadata format.
+ *
+ * @return MIME content type (e.g., "application/json", "application/xml")
+ */
+ String contentType();
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/metadata/MetadataRequestDetector.java b/artipie-core/src/main/java/com/artipie/cooldown/metadata/MetadataRequestDetector.java
new file mode 100644
index 000000000..02df3ed02
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/metadata/MetadataRequestDetector.java
@@ -0,0 +1,47 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown.metadata;
+
+import java.util.Optional;
+
+/**
+ * Detects whether an HTTP request path is a metadata request (vs artifact download).
+ * Each adapter implements this interface to identify metadata endpoints for its package format.
+ *
+ * Examples:
+ *
+ * NPM: {@code /lodash} is metadata, {@code /lodash/-/lodash-4.17.21.tgz} is artifact
+ * Maven: {@code .../maven-metadata.xml} is metadata, {@code .../artifact-1.0.jar} is artifact
+ * PyPI: {@code /simple/requests/} is metadata
+ * Go: {@code /@v/list} is metadata
+ *
+ *
+ * @since 1.0
+ */
+public interface MetadataRequestDetector {
+
+ /**
+ * Check if the given request path is a metadata request.
+ *
+ * @param path Request path (e.g., "/lodash", "/simple/requests/")
+ * @return {@code true} if this is a metadata request, {@code false} if artifact download
+ */
+ boolean isMetadataRequest(String path);
+
+ /**
+ * Extract package name from a metadata request path.
+ *
+ * @param path Request path
+ * @return Package name if this is a metadata request, empty otherwise
+ */
+ Optional extractPackageName(String path);
+
+ /**
+ * Get the repository type this detector handles.
+ *
+ * @return Repository type identifier (e.g., "npm", "maven", "pypi")
+ */
+ String repoType();
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/metadata/MetadataRewriteException.java b/artipie-core/src/main/java/com/artipie/cooldown/metadata/MetadataRewriteException.java
new file mode 100644
index 000000000..b300d3af7
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/metadata/MetadataRewriteException.java
@@ -0,0 +1,34 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown.metadata;
+
+/**
+ * Exception thrown when metadata rewriting/serialization fails.
+ *
+ * @since 1.0
+ */
+public final class MetadataRewriteException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Constructor with message.
+ *
+ * @param message Error message
+ */
+ public MetadataRewriteException(final String message) {
+ super(message);
+ }
+
+ /**
+ * Constructor with message and cause.
+ *
+ * @param message Error message
+ * @param cause Underlying cause
+ */
+ public MetadataRewriteException(final String message, final Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/metadata/MetadataRewriter.java b/artipie-core/src/main/java/com/artipie/cooldown/metadata/MetadataRewriter.java
new file mode 100644
index 000000000..ae6dab07f
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/metadata/MetadataRewriter.java
@@ -0,0 +1,31 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown.metadata;
+
+/**
+ * Serializes filtered metadata back to bytes for HTTP response.
+ * Each adapter implements this to serialize its metadata format.
+ *
+ * @param Type of parsed metadata object (must match {@link MetadataParser} and {@link MetadataFilter})
+ * @since 1.0
+ */
+public interface MetadataRewriter {
+
+ /**
+ * Serialize filtered metadata to bytes.
+ *
+ * @param metadata Filtered metadata object
+ * @return Serialized bytes ready for HTTP response
+ * @throws MetadataRewriteException If serialization fails
+ */
+ byte[] rewrite(T metadata) throws MetadataRewriteException;
+
+ /**
+ * Get the HTTP Content-Type header value for this metadata format.
+ *
+ * @return Content-Type value (e.g., "application/json", "application/xml", "text/html")
+ */
+ String contentType();
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/metadata/NoopCooldownMetadataService.java b/artipie-core/src/main/java/com/artipie/cooldown/metadata/NoopCooldownMetadataService.java
new file mode 100644
index 000000000..1f8d1d5dd
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/metadata/NoopCooldownMetadataService.java
@@ -0,0 +1,64 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown.metadata;
+
+import com.artipie.cooldown.CooldownInspector;
+
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * No-op implementation of {@link CooldownMetadataService}.
+ * Returns raw metadata unchanged. Used when cooldown is disabled.
+ *
+ * @since 1.0
+ */
+public final class NoopCooldownMetadataService implements CooldownMetadataService {
+
+ /**
+ * Singleton instance.
+ */
+ public static final NoopCooldownMetadataService INSTANCE = new NoopCooldownMetadataService();
+
+ /**
+ * Private constructor.
+ */
+ private NoopCooldownMetadataService() {
+ }
+
+ @Override
+ public CompletableFuture filterMetadata(
+ final String repoType,
+ final String repoName,
+ final String packageName,
+ final byte[] rawMetadata,
+ final MetadataParser parser,
+ final MetadataFilter filter,
+ final MetadataRewriter rewriter,
+ final Optional inspector
+ ) {
+ // Return raw metadata unchanged
+ return CompletableFuture.completedFuture(rawMetadata);
+ }
+
+ @Override
+ public void invalidate(
+ final String repoType,
+ final String repoName,
+ final String packageName
+ ) {
+ // No-op
+ }
+
+ @Override
+ public void invalidateAll(final String repoType, final String repoName) {
+ // No-op
+ }
+
+ @Override
+ public String stats() {
+ return "NoopCooldownMetadataService[disabled]";
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/metadata/ReleaseDateProvider.java b/artipie-core/src/main/java/com/artipie/cooldown/metadata/ReleaseDateProvider.java
new file mode 100644
index 000000000..2ae94ce61
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/metadata/ReleaseDateProvider.java
@@ -0,0 +1,35 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown.metadata;
+
+import java.time.Instant;
+import java.util.Map;
+
+/**
+ * Optional extension for {@link MetadataParser} implementations that can extract
+ * release dates directly from metadata.
+ *
+ * Some package formats include release timestamps in their metadata:
+ *
+ * NPM: {@code time} object with version → ISO timestamp
+ * Composer: {@code time} field in version objects
+ *
+ *
+ * When a parser implements this interface, the cooldown metadata service can
+ * preload release dates into inspectors, avoiding additional upstream HTTP requests.
+ *
+ * @param Type of parsed metadata object (must match {@link MetadataParser})
+ * @since 1.0
+ */
+public interface ReleaseDateProvider {
+
+ /**
+ * Extract release dates from parsed metadata.
+ *
+ * @param metadata Parsed metadata object
+ * @return Map of version string → release timestamp (may be empty, never null)
+ */
+ Map releaseDates(T metadata);
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/metadata/VersionComparators.java b/artipie-core/src/main/java/com/artipie/cooldown/metadata/VersionComparators.java
new file mode 100644
index 000000000..616a79908
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/metadata/VersionComparators.java
@@ -0,0 +1,184 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown.metadata;
+
+import java.util.Comparator;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Version comparators for different package formats.
+ * Used to sort versions and determine the "latest" unblocked version.
+ *
+ * @since 1.0
+ */
+public final class VersionComparators {
+
+ /**
+ * Pattern for semantic versioning: major.minor.patch[-prerelease][+build].
+ * Prerelease and build metadata can contain alphanumeric, hyphens, and dots.
+ */
+ private static final Pattern SEMVER = Pattern.compile(
+ "^v?(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?(?:-([\\w.\\-]+))?(?:\\+([\\w.\\-]+))?$"
+ );
+
+ /**
+ * Private constructor.
+ */
+ private VersionComparators() {
+ }
+
+ /**
+ * Semantic version comparator (NPM, Composer style).
+ * Compares major.minor.patch numerically, then prerelease lexically.
+ * Versions without prerelease are considered newer than those with prerelease.
+ *
+ * @return Comparator that orders versions from oldest to newest
+ */
+ public static Comparator semver() {
+ return (v1, v2) -> {
+ final Matcher m1 = SEMVER.matcher(v1);
+ final Matcher m2 = SEMVER.matcher(v2);
+ final boolean match1 = m1.matches();
+ final boolean match2 = m2.matches();
+ // Handle non-semver versions consistently to maintain transitivity:
+ // - semver versions sort before non-semver versions
+ // - non-semver versions sort lexically among themselves
+ if (!match1 && !match2) {
+ // Both non-semver: lexical comparison
+ return v1.compareTo(v2);
+ }
+ if (!match1) {
+ // v1 is non-semver, v2 is semver: v1 sorts after v2
+ return 1;
+ }
+ if (!match2) {
+ // v1 is semver, v2 is non-semver: v1 sorts before v2
+ return -1;
+ }
+ // Both semver: compare numerically
+ // Compare major
+ int cmp = compareNumeric(m1.group(1), m2.group(1));
+ if (cmp != 0) {
+ return cmp;
+ }
+ // Compare minor
+ cmp = compareNumeric(m1.group(2), m2.group(2));
+ if (cmp != 0) {
+ return cmp;
+ }
+ // Compare patch
+ cmp = compareNumeric(m1.group(3), m2.group(3));
+ if (cmp != 0) {
+ return cmp;
+ }
+ // Compare prerelease: no prerelease > has prerelease
+ final String pre1 = m1.group(4);
+ final String pre2 = m2.group(4);
+ if (pre1 == null && pre2 == null) {
+ return 0;
+ }
+ if (pre1 == null) {
+ return 1; // v1 is release, v2 is prerelease → v1 > v2
+ }
+ if (pre2 == null) {
+ return -1; // v1 is prerelease, v2 is release → v1 < v2
+ }
+ return pre1.compareTo(pre2);
+ };
+ }
+
+ /**
+ * Maven version comparator.
+ * Handles Maven's version ordering rules (numeric segments, qualifiers).
+ *
+ * @return Comparator that orders versions from oldest to newest
+ */
+ public static Comparator maven() {
+ // Simplified Maven comparator - handles common cases
+ return (v1, v2) -> {
+ final String[] parts1 = v1.split("[.-]");
+ final String[] parts2 = v2.split("[.-]");
+ final int len = Math.max(parts1.length, parts2.length);
+ for (int i = 0; i < len; i++) {
+ final String p1 = i < parts1.length ? parts1[i] : "0";
+ final String p2 = i < parts2.length ? parts2[i] : "0";
+ // Try numeric comparison first
+ try {
+ final long n1 = Long.parseLong(p1);
+ final long n2 = Long.parseLong(p2);
+ if (n1 != n2) {
+ return Long.compare(n1, n2);
+ }
+ } catch (NumberFormatException e) {
+ // Fall back to string comparison for qualifiers
+ final int cmp = compareQualifier(p1, p2);
+ if (cmp != 0) {
+ return cmp;
+ }
+ }
+ }
+ return 0;
+ };
+ }
+
+ /**
+ * Simple lexical comparator.
+ * Useful for Go modules and other formats with simple version strings.
+ *
+ * @return Comparator that orders versions lexically
+ */
+ public static Comparator lexical() {
+ return String::compareTo;
+ }
+
+ /**
+ * Compare numeric strings, treating null/empty as 0.
+ * Uses Long to handle version numbers that exceed Integer.MAX_VALUE.
+ */
+ private static int compareNumeric(final String s1, final String s2) {
+ final long n1 = s1 == null || s1.isEmpty() ? 0L : Long.parseLong(s1);
+ final long n2 = s2 == null || s2.isEmpty() ? 0L : Long.parseLong(s2);
+ return Long.compare(n1, n2);
+ }
+
+ /**
+ * Compare Maven qualifiers.
+ * Order: alpha < beta < milestone < rc < snapshot < "" (release) < sp
+ */
+ private static int compareQualifier(final String q1, final String q2) {
+ return Integer.compare(qualifierRank(q1), qualifierRank(q2));
+ }
+
+ /**
+ * Get rank for Maven qualifier.
+ */
+ private static int qualifierRank(final String qualifier) {
+ final String lower = qualifier.toLowerCase();
+ if (lower.startsWith("alpha") || lower.equals("a")) {
+ return 1;
+ }
+ if (lower.startsWith("beta") || lower.equals("b")) {
+ return 2;
+ }
+ if (lower.startsWith("milestone") || lower.equals("m")) {
+ return 3;
+ }
+ if (lower.startsWith("rc") || lower.startsWith("cr")) {
+ return 4;
+ }
+ if (lower.equals("snapshot")) {
+ return 5;
+ }
+ if (lower.isEmpty() || lower.equals("final") || lower.equals("ga") || lower.equals("release")) {
+ return 6;
+ }
+ if (lower.startsWith("sp")) {
+ return 7;
+ }
+ // Unknown qualifier - treat as release equivalent
+ return 6;
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/metadata/package-info.java b/artipie-core/src/main/java/com/artipie/cooldown/metadata/package-info.java
new file mode 100644
index 000000000..7693894b2
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/metadata/package-info.java
@@ -0,0 +1,40 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+
+/**
+ * Cooldown metadata filtering infrastructure.
+ *
+ * This package provides the core infrastructure for filtering package metadata
+ * to remove blocked versions before serving to clients. This enables "version fallback"
+ * where clients automatically resolve to older, unblocked versions.
+ *
+ * Key Components
+ *
+ * {@link com.artipie.cooldown.metadata.CooldownMetadataService} - Main service interface
+ * {@link com.artipie.cooldown.metadata.MetadataParser} - Parse metadata from bytes
+ * {@link com.artipie.cooldown.metadata.MetadataFilter} - Filter blocked versions
+ * {@link com.artipie.cooldown.metadata.MetadataRewriter} - Serialize filtered metadata
+ * {@link com.artipie.cooldown.metadata.FilteredMetadataCache} - Cache filtered metadata
+ *
+ *
+ * Per-Adapter Implementation
+ * Each adapter (NPM, Maven, PyPI, etc.) implements:
+ *
+ * {@link com.artipie.cooldown.metadata.MetadataRequestDetector} - Detect metadata requests
+ * {@link com.artipie.cooldown.metadata.MetadataParser} - Parse format-specific metadata
+ * {@link com.artipie.cooldown.metadata.MetadataFilter} - Filter format-specific metadata
+ * {@link com.artipie.cooldown.metadata.MetadataRewriter} - Serialize format-specific metadata
+ *
+ *
+ * Performance Targets
+ *
+ * P99 latency: < 200ms for metadata filtering
+ * Cache hit rate: > 90%
+ * Throughput: 1,500 requests/second
+ *
+ *
+ * @since 1.0
+ */
+package com.artipie.cooldown.metadata;
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/metrics/CooldownMetrics.java b/artipie-core/src/main/java/com/artipie/cooldown/metrics/CooldownMetrics.java
new file mode 100644
index 000000000..9a290138e
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/metrics/CooldownMetrics.java
@@ -0,0 +1,379 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.cooldown.metrics;
+
+import com.artipie.metrics.MicrometerMetrics;
+import io.micrometer.core.instrument.Counter;
+import io.micrometer.core.instrument.Gauge;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.Timer;
+
+import java.time.Duration;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.Supplier;
+
+/**
+ * Metrics for cooldown functionality.
+ * Provides comprehensive observability for cooldown operations.
+ *
+ * Metric naming convention: {@code artipie.cooldown.*}
+ *
+ * Metrics emitted:
+ *
+ * Counters:
+ *
+ * {@code artipie.cooldown.versions.blocked} - versions blocked count
+ * {@code artipie.cooldown.versions.allowed} - versions allowed count
+ * {@code artipie.cooldown.cache.hits} - cache hits (L1/L2)
+ * {@code artipie.cooldown.cache.misses} - cache misses
+ * {@code artipie.cooldown.all_blocked} - all versions blocked events
+ * {@code artipie.cooldown.invalidations} - cache invalidations
+ *
+ * Gauges:
+ *
+ * {@code artipie.cooldown.cache.size} - current cache size
+ * {@code artipie.cooldown.active_blocks} - active blocks count
+ *
+ * Timers:
+ *
+ * {@code artipie.cooldown.metadata.filter.duration} - metadata filtering duration
+ * {@code artipie.cooldown.evaluate.duration} - per-version evaluation duration
+ * {@code artipie.cooldown.cache.load.duration} - cache load duration
+ *
+ *
+ *
+ * @since 1.0
+ */
+public final class CooldownMetrics {
+
+ /**
+ * Singleton instance.
+ */
+ private static volatile CooldownMetrics instance;
+
+ /**
+ * Meter registry.
+ */
+ private final MeterRegistry registry;
+
+ /**
+ * Active blocks gauge values per repo.
+ */
+ private final Map activeBlocksGauges;
+
+ /**
+ * All-blocked packages count (packages where ALL versions are blocked).
+ */
+ private final AtomicLong allBlockedPackages;
+
+ /**
+ * Cache size supplier (set by FilteredMetadataCache).
+ */
+ private volatile Supplier cacheSizeSupplier;
+
+ /**
+ * Private constructor.
+ *
+ * @param registry Meter registry
+ */
+ private CooldownMetrics(final MeterRegistry registry) {
+ this.registry = registry;
+ this.activeBlocksGauges = new ConcurrentHashMap<>();
+ this.allBlockedPackages = new AtomicLong(0);
+ this.cacheSizeSupplier = () -> 0L;
+
+ // Register cache size gauge - uses 'this' reference to prevent GC
+ Gauge.builder("artipie.cooldown.cache.size", this, m -> m.cacheSizeSupplier.get().doubleValue())
+ .description("Current cooldown metadata cache size")
+ .register(registry);
+
+ // Register global active_blocks gauge - always emits, computes total from per-repo gauges
+ // Uses 'this' reference to prevent GC and ensure gauge is never stale
+ Gauge.builder("artipie.cooldown.active_blocks", this, CooldownMetrics::computeTotalActiveBlocks)
+ .description("Total active cooldown blocks across all repositories")
+ .register(registry);
+
+ // Register all_blocked gauge - tracks packages where ALL versions are blocked
+ // Persisted in database, loaded on startup, always emits current value
+ Gauge.builder("artipie.cooldown.all_blocked", this.allBlockedPackages, AtomicLong::doubleValue)
+ .description("Number of packages where all versions are blocked")
+ .register(registry);
+ }
+
+ /**
+ * Get or create singleton instance.
+ *
+ * @return CooldownMetrics instance, or null if MicrometerMetrics not initialized
+ */
+ public static CooldownMetrics getInstance() {
+ if (instance == null) {
+ synchronized (CooldownMetrics.class) {
+ if (instance == null && MicrometerMetrics.isInitialized()) {
+ instance = new CooldownMetrics(MicrometerMetrics.getInstance().getRegistry());
+ }
+ }
+ }
+ return instance;
+ }
+
+ /**
+ * Check if metrics are available.
+ *
+ * @return true if metrics can be recorded
+ */
+ public static boolean isAvailable() {
+ return getInstance() != null;
+ }
+
+ /**
+ * Set cache size supplier for gauge.
+ *
+ * @param supplier Supplier returning current cache size
+ */
+ public void setCacheSizeSupplier(final Supplier supplier) {
+ this.cacheSizeSupplier = supplier;
+ }
+
+ // ==================== Counters ====================
+
+ /**
+ * Record blocked version.
+ *
+ * @param repoType Repository type
+ * @param repoName Repository name
+ */
+ public void recordVersionBlocked(final String repoType, final String repoName) {
+ Counter.builder("artipie.cooldown.versions.blocked")
+ .description("Number of versions blocked by cooldown")
+ .tag("repo_type", repoType)
+ .tag("repo_name", repoName)
+ .register(this.registry)
+ .increment();
+ }
+
+ /**
+ * Record allowed version.
+ *
+ * @param repoType Repository type
+ * @param repoName Repository name
+ */
+ public void recordVersionAllowed(final String repoType, final String repoName) {
+ Counter.builder("artipie.cooldown.versions.allowed")
+ .description("Number of versions allowed by cooldown")
+ .tag("repo_type", repoType)
+ .tag("repo_name", repoName)
+ .register(this.registry)
+ .increment();
+ }
+
+ /**
+ * Record cache hit.
+ *
+ * @param tier Cache tier (l1 or l2)
+ */
+ public void recordCacheHit(final String tier) {
+ Counter.builder("artipie.cooldown.cache.hits")
+ .description("Cooldown cache hits")
+ .tag("tier", tier)
+ .register(this.registry)
+ .increment();
+ }
+
+ /**
+ * Record cache miss.
+ */
+ public void recordCacheMiss() {
+ Counter.builder("artipie.cooldown.cache.misses")
+ .description("Cooldown cache misses")
+ .register(this.registry)
+ .increment();
+ }
+
+ /**
+ * Set all-blocked packages count (loaded from database on startup).
+ *
+ * @param count Current count of packages with all versions blocked
+ */
+ public void setAllBlockedPackages(final long count) {
+ this.allBlockedPackages.set(count);
+ }
+
+ /**
+ * Increment all-blocked packages count (called when all versions become blocked).
+ */
+ public void incrementAllBlocked() {
+ this.allBlockedPackages.incrementAndGet();
+ }
+
+ /**
+ * Decrement all-blocked packages count (called when a package is unblocked).
+ */
+ public void decrementAllBlocked() {
+ final long current = this.allBlockedPackages.decrementAndGet();
+ if (current < 0) {
+ this.allBlockedPackages.set(0);
+ }
+ }
+
+ /**
+ * Record cache invalidation.
+ *
+ * @param repoType Repository type
+ * @param reason Invalidation reason (unblock, expire, manual)
+ */
+ public void recordInvalidation(final String repoType, final String reason) {
+ Counter.builder("artipie.cooldown.invalidations")
+ .description("Cooldown cache invalidations")
+ .tag("repo_type", repoType)
+ .tag("reason", reason)
+ .register(this.registry)
+ .increment();
+ }
+
+ // ==================== Gauges ====================
+
+ /**
+ * Update active blocks count for a repository (used on startup to load from DB).
+ *
+ * @param repoType Repository type
+ * @param repoName Repository name
+ * @param count Current active blocks count
+ */
+ public void updateActiveBlocks(final String repoType, final String repoName, final long count) {
+ final String key = repoType + ":" + repoName;
+ this.getOrCreateRepoGauge(repoType, repoName, key).set(count);
+ }
+
+ /**
+ * Increment active blocks for a repository (O(1), no DB query).
+ *
+ * @param repoType Repository type
+ * @param repoName Repository name
+ */
+ public void incrementActiveBlocks(final String repoType, final String repoName) {
+ final String key = repoType + ":" + repoName;
+ this.getOrCreateRepoGauge(repoType, repoName, key).incrementAndGet();
+ }
+
+ /**
+ * Decrement active blocks for a repository (O(1), no DB query).
+ *
+ * @param repoType Repository type
+ * @param repoName Repository name
+ */
+ public void decrementActiveBlocks(final String repoType, final String repoName) {
+ final String key = repoType + ":" + repoName;
+ final AtomicLong gauge = this.activeBlocksGauges.get(key);
+ if (gauge != null) {
+ final long val = gauge.decrementAndGet();
+ if (val < 0) {
+ gauge.set(0);
+ }
+ }
+ }
+
+ /**
+ * Get or create per-repo gauge.
+ */
+ private AtomicLong getOrCreateRepoGauge(final String repoType, final String repoName, final String key) {
+ return this.activeBlocksGauges.computeIfAbsent(key, k -> {
+ final AtomicLong newGauge = new AtomicLong(0);
+ Gauge.builder("artipie.cooldown.active_blocks.repo", newGauge, AtomicLong::doubleValue)
+ .description("Number of active cooldown blocks per repository")
+ .tag("repo_type", repoType)
+ .tag("repo_name", repoName)
+ .register(this.registry);
+ return newGauge;
+ });
+ }
+
+ /**
+ * Compute total active blocks across all repositories.
+ * Called by Micrometer on each scrape - ensures gauge always emits.
+ *
+ * @return Total active blocks count
+ */
+ private double computeTotalActiveBlocks() {
+ return this.activeBlocksGauges.values().stream()
+ .mapToLong(AtomicLong::get)
+ .sum();
+ }
+
+ // ==================== Timers ====================
+
+ /**
+ * Record metadata filtering duration.
+ *
+ * @param repoType Repository type
+ * @param durationMs Duration in milliseconds
+ * @param versionsTotal Total versions in metadata
+ * @param versionsBlocked Number of blocked versions
+ */
+ public void recordFilterDuration(
+ final String repoType,
+ final long durationMs,
+ final int versionsTotal,
+ final int versionsBlocked
+ ) {
+ Timer.builder("artipie.cooldown.metadata.filter.duration")
+ .description("Cooldown metadata filtering duration")
+ .tag("repo_type", repoType)
+ .tag("versions_bucket", versionsBucket(versionsTotal))
+ .register(this.registry)
+ .record(durationMs, TimeUnit.MILLISECONDS);
+ }
+
+ /**
+ * Record per-version evaluation duration.
+ *
+ * @param repoType Repository type
+ * @param durationMs Duration in milliseconds
+ * @param blocked Whether version was blocked
+ */
+ public void recordEvaluateDuration(
+ final String repoType,
+ final long durationMs,
+ final boolean blocked
+ ) {
+ Timer.builder("artipie.cooldown.evaluate.duration")
+ .description("Per-version cooldown evaluation duration")
+ .tag("repo_type", repoType)
+ .tag("result", blocked ? "blocked" : "allowed")
+ .register(this.registry)
+ .record(durationMs, TimeUnit.MILLISECONDS);
+ }
+
+ /**
+ * Record cache load duration.
+ *
+ * @param durationMs Duration in milliseconds
+ */
+ public void recordCacheLoadDuration(final long durationMs) {
+ Timer.builder("artipie.cooldown.cache.load.duration")
+ .description("Cooldown cache load duration on miss")
+ .register(this.registry)
+ .record(durationMs, TimeUnit.MILLISECONDS);
+ }
+
+ /**
+ * Get versions bucket for histogram.
+ */
+ private static String versionsBucket(final int versions) {
+ if (versions <= 10) {
+ return "1-10";
+ } else if (versions <= 50) {
+ return "11-50";
+ } else if (versions <= 200) {
+ return "51-200";
+ } else if (versions <= 500) {
+ return "201-500";
+ } else {
+ return "500+";
+ }
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/cooldown/metrics/package-info.java b/artipie-core/src/main/java/com/artipie/cooldown/metrics/package-info.java
new file mode 100644
index 000000000..6f371242e
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/cooldown/metrics/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+
+/**
+ * Cooldown metrics for observability.
+ *
+ * This package provides Micrometer-based metrics for cooldown functionality:
+ *
+ * {@link com.artipie.cooldown.metrics.CooldownMetrics} - Central metrics facade
+ *
+ *
+ * All metrics use the prefix {@code artipie.cooldown.*} and are designed to be
+ * visualized in Grafana dashboards.
+ *
+ * @since 1.0
+ */
+package com.artipie.cooldown.metrics;
diff --git a/artipie-core/src/main/java/com/artipie/http/ArtipieHttpException.java b/artipie-core/src/main/java/com/artipie/http/ArtipieHttpException.java
index 819ad3db9..94f9b8e51 100644
--- a/artipie-core/src/main/java/com/artipie/http/ArtipieHttpException.java
+++ b/artipie-core/src/main/java/com/artipie/http/ArtipieHttpException.java
@@ -5,17 +5,17 @@
package com.artipie.http;
import com.artipie.ArtipieException;
-import com.artipie.http.rs.RsStatus;
import com.google.common.collect.ImmutableMap;
+
+import java.io.Serial;
import java.util.Map;
/**
* Base HTTP exception for Artipie endpoints.
- * @since 1.0
*/
-@SuppressWarnings("PMD.OnlyOneConstructorShouldDoInitialization")
public final class ArtipieHttpException extends ArtipieException {
+ @Serial
private static final long serialVersionUID = -16695752893817954L;
/**
@@ -94,7 +94,7 @@ public ArtipieHttpException(final RsStatus status, final String message) {
* @param cause Of the error
*/
public ArtipieHttpException(final RsStatus status, final String message,
- final Throwable cause) {
+ final Throwable cause) {
super(message, cause);
this.code = status;
}
@@ -112,7 +112,7 @@ public RsStatus status() {
* @param status HTTP status code for error
* @return Meaning string for this code
*/
- private static String meaning(final RsStatus status) {
- return ArtipieHttpException.MEANINGS.getOrDefault(status.code(), "Unknown");
+ private static String meaning(RsStatus status) {
+ return ArtipieHttpException.MEANINGS.getOrDefault(status.asString(), "Unknown");
}
}
diff --git a/artipie-core/src/main/java/com/artipie/http/Connection.java b/artipie-core/src/main/java/com/artipie/http/Connection.java
deleted file mode 100644
index cd0861954..000000000
--- a/artipie-core/src/main/java/com/artipie/http/Connection.java
+++ /dev/null
@@ -1,44 +0,0 @@
-/*
- * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
- * https://github.com/artipie/artipie/blob/master/LICENSE.txt
- */
-package com.artipie.http;
-
-import com.artipie.http.rs.RsStatus;
-import java.nio.ByteBuffer;
-import java.util.Map;
-import java.util.concurrent.CompletionStage;
-import org.reactivestreams.Publisher;
-
-/**
- * The http connection.
- * @since 0.1
- */
-public interface Connection {
-
- /**
- * Respond on connection.
- * @param status The http status code.
- * @param headers The http response headers.
- * @param body The http response body.
- * @return Completion stage for accepting HTTP response.
- */
- CompletionStage accept(RsStatus status, Headers headers, Publisher body);
-
- /**
- * Respond on connection.
- * @param status The http status code.
- * @param headers The http response headers.
- * @param body The http response body.
- * @return Completion stage for accepting HTTP response.
- * @deprecated Use {@link Connection#accept(RsStatus, Headers, Publisher)}.
- */
- @Deprecated
- default CompletionStage accept(
- RsStatus status,
- Iterable> headers,
- Publisher body
- ) {
- return this.accept(status, new Headers.From(headers), body);
- }
-}
diff --git a/artipie-core/src/main/java/com/artipie/http/Headers.java b/artipie-core/src/main/java/com/artipie/http/Headers.java
index a1472f0ac..afba1bbb2 100644
--- a/artipie-core/src/main/java/com/artipie/http/Headers.java
+++ b/artipie-core/src/main/java/com/artipie/http/Headers.java
@@ -5,187 +5,139 @@
package com.artipie.http;
import com.artipie.http.headers.Header;
-import com.google.common.collect.Iterables;
+
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Iterator;
+import java.util.List;
import java.util.Map;
-import java.util.Spliterator;
-import java.util.function.Consumer;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
/**
* HTTP request headers.
- *
- * @since 0.8
- * @checkstyle InterfaceIsTypeCheck (2 lines)
*/
-public interface Headers extends Iterable> {
-
- /**
- * Empty headers.
- */
- Headers EMPTY = new From(Collections.emptyList());
-
- /**
- * {@link Headers} created from something.
- *
- * @since 0.8
- */
- final class From implements Headers {
-
- /**
- * Origin headers.
- */
- private final Iterable> origin;
-
- /**
- * Ctor.
- *
- * @param name Header name.
- * @param value Header value.
- */
- public From(final String name, final String value) {
- this(new Header(name, value));
- }
+public class Headers implements Iterable {
- /**
- * Ctor.
- *
- * @param origin Origin headers.
- * @param name Additional header name.
- * @param value Additional header value.
- */
- public From(
- final Iterable> origin,
- final String name, final String value
- ) {
- this(origin, new Header(name, value));
- }
+ public static Headers EMPTY = new Headers(Collections.emptyList());
- /**
- * Ctor.
- *
- * @param header Header.
- */
- public From(final Map.Entry header) {
- this(Collections.singleton(header));
- }
+ public static Headers from(String name, String value) {
+ return from(new Header(name, value));
+ }
- /**
- * Ctor.
- *
- * @param origin Origin headers.
- * @param additional Additional headers.
- */
- public From(
- final Iterable> origin,
- final Map.Entry additional
- ) {
- this(origin, Collections.singleton(additional));
- }
+ public static Headers from(Header header) {
+ List list = new ArrayList<>();
+ list.add(header);
+ return new Headers(list);
+ }
- /**
- * Ctor.
- *
- * @param origin Origin headers.
- */
- @SafeVarargs
- public From(final Map.Entry... origin) {
- this(Arrays.asList(origin));
- }
+ public static Headers from(Iterable> multiMap) {
+ return new Headers(
+ StreamSupport.stream(multiMap.spliterator(), false)
+ .map(Header::new)
+ .toList()
+ );
+ }
- /**
- * Ctor.
- *
- * @param origin Origin headers.
- * @param additional Additional headers.
- */
- @SafeVarargs
- public From(
- final Iterable> origin,
- final Map.Entry... additional
- ) {
- this(origin, Arrays.asList(additional));
- }
+ @SafeVarargs
+ public static Headers from(Map.Entry... entries) {
+ return new Headers(Arrays.stream(entries).map(Header::new).toList());
+ }
- /**
- * Ctor.
- *
- * @param origin Origin headers.
- * @param additional Additional headers.
- */
- public From(
- final Iterable> origin,
- final Iterable> additional
- ) {
- this(Iterables.concat(origin, additional));
- }
+ private final List headers;
- /**
- * Ctor.
- *
- * @param origin Origin headers.
- */
- public From(final Iterable> origin) {
- this.origin = origin;
- }
+ public Headers() {
+ this.headers = new ArrayList<>();
+ }
- @Override
- public Iterator> iterator() {
- return this.origin.iterator();
- }
+ public Headers(List headers) {
+ this.headers = headers;
+ }
- @Override
- public void forEach(final Consumer super Map.Entry> action) {
- this.origin.forEach(action);
- }
+ public Headers add(String name, String value) {
+ headers.add(new Header(name, value));
+ return this;
+ }
- @Override
- public Spliterator> spliterator() {
- return this.origin.spliterator();
+ public Headers add(Header header, boolean overwrite) {
+ if (overwrite) {
+ headers.removeIf(h -> h.getKey().equals(header.getKey()));
}
+ headers.add(header);
+ return this;
}
- /**
- * Abstract decorator for {@link Headers}.
- * @since 0.10
- */
- abstract class Wrap implements Headers {
+ public Headers add(Header header) {
+ headers.add(header);
+ return this;
+ }
- /**
- * Origin headers.
- */
- private final Iterable> origin;
+ public Headers add(Map.Entry entry) {
+ return add(entry.getKey(), entry.getValue());
+ }
- /**
- * Ctor.
- * @param origin Origin headers
- */
- protected Wrap(final Iterable> origin) {
- this.origin = origin;
- }
+ public Headers addAll(Headers src) {
+ headers.addAll(src.headers);
+ return this;
+ }
- /**
- * Ctor.
- * @param origin Origin headers
- */
- protected Wrap(final Header... origin) {
- this(Arrays.asList(origin));
- }
+ public Headers copy() {
+ return new Headers(new ArrayList<>(headers));
+ }
- @Override
- public final Iterator> iterator() {
- return this.origin.iterator();
- }
+ public boolean isEmpty() {
+ return headers.isEmpty();
+ }
- @Override
- public final void forEach(final Consumer super Map.Entry> action) {
- this.origin.forEach(action);
- }
+ public List values(String name) {
+ return headers.stream()
+ .filter(h -> h.getKey().equalsIgnoreCase(name))
+ .map(Header::getValue)
+ .toList();
+ }
- @Override
- public final Spliterator> spliterator() {
- return this.origin.spliterator();
+ public List find(String name) {
+ return headers.stream()
+ .filter(h -> h.getKey().equalsIgnoreCase(name))
+ .toList();
+ }
+
+ public Header single(String name) {
+ List res = find(name);
+ if (res.isEmpty()) {
+ throw new IllegalStateException("Header '" + name + "' is not found");
+ }
+ if (res.size() > 1) {
+ throw new IllegalStateException("Too many headers '" + name + "' are found");
}
+ return res.getFirst();
+ }
+
+ @Override
+ public Iterator iterator() {
+ return headers.iterator();
+ }
+
+ public Stream stream() {
+ return headers.stream();
+ }
+
+ public List asList() {
+ return new ArrayList<>(headers);
+ }
+
+ public String asString() {
+ return headers.stream()
+ .map(h -> h.getKey() + '=' + h.getValue())
+ .collect(Collectors.joining(";"));
+ }
+
+ @Override
+ public String toString() {
+ return "Headers{" +
+ "headers=" + headers +
+ '}';
}
}
diff --git a/artipie-core/src/main/java/com/artipie/http/RangeSpec.java b/artipie-core/src/main/java/com/artipie/http/RangeSpec.java
new file mode 100644
index 000000000..7c8cbfe5f
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/http/RangeSpec.java
@@ -0,0 +1,139 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.http;
+
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * HTTP Range header parser and validator.
+ * Supports byte ranges in format: "bytes=start-end"
+ *
+ * @since 1.0
+ */
+public final class RangeSpec {
+
+ /**
+ * Pattern for parsing Range header: "bytes=start-end"
+ */
+ private static final Pattern RANGE_PATTERN = Pattern.compile("bytes=(\\d+)-(\\d*)");
+
+ /**
+ * Start byte (inclusive).
+ */
+ private final long start;
+
+ /**
+ * End byte (inclusive), -1 means to end of file.
+ */
+ private final long end;
+
+ /**
+ * Constructor.
+ * @param start Start byte (inclusive)
+ * @param end End byte (inclusive), -1 for end of file
+ */
+ public RangeSpec(final long start, final long end) {
+ this.start = start;
+ this.end = end;
+ }
+
+ /**
+ * Parse Range header.
+ * @param header Range header value (e.g., "bytes=0-1023")
+ * @return RangeSpec if valid, empty otherwise
+ */
+ public static Optional parse(final String header) {
+ if (header == null || header.isEmpty()) {
+ return Optional.empty();
+ }
+
+ final Matcher matcher = RANGE_PATTERN.matcher(header.trim());
+ if (!matcher.matches()) {
+ return Optional.empty();
+ }
+
+ try {
+ final long start = Long.parseLong(matcher.group(1));
+ final long end;
+
+ final String endStr = matcher.group(2);
+ if (endStr == null || endStr.isEmpty()) {
+ end = -1; // To end of file
+ } else {
+ end = Long.parseLong(endStr);
+ }
+
+ if (start < 0 || (end != -1 && end < start)) {
+ return Optional.empty();
+ }
+
+ return Optional.of(new RangeSpec(start, end));
+ } catch (NumberFormatException e) {
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * Check if range is valid for given file size.
+ * @param fileSize Total file size in bytes
+ * @return True if valid
+ */
+ public boolean isValid(final long fileSize) {
+ if (this.start >= fileSize) {
+ return false;
+ }
+ if (this.end != -1 && this.end >= fileSize) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Get start byte position.
+ * @return Start byte (inclusive)
+ */
+ public long start() {
+ return this.start;
+ }
+
+ /**
+ * Get end byte position for given file size.
+ * @param fileSize Total file size
+ * @return End byte (inclusive)
+ */
+ public long end(final long fileSize) {
+ return this.end == -1 ? fileSize - 1 : this.end;
+ }
+
+ /**
+ * Get length of range for given file size.
+ * @param fileSize Total file size
+ * @return Number of bytes in range
+ */
+ public long length(final long fileSize) {
+ return end(fileSize) - this.start + 1;
+ }
+
+ /**
+ * Format as Content-Range header value.
+ * @param fileSize Total file size
+ * @return Content-Range header value (e.g., "bytes 0-1023/2048")
+ */
+ public String toContentRange(final long fileSize) {
+ return String.format(
+ "bytes %d-%d/%d",
+ this.start,
+ end(fileSize),
+ fileSize
+ );
+ }
+
+ @Override
+ public String toString() {
+ return String.format("bytes=%d-%s", this.start, this.end == -1 ? "" : this.end);
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/http/Response.java b/artipie-core/src/main/java/com/artipie/http/Response.java
index 1a6768975..2844cb1a9 100644
--- a/artipie-core/src/main/java/com/artipie/http/Response.java
+++ b/artipie-core/src/main/java/com/artipie/http/Response.java
@@ -4,55 +4,16 @@
*/
package com.artipie.http;
-import com.artipie.http.rs.StandardRs;
-import java.util.concurrent.CompletionStage;
+import com.artipie.asto.Content;
-/**
- * HTTP response.
- * @see RFC2616
- * @since 0.1
- */
-public interface Response {
-
- /**
- * Empty response.
- * @deprecated Use {@link StandardRs#EMPTY}.
- */
- @Deprecated
- Response EMPTY = StandardRs.EMPTY;
-
- /**
- * Send the response.
- *
- * @param connection Connection to send the response to
- * @return Completion stage for sending response to the connection.
- */
- CompletionStage send(Connection connection);
-
- /**
- * Abstract decorator for Response.
- *
- * @since 0.9
- */
- abstract class Wrap implements Response {
-
- /**
- * Origin response.
- */
- private final Response response;
-
- /**
- * Ctor.
- *
- * @param response Response.
- */
- protected Wrap(final Response response) {
- this.response = response;
- }
+public record Response(RsStatus status, Headers headers, Content body) {
- @Override
- public final CompletionStage send(final Connection connection) {
- return this.response.send(connection);
- }
+ @Override
+ public String toString() {
+ return "Response{" +
+ "status=" + status +
+ ", headers=" + headers +
+ ", hasBody=" + body.size().map(s -> s > 0).orElse(false) +
+ '}';
}
}
diff --git a/artipie-core/src/main/java/com/artipie/http/ResponseBuilder.java b/artipie-core/src/main/java/com/artipie/http/ResponseBuilder.java
new file mode 100644
index 000000000..1aad820ad
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/http/ResponseBuilder.java
@@ -0,0 +1,290 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.http;
+
+import com.artipie.asto.Content;
+import com.artipie.http.headers.ContentLength;
+import com.artipie.http.headers.ContentType;
+import com.artipie.http.headers.Header;
+import org.reactivestreams.Publisher;
+
+import javax.json.JsonStructure;
+import java.nio.ByteBuffer;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.CompletableFuture;
+
+public class ResponseBuilder {
+
+ public static ResponseBuilder from(RsStatus status) {
+ return new ResponseBuilder(status);
+ }
+
+ public static ResponseBuilder ok() {
+ return new ResponseBuilder(RsStatus.OK);
+ }
+
+ public static ResponseBuilder created() {
+ return new ResponseBuilder(RsStatus.CREATED);
+ }
+
+ public static ResponseBuilder accepted() {
+ return new ResponseBuilder(RsStatus.ACCEPTED);
+ }
+
+ public static ResponseBuilder temporaryRedirect() {
+ return new ResponseBuilder(RsStatus.TEMPORARY_REDIRECT);
+ }
+
+ public static ResponseBuilder proxyAuthenticationRequired() {
+ return new ResponseBuilder(RsStatus.PROXY_AUTHENTICATION_REQUIRED);
+ }
+
+ public static ResponseBuilder movedPermanently() {
+ return new ResponseBuilder(RsStatus.MOVED_PERMANENTLY);
+ }
+
+ public static ResponseBuilder notFound() {
+ return new ResponseBuilder(RsStatus.NOT_FOUND);
+ }
+
+ public static ResponseBuilder noContent() {
+ return new ResponseBuilder(RsStatus.NO_CONTENT);
+ }
+
+ public static ResponseBuilder unavailable() {
+ return new ResponseBuilder(RsStatus.SERVICE_UNAVAILABLE);
+ }
+
+ public static ResponseBuilder unauthorized() {
+ return new ResponseBuilder(RsStatus.UNAUTHORIZED);
+ }
+
+ public static ResponseBuilder forbidden() {
+ return new ResponseBuilder(RsStatus.FORBIDDEN);
+ }
+
+ public static ResponseBuilder methodNotAllowed() {
+ return new ResponseBuilder(RsStatus.METHOD_NOT_ALLOWED);
+ }
+
+ public static ResponseBuilder badRequest() {
+ return new ResponseBuilder(RsStatus.BAD_REQUEST);
+ }
+
+ public static ResponseBuilder badRequest(Throwable error) {
+ return new ResponseBuilder(RsStatus.BAD_REQUEST)
+ .body(errorBody(error));
+ }
+
+ public static ResponseBuilder payloadTooLarge() {
+ return new ResponseBuilder(RsStatus.REQUEST_TOO_LONG);
+ }
+
+ public static ResponseBuilder internalError() {
+ return new ResponseBuilder(RsStatus.INTERNAL_ERROR);
+ }
+
+ public static ResponseBuilder internalError(Throwable error) {
+ return new ResponseBuilder(RsStatus.INTERNAL_ERROR)
+ .body(errorBody(error));
+ }
+
+ public static ResponseBuilder partialContent() {
+ return new ResponseBuilder(RsStatus.PARTIAL_CONTENT);
+ }
+
+ public static ResponseBuilder rangeNotSatisfiable() {
+ return new ResponseBuilder(RsStatus.REQUESTED_RANGE_NOT_SATISFIABLE);
+ }
+
+ public static ResponseBuilder gatewayTimeout() {
+ return new ResponseBuilder(RsStatus.GATEWAY_TIMEOUT);
+ }
+
+ public static ResponseBuilder serviceUnavailable(String message) {
+ return new ResponseBuilder(RsStatus.SERVICE_UNAVAILABLE)
+ .textBody(message);
+ }
+
+ private static byte[] errorBody(Throwable error) {
+ StringBuilder res = new StringBuilder();
+ res.append(error.getMessage()).append('\n');
+ Throwable cause = error.getCause();
+ if (cause != null) {
+ res.append(cause.getMessage()).append('\n');
+ if (cause.getSuppressed() != null) {
+ for (final Throwable suppressed : cause.getSuppressed()) {
+ res.append(suppressed.getMessage()).append('\n');
+ }
+ }
+ }
+ return res.toString().getBytes();
+ }
+
+ private final RsStatus status;
+ private Headers headers;
+ private Content body;
+
+ ResponseBuilder(RsStatus status) {
+ this.status = status;
+ this.headers = new Headers();
+ this.body = Content.EMPTY;
+ }
+
+ public ResponseBuilder headers(Headers headers) {
+ this.headers = headers.copy();
+ return this;
+ }
+
+ public ResponseBuilder header(String name, String val) {
+ this.headers.add(name, val);
+ return this;
+ }
+
+ public ResponseBuilder header(Header header) {
+ this.headers.add(header, false);
+ return this;
+ }
+
+ public ResponseBuilder header(Header header, boolean overwrite) {
+ this.headers.add(header, overwrite);
+ return this;
+ }
+
+ public ResponseBuilder body(Publisher body) {
+ return body(new Content.From(body));
+ }
+
+ public ResponseBuilder body(Content body) {
+ this.body = body;
+ this.body.size().ifPresent(val -> header(new ContentLength(val), true));
+ return this;
+ }
+
+ public ResponseBuilder body(byte[] body) {
+ return this.body(new Content.From(body));
+ }
+
+ public ResponseBuilder textBody(String text) {
+ return textBody(text, StandardCharsets.UTF_8);
+ }
+
+ public ResponseBuilder textBody(String text, Charset charset) {
+ this.body = new Content.From(text.getBytes(charset));
+ this.headers.add(ContentType.text(charset))
+ .add(new ContentLength(body.size().orElseThrow()));
+ return this;
+ }
+
+ public ResponseBuilder jsonBody(JsonStructure json) {
+ return jsonBody(json, StandardCharsets.UTF_8);
+ }
+
+ public ResponseBuilder jsonBody(String json) {
+ return jsonBody(json, StandardCharsets.UTF_8);
+ }
+
+ public ResponseBuilder jsonBody(JsonStructure json, Charset charset) {
+ return jsonBody(json.toString(), charset);
+ }
+
+ public ResponseBuilder jsonBody(String json, Charset charset) {
+ this.body = new Content.From(json.getBytes(charset));
+ headers.add(ContentType.json(charset))
+ .add(new ContentLength(body.size().orElseThrow())
+ );
+ return this;
+ }
+
+ public ResponseBuilder yamlBody(String yaml) {
+ return yamlBody(yaml, StandardCharsets.UTF_8);
+ }
+
+ public ResponseBuilder yamlBody(String yaml, Charset charset) {
+ this.body = new Content.From(yaml.getBytes(charset));
+ this.headers.add(ContentType.yaml(charset))
+ .add(new ContentLength(body.size().orElseThrow()));
+ return this;
+ }
+
+ public ResponseBuilder htmlBody(String html, Charset charset) {
+ this.body = new Content.From(html.getBytes(charset));
+ this.headers.add(ContentType.html(charset))
+ .add(new ContentLength(body.size().orElseThrow()));
+ return this;
+ }
+
+ public Response build() {
+ if (headers.isEmpty() && body == Content.EMPTY) {
+ return switch (status) {
+ case CONTINUE -> RSP_CONTINUE;
+ case OK -> RSP_OK;
+ case CREATED -> RSP_CREATED;
+ case ACCEPTED -> RSP_ACCEPTED;
+ case NO_CONTENT -> RSP_NO_CONTENT;
+ case MOVED_PERMANENTLY -> RSP_MOVED_PERMANENTLY;
+ case MOVED_TEMPORARILY -> RSP_MOVED_TEMPORARILY;
+ case NOT_MODIFIED -> RSP_NOT_MODIFIED;
+ case TEMPORARY_REDIRECT -> RSP_TEMPORARY_REDIRECT;
+ case PROXY_AUTHENTICATION_REQUIRED -> RSP_PROXY_AUTH_REQUIRED;
+ case BAD_REQUEST -> RSP_BAD_REQUEST;
+ case UNAUTHORIZED -> RSP_UNAUTHORIZED;
+ case FORBIDDEN -> RSP_FORBIDDEN;
+ case NOT_FOUND -> RSP_NOT_FOUND;
+ case METHOD_NOT_ALLOWED -> RSP_METHOD_NOT_ALLOWED;
+ case REQUEST_TIMEOUT -> RSP_REQUEST_TIMEOUT;
+ case CONFLICT -> RSP_CONFLICT;
+ case LENGTH_REQUIRED -> RSP_LENGTH_REQUIRED;
+ case PRECONDITION_FAILED -> RSP_PRECONDITION_FAILED;
+ case REQUEST_TOO_LONG -> RSP_REQUEST_TOO_LONG;
+ case REQUESTED_RANGE_NOT_SATISFIABLE -> RSP_REQUESTED_RANGE_NOT_SATISFIABLE;
+ case EXPECTATION_FAILED -> RSP_EXPECTATION_FAILED;
+ case TOO_MANY_REQUESTS -> RSP_TOO_MANY_REQUESTS;
+ case INTERNAL_ERROR -> RSP_INTERNAL_ERROR;
+ case NOT_IMPLEMENTED -> RSP_NOT_IMPLEMENTED;
+ case SERVICE_UNAVAILABLE -> RSP_SERVICE_UNAVAILABLE;
+ case PARTIAL_CONTENT -> RSP_PARTIAL_CONTENT;
+ case GATEWAY_TIMEOUT -> RSP_GATEWAY_TIMEOUT;
+ };
+ }
+ return new Response(status, new UnmodifiableHeaders(headers.asList()), body);
+ }
+
+ public CompletableFuture completedFuture() {
+ return CompletableFuture.completedFuture(build());
+ }
+
+ private final static Response RSP_OK = new Response(RsStatus.OK, Headers.EMPTY, Content.EMPTY);
+ private final static Response RSP_NOT_FOUND = new Response(RsStatus.NOT_FOUND, Headers.EMPTY, Content.EMPTY);
+ private final static Response RSP_PROXY_AUTH_REQUIRED =
+ new Response(RsStatus.PROXY_AUTHENTICATION_REQUIRED, Headers.EMPTY, Content.EMPTY);
+ private final static Response RSP_CONTINUE = new Response(RsStatus.CONTINUE, Headers.EMPTY, Content.EMPTY);
+ private final static Response RSP_CREATED = new Response(RsStatus.CREATED, Headers.EMPTY, Content.EMPTY);
+ private final static Response RSP_ACCEPTED = new Response(RsStatus.ACCEPTED, Headers.EMPTY, Content.EMPTY);
+ private final static Response RSP_NO_CONTENT = new Response(RsStatus.NO_CONTENT, Headers.EMPTY, Content.EMPTY);
+ private final static Response RSP_MOVED_PERMANENTLY = new Response(RsStatus.MOVED_PERMANENTLY, Headers.EMPTY, Content.EMPTY);
+ private final static Response RSP_MOVED_TEMPORARILY = new Response(RsStatus.MOVED_TEMPORARILY, Headers.EMPTY, Content.EMPTY);
+ private final static Response RSP_NOT_MODIFIED = new Response(RsStatus.NOT_MODIFIED, Headers.EMPTY, Content.EMPTY);
+ private final static Response RSP_TEMPORARY_REDIRECT = new Response(RsStatus.TEMPORARY_REDIRECT, Headers.EMPTY, Content.EMPTY);
+ private final static Response RSP_BAD_REQUEST = new Response(RsStatus.BAD_REQUEST, Headers.EMPTY, Content.EMPTY);
+ private final static Response RSP_UNAUTHORIZED = new Response(RsStatus.UNAUTHORIZED, Headers.EMPTY, Content.EMPTY);
+ private final static Response RSP_FORBIDDEN = new Response(RsStatus.FORBIDDEN, Headers.EMPTY, Content.EMPTY);
+ private final static Response RSP_METHOD_NOT_ALLOWED = new Response(RsStatus.METHOD_NOT_ALLOWED, Headers.EMPTY, Content.EMPTY);
+ private final static Response RSP_REQUEST_TIMEOUT = new Response(RsStatus.REQUEST_TIMEOUT, Headers.EMPTY, Content.EMPTY);
+ private final static Response RSP_CONFLICT = new Response(RsStatus.CONFLICT, Headers.EMPTY, Content.EMPTY);
+ private final static Response RSP_LENGTH_REQUIRED = new Response(RsStatus.LENGTH_REQUIRED, Headers.EMPTY, Content.EMPTY);
+ private final static Response RSP_PRECONDITION_FAILED =
+ new Response(RsStatus.PRECONDITION_FAILED, Headers.EMPTY, Content.EMPTY);
+ private final static Response RSP_REQUEST_TOO_LONG = new Response(RsStatus.REQUEST_TOO_LONG, Headers.EMPTY, Content.EMPTY);
+ private final static Response RSP_REQUESTED_RANGE_NOT_SATISFIABLE = new Response(RsStatus.REQUESTED_RANGE_NOT_SATISFIABLE, Headers.EMPTY, Content.EMPTY);
+ private final static Response RSP_EXPECTATION_FAILED = new Response(RsStatus.EXPECTATION_FAILED, Headers.EMPTY, Content.EMPTY);
+ private final static Response RSP_TOO_MANY_REQUESTS = new Response(RsStatus.TOO_MANY_REQUESTS, Headers.EMPTY, Content.EMPTY);
+ private final static Response RSP_INTERNAL_ERROR = new Response(RsStatus.INTERNAL_ERROR, Headers.EMPTY, Content.EMPTY);
+ private final static Response RSP_NOT_IMPLEMENTED = new Response(RsStatus.NOT_IMPLEMENTED, Headers.EMPTY, Content.EMPTY);
+ private final static Response RSP_SERVICE_UNAVAILABLE = new Response(RsStatus.SERVICE_UNAVAILABLE, Headers.EMPTY, Content.EMPTY);
+ private final static Response RSP_PARTIAL_CONTENT = new Response(RsStatus.PARTIAL_CONTENT, Headers.EMPTY, Content.EMPTY);
+ private final static Response RSP_GATEWAY_TIMEOUT = new Response(RsStatus.GATEWAY_TIMEOUT, Headers.EMPTY, Content.EMPTY);
+}
diff --git a/artipie-core/src/main/java/com/artipie/http/ResponseException.java b/artipie-core/src/main/java/com/artipie/http/ResponseException.java
new file mode 100644
index 000000000..d84260970
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/http/ResponseException.java
@@ -0,0 +1,34 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.http;
+
+/**
+ * Runtime exception carrying pre-built HTTP response.
+ *
+ * @since 1.0
+ */
+public final class ResponseException extends RuntimeException {
+
+ private final Response response;
+
+ /**
+ * Ctor.
+ *
+ * @param response Response to return
+ */
+ public ResponseException(final Response response) {
+ super(response.toString());
+ this.response = response;
+ }
+
+ /**
+ * Exception response.
+ *
+ * @return Response
+ */
+ public Response response() {
+ return this.response;
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/http/ResponseUtils.java b/artipie-core/src/main/java/com/artipie/http/ResponseUtils.java
new file mode 100644
index 000000000..4a10aeefb
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/http/ResponseUtils.java
@@ -0,0 +1,124 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.http;
+
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Utility methods for Response handling to prevent memory leaks.
+ *
+ * These utilities ensure response bodies are properly consumed
+ * even when the response is discarded (e.g., 404s, race conditions).
+ * This prevents Vert.x HTTP server request leaks where the active
+ * request counter never decrements.
+ *
+ * @since 1.0
+ */
+public final class ResponseUtils {
+
+ /**
+ * Private constructor to prevent instantiation.
+ */
+ private ResponseUtils() {
+ }
+
+ /**
+ * Consume response body and discard it.
+ * Used when response is not needed (e.g., lost parallel race, 404 in multi-repo lookup).
+ *
+ * CRITICAL: Always consume response bodies before discarding
+ * to prevent Vert.x request leaks. Vert.x keeps requests "active" until
+ * the body Publisher is fully consumed or canceled.
+ *
+ * @param response Response to consume and discard
+ * @return CompletableFuture that completes when body is consumed
+ */
+ public static CompletableFuture consumeAndDiscard(final Response response) {
+ return response.body().asBytesFuture().thenApply(ignored -> null);
+ }
+
+ /**
+ * Consume response body and return a 404 response.
+ * Used in error handling where we need to consume the error response
+ * body before returning 404 to the client.
+ *
+ * @param response Response to consume
+ * @return CompletableFuture that resolves to 404
+ */
+ public static CompletableFuture consumeAndReturn404(final Response response) {
+ return response.body().asBytesFuture().thenApply(ignored ->
+ ResponseBuilder.notFound().build()
+ );
+ }
+
+ /**
+ * Consume response body and return a custom response.
+ * Used when we need to consume the body but return a different response.
+ *
+ * @param response Response whose body should be consumed
+ * @param replacement Response to return after consumption
+ * @return CompletableFuture that resolves to replacement
+ */
+ public static CompletableFuture consumeAndReturn(
+ final Response response,
+ final Response replacement
+ ) {
+ return response.body().asBytesFuture().thenApply(ignored -> replacement);
+ }
+
+ /**
+ * Consume response body and throw an exception.
+ * Used when converting error responses to exceptions (e.g., upstream failures).
+ *
+ * CRITICAL: Must consume body BEFORE throwing exception
+ * to prevent Vert.x request leaks on error paths.
+ *
+ * @param response Response to consume
+ * @param exception Exception to throw after consumption
+ * @param Return type (will never actually return, always throws)
+ * @return CompletableFuture that fails with exception after consuming body
+ */
+ public static CompletableFuture consumeAndFail(
+ final Response response,
+ final Throwable exception
+ ) {
+ return response.body().asBytesFuture().thenCompose(ignored ->
+ CompletableFuture.failedFuture(exception)
+ );
+ }
+
+ /**
+ * Check if response indicates success and consume if not.
+ * Common pattern for proxy implementations that only care about successful responses.
+ *
+ * @param response Response to check
+ * @return CompletableFuture - true if success (body NOT consumed),
+ * false if not success (body consumed)
+ */
+ public static CompletableFuture isSuccessOrConsume(final Response response) {
+ if (response.status().success()) {
+ return CompletableFuture.completedFuture(true);
+ }
+ return response.body().asBytesFuture().thenApply(ignored -> false);
+ }
+
+ /**
+ * Consume body only if condition is true, otherwise pass through.
+ * Used in conditional logic where response might be used or discarded.
+ *
+ * @param response Response to potentially consume
+ * @param shouldConsume Whether to consume the body
+ * @return CompletableFuture - same response if not consumed, null if consumed
+ */
+ public static CompletableFuture consumeIf(
+ final Response response,
+ final boolean shouldConsume
+ ) {
+ if (shouldConsume) {
+ return response.body().asBytesFuture().thenApply(ignored -> null);
+ }
+ return CompletableFuture.completedFuture(response);
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/http/rs/RsStatus.java b/artipie-core/src/main/java/com/artipie/http/RsStatus.java
similarity index 59%
rename from artipie-core/src/main/java/com/artipie/http/rs/RsStatus.java
rename to artipie-core/src/main/java/com/artipie/http/RsStatus.java
index bbc060e03..edfff24e5 100644
--- a/artipie-core/src/main/java/com/artipie/http/rs/RsStatus.java
+++ b/artipie-core/src/main/java/com/artipie/http/RsStatus.java
@@ -2,134 +2,147 @@
* The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
* https://github.com/artipie/artipie/blob/master/LICENSE.txt
*/
-package com.artipie.http.rs;
+package com.artipie.http;
+
+
+import org.apache.hc.core5.http.HttpStatus;
import java.util.stream.Stream;
-import javax.annotation.Nullable;
/**
* HTTP response status code.
* See RFC 2616 6.1.1 Status Code and Reason Phrase
- *
- * @since 0.4
*/
public enum RsStatus {
/**
* Status Continue .
*/
- CONTINUE("100"),
+ CONTINUE(HttpStatus.SC_CONTINUE),
/**
* OK.
*/
- OK("200"),
+ OK(HttpStatus.SC_OK),
/**
* Created.
*/
- CREATED("201"),
+ CREATED(HttpStatus.SC_CREATED),
/**
* Accepted.
*/
- ACCEPTED("202"),
+ ACCEPTED(HttpStatus.SC_ACCEPTED),
/**
* No Content.
*/
- NO_CONTENT("204"),
+ NO_CONTENT(HttpStatus.SC_NO_CONTENT),
+ /**
+ * Partial Content (206) - Range request.
+ */
+ PARTIAL_CONTENT(HttpStatus.SC_PARTIAL_CONTENT),
/**
* Moved Permanently.
*/
- MOVED_PERMANENTLY("301"),
+ MOVED_PERMANENTLY(HttpStatus.SC_MOVED_PERMANENTLY),
/**
* Found.
*/
- FOUND("302"),
+ MOVED_TEMPORARILY(HttpStatus.SC_MOVED_TEMPORARILY),
/**
* Not Modified.
*/
- NOT_MODIFIED("304"),
+ NOT_MODIFIED(HttpStatus.SC_NOT_MODIFIED),
/**
* Temporary Redirect.
*/
- @SuppressWarnings("PMD.LongVariable")
- TEMPORARY_REDIRECT("307"),
+ TEMPORARY_REDIRECT(HttpStatus.SC_TEMPORARY_REDIRECT),
+ /**
+ * Proxy Authentication Required.
+ */
+ PROXY_AUTHENTICATION_REQUIRED(HttpStatus.SC_PROXY_AUTHENTICATION_REQUIRED),
/**
* Bad Request.
*/
- BAD_REQUEST("400"),
+ BAD_REQUEST(HttpStatus.SC_BAD_REQUEST),
/**
* Unauthorized.
*/
- UNAUTHORIZED("401"),
+ UNAUTHORIZED(HttpStatus.SC_UNAUTHORIZED),
/**
* Forbidden.
*/
- FORBIDDEN("403"),
+ FORBIDDEN(HttpStatus.SC_FORBIDDEN),
/**
* Not Found.
*/
- NOT_FOUND("404"),
+ NOT_FOUND(HttpStatus.SC_NOT_FOUND),
/**
* Method Not Allowed.
*/
@SuppressWarnings("PMD.LongVariable")
- METHOD_NOT_ALLOWED("405"),
+ METHOD_NOT_ALLOWED(HttpStatus.SC_METHOD_NOT_ALLOWED),
/**
* Request Time-out.
*/
- REQUEST_TIMEOUT("408"),
+ REQUEST_TIMEOUT(HttpStatus.SC_REQUEST_TIMEOUT),
/**
* Conflict.
*/
- CONFLICT("409"),
+ CONFLICT(HttpStatus.SC_CONFLICT),
/**
* Length Required.
*/
- LENGTH_REQUIRED("411"),
+ LENGTH_REQUIRED(HttpStatus.SC_LENGTH_REQUIRED),
+ /**
+ * Precondition Failed.
+ */
+ PRECONDITION_FAILED(HttpStatus.SC_PRECONDITION_FAILED),
/**
* Payload Too Large.
*/
- PAYLOAD_TOO_LARGE("413"),
+ REQUEST_TOO_LONG(HttpStatus.SC_REQUEST_TOO_LONG),
/**
* Requested Range Not Satisfiable.
*/
- BAD_RANGE("416"),
+ REQUESTED_RANGE_NOT_SATISFIABLE(HttpStatus.SC_REQUESTED_RANGE_NOT_SATISFIABLE),
/**
* Status
* Expectation Failed .
*/
- EXPECTATION_FAILED("417"),
- /**
- * Misdirected Request.
- */
- MISDIRECTED_REQUEST("421"),
+ EXPECTATION_FAILED(HttpStatus.SC_EXPECTATION_FAILED),
/**
* Too Many Requests.
*/
- TOO_MANY_REQUESTS("429"),
+ TOO_MANY_REQUESTS(HttpStatus.SC_TOO_MANY_REQUESTS),
/**
* Internal Server Error.
*/
- INTERNAL_ERROR("500"),
+ INTERNAL_ERROR(HttpStatus.SC_INTERNAL_SERVER_ERROR),
/**
* Not Implemented.
*/
- NOT_IMPLEMENTED("501"),
+ NOT_IMPLEMENTED(HttpStatus.SC_NOT_IMPLEMENTED),
/**
* Service Unavailable.
*/
- UNAVAILABLE("503");
+ SERVICE_UNAVAILABLE(HttpStatus.SC_SERVICE_UNAVAILABLE),
+ /**
+ * Gateway Timeout (504).
+ */
+ GATEWAY_TIMEOUT(HttpStatus.SC_GATEWAY_TIMEOUT);
/**
* Code value.
*/
- private final String string;
+ private final int code;
/**
- * Ctor.
- *
- * @param string Code value.
+ * @param code Code value.
*/
- RsStatus(final String string) {
- this.string = string;
+ RsStatus(int code) {
+ this.code = code;
+ }
+
+ public int code(){
+ return code;
}
/**
@@ -137,14 +150,13 @@ public enum RsStatus {
*
* @return Code as 3-digit string.
*/
- public String code() {
- return this.string;
+ public String asString() {
+ return String.valueOf(this.code);
}
/**
* Checks whether the RsStatus is an informational group (1xx).
* @return True if the RsStatus is 1xx, otherwise - false.
- * @since 0.16
*/
public boolean information() {
return this.firstSymbol('1');
@@ -153,7 +165,6 @@ public boolean information() {
/**
* Checks whether the RsStatus is a successful group (2xx).
* @return True if the RsStatus is 2xx, otherwise - false.
- * @since 0.16
*/
public boolean success() {
return this.firstSymbol('2');
@@ -162,7 +173,6 @@ public boolean success() {
/**
* Checks whether the RsStatus is a redirection.
* @return True if the RsStatus is 3xx, otherwise - false.
- * @since 0.16
*/
public boolean redirection() {
return this.firstSymbol('3');
@@ -171,7 +181,6 @@ public boolean redirection() {
/**
* Checks whether the RsStatus is a client error.
* @return True if the RsStatus is 4xx, otherwise - false.
- * @since 0.16
*/
public boolean clientError() {
return this.firstSymbol('4');
@@ -180,7 +189,6 @@ public boolean clientError() {
/**
* Checks whether the RsStatus is a server error.
* @return True if the RsStatus is 5xx, otherwise - false.
- * @since 0.16
*/
public boolean serverError() {
return this.firstSymbol('5');
@@ -189,7 +197,6 @@ public boolean serverError() {
/**
* Checks whether the RsStatus is an error.
* @return True if the RsStatus is an error, otherwise - false.
- * @since 0.16
*/
public boolean error() {
return this.clientError() || this.serverError();
@@ -199,53 +206,14 @@ public boolean error() {
* Checks whether the first character matches the symbol.
* @param symbol Symbol to check
* @return True if the first character matches the symbol, otherwise - false.
- * @since 0.16
*/
private boolean firstSymbol(final char symbol) {
- return this.string.charAt(0) == symbol;
+ return asString().charAt(0) == symbol;
}
- /**
- * Searches {@link RsStatus} instance by response code.
- * @since 0.11
- */
- public static class ByCode {
-
- /**
- * Status code.
- */
- private final String code;
-
- /**
- * Ctor.
- * @param code Code
- */
- public ByCode(@Nullable final String code) {
- this.code = code;
- }
-
- /**
- * Ctor.
- * @param code Code
- */
- public ByCode(final int code) {
- this(String.valueOf(code));
- }
-
- /**
- * Searches RsStatus by code.
- * @return RsStatus instance if found
- * @throws IllegalArgumentException If RsStatus is not found
- */
- public RsStatus find() {
- return Stream.of(RsStatus.values())
- .filter(status -> status.code().equals(this.code))
- .findAny()
- .orElseThrow(
- () -> new IllegalArgumentException(
- String.format("Unknown status code: `%s`", this.code)
- )
- );
- }
+ public static RsStatus byCode(int code) {
+ return Stream.of(RsStatus.values())
+ .filter(s -> s.code == code).findFirst()
+ .orElseThrow(() -> new IllegalArgumentException("Unsupported status code: " + code));
}
}
diff --git a/artipie-core/src/main/java/com/artipie/http/Slice.java b/artipie-core/src/main/java/com/artipie/http/Slice.java
index 61bb25fa0..99cb01d0d 100644
--- a/artipie-core/src/main/java/com/artipie/http/Slice.java
+++ b/artipie-core/src/main/java/com/artipie/http/Slice.java
@@ -4,9 +4,10 @@
*/
package com.artipie.http;
-import java.nio.ByteBuffer;
-import java.util.Map;
-import org.reactivestreams.Publisher;
+import com.artipie.asto.Content;
+import com.artipie.http.rq.RequestLine;
+
+import java.util.concurrent.CompletableFuture;
/**
* Arti-pie slice.
@@ -15,28 +16,21 @@
* Each Artipie adapter implements this interface to expose
* repository HTTP API.
* Artipie main module joins all slices together into solid web server.
- *
- * @since 0.1
*/
public interface Slice {
/**
* Respond to a http request.
- * @param line The request line
+ *
+ * @param line The request line
* @param headers The request headers
- * @param body The request body
+ * @param body The request body
* @return The response.
*/
- Response response(
- String line,
- Iterable> headers,
- Publisher body
- );
+ CompletableFuture response(RequestLine line, Headers headers, Content body);
/**
* SliceWrap is a simple decorative envelope for Slice.
- *
- * @since 0.7
*/
abstract class Wrap implements Slice {
@@ -46,8 +40,6 @@ abstract class Wrap implements Slice {
private final Slice slice;
/**
- * Ctor.
- *
* @param slice Slice.
*/
protected Wrap(final Slice slice) {
@@ -55,10 +47,7 @@ protected Wrap(final Slice slice) {
}
@Override
- public final Response response(
- final String line,
- final Iterable> headers,
- final Publisher body) {
+ public final CompletableFuture response(RequestLine line, Headers headers, Content body) {
return this.slice.response(line, headers, body);
}
}
diff --git a/artipie-core/src/main/java/com/artipie/http/UnmodifiableHeaders.java b/artipie-core/src/main/java/com/artipie/http/UnmodifiableHeaders.java
new file mode 100644
index 000000000..baff6eb64
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/http/UnmodifiableHeaders.java
@@ -0,0 +1,46 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.http;
+
+import com.artipie.http.headers.Header;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Unmodifiable list of HTTP request headers.
+ */
+public class UnmodifiableHeaders extends Headers {
+
+ UnmodifiableHeaders(List headers) {
+ super(Collections.unmodifiableList(headers));
+ }
+
+ @Override
+ public Headers add(String name, String value) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Headers add(Header header, boolean overwrite) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Headers add(Header header) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Headers add(Map.Entry entry) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Headers addAll(Headers src) {
+ throw new UnsupportedOperationException();
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/http/async/AsyncResponse.java b/artipie-core/src/main/java/com/artipie/http/async/AsyncResponse.java
deleted file mode 100644
index 02f24a0ca..000000000
--- a/artipie-core/src/main/java/com/artipie/http/async/AsyncResponse.java
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
- * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
- * https://github.com/artipie/artipie/blob/master/LICENSE.txt
- */
-package com.artipie.http.async;
-
-import com.artipie.http.Connection;
-import com.artipie.http.Response;
-import hu.akarnokd.rxjava2.interop.SingleInterop;
-import io.reactivex.Single;
-import java.util.concurrent.CompletionStage;
-
-/**
- * Async response from {@link CompletionStage}.
- * @since 0.6
- */
-public final class AsyncResponse implements Response {
-
- /**
- * Source stage.
- */
- private final CompletionStage extends Response> future;
-
- /**
- * Response from {@link Single}.
- * @param single Single
- */
- public AsyncResponse(final Single extends Response> single) {
- this(single.to(SingleInterop.get()));
- }
-
- /**
- * Response from {@link CompletionStage}.
- * @param future Stage
- */
- public AsyncResponse(final CompletionStage extends Response> future) {
- this.future = future;
- }
-
- @Override
- public CompletionStage send(final Connection connection) {
- return this.future.thenCompose(rsp -> rsp.send(connection));
- }
-
- @Override
- public String toString() {
- return String.format(
- "(%s: %s)",
- this.getClass().getSimpleName(),
- this.future.toString()
- );
- }
-}
diff --git a/artipie-core/src/main/java/com/artipie/http/async/AsyncSlice.java b/artipie-core/src/main/java/com/artipie/http/async/AsyncSlice.java
deleted file mode 100644
index 3ec2ee924..000000000
--- a/artipie-core/src/main/java/com/artipie/http/async/AsyncSlice.java
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
- * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
- * https://github.com/artipie/artipie/blob/master/LICENSE.txt
- */
-
-package com.artipie.http.async;
-
-import com.artipie.http.Response;
-import com.artipie.http.Slice;
-import java.nio.ByteBuffer;
-import java.util.Map;
-import java.util.concurrent.CompletionStage;
-import org.reactivestreams.Publisher;
-
-/**
- * Asynchronous {@link Slice} implementation.
- *
- * This slice encapsulates {@link CompletionStage} of {@link Slice} and returns {@link Response}.
- *
- * @since 0.4
- */
-public final class AsyncSlice implements Slice {
-
- /**
- * Async slice.
- */
- private final CompletionStage extends Slice> slice;
-
- /**
- * Ctor.
- * @param slice Async slice.
- */
- public AsyncSlice(final CompletionStage extends Slice> slice) {
- this.slice = slice;
- }
-
- @Override
- public Response response(
- final String line,
- final Iterable> headers,
- final Publisher body
- ) {
- return new AsyncResponse(
- this.slice.thenApply(target -> target.response(line, headers, body))
- );
- }
-}
diff --git a/artipie-core/src/main/java/com/artipie/http/async/package-info.java b/artipie-core/src/main/java/com/artipie/http/async/package-info.java
deleted file mode 100644
index b2db94a2a..000000000
--- a/artipie-core/src/main/java/com/artipie/http/async/package-info.java
+++ /dev/null
@@ -1,11 +0,0 @@
-/*
- * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
- * https://github.com/artipie/artipie/blob/master/LICENSE.txt
- */
-
-/**
- * Async implementations of {@link com.artipie.http.Slice}, {@link com.artipie.http.Response}, etc.
- * @since 0.4
- */
-package com.artipie.http.async;
-
diff --git a/artipie-core/src/main/java/com/artipie/http/auth/AuthLoader.java b/artipie-core/src/main/java/com/artipie/http/auth/AuthLoader.java
index 5982c5a5a..7e13e89cc 100644
--- a/artipie-core/src/main/java/com/artipie/http/auth/AuthLoader.java
+++ b/artipie-core/src/main/java/com/artipie/http/auth/AuthLoader.java
@@ -66,7 +66,6 @@ public String getFactoryName(final Class> clazz) {
.map(inst -> ((ArtipieAuthFactory) inst).value())
.findFirst()
.orElseThrow(
- // @checkstyle LineLengthCheck (1 lines)
() -> new ArtipieException("Annotation 'ArtipieAuthFactory' should have a not empty value")
);
}
diff --git a/artipie-core/src/main/java/com/artipie/http/auth/AuthScheme.java b/artipie-core/src/main/java/com/artipie/http/auth/AuthScheme.java
index d4a464cc7..12ece41cf 100644
--- a/artipie-core/src/main/java/com/artipie/http/auth/AuthScheme.java
+++ b/artipie-core/src/main/java/com/artipie/http/auth/AuthScheme.java
@@ -4,7 +4,9 @@
*/
package com.artipie.http.auth;
-import java.util.Map;
+import com.artipie.http.Headers;
+import com.artipie.http.rq.RequestLine;
+
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
@@ -12,16 +14,7 @@
/**
* Authentication scheme such as Basic, Bearer etc.
- *
- * @since 0.17
- * @checkstyle JavadocMethodCheck (500 lines)
- * @checkstyle JavadocVariableCheck (500 lines)
- * @checkstyle AvoidInlineConditionalsCheck (500 lines)
- * @checkstyle OperatorWrapCheck (500 lines)
- * @checkstyle StringLiteralsConcatenationCheck (500 lines)
*/
-@SuppressWarnings({"PMD.ProhibitPublicStaticMethods",
- "PMD.ConstructorOnlyInitializesOrCallOtherConstructors"})
public interface AuthScheme {
/**
@@ -43,7 +36,7 @@ public String challenge() {
* @param line Request line.
* @return Authentication result.
*/
- CompletionStage authenticate(Iterable> headers, String line);
+ CompletionStage authenticate(Headers headers, RequestLine line);
/**
* Authenticate HTTP request by its headers.
@@ -51,8 +44,8 @@ public String challenge() {
* @param headers Request headers.
* @return Authentication result.
*/
- default CompletionStage authenticate(Iterable> headers) {
- return this.authenticate(headers, "");
+ default CompletionStage authenticate(Headers headers) {
+ return this.authenticate(headers, null);
}
/**
@@ -209,8 +202,7 @@ public Fake() {
@Override
public CompletionStage authenticate(
- final Iterable> headers,
- final String line
+ Headers headers, RequestLine line
) {
return CompletableFuture.completedFuture(
AuthScheme.result(this.usr, this.chllng)
diff --git a/artipie-core/src/main/java/com/artipie/http/auth/Authentication.java b/artipie-core/src/main/java/com/artipie/http/auth/Authentication.java
index 9fc0dc43f..c07667452 100644
--- a/artipie-core/src/main/java/com/artipie/http/auth/Authentication.java
+++ b/artipie-core/src/main/java/com/artipie/http/auth/Authentication.java
@@ -5,22 +5,17 @@
package com.artipie.http.auth;
import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
-import java.util.stream.Stream;
/**
* Authentication mechanism to verify user.
- * @since 0.8
*/
public interface Authentication {
- /**
- * Resolve anyone as an anonymous user.
- */
- Authentication ANONYMOUS = (name, pswd) -> Optional.of(AuthUser.ANONYMOUS);
-
/**
* Find user by credentials.
* @param username Username
@@ -29,6 +24,24 @@ public interface Authentication {
*/
Optional user(String username, String password);
+ /**
+ * Check if this authentication provider can handle the given username.
+ * Used for domain-based routing to avoid trying providers that don't apply.
+ * @param username Username to check
+ * @return True if this provider should attempt authentication for this user
+ */
+ default boolean canHandle(final String username) {
+ return true; // Default: handle all users
+ }
+
+ /**
+ * Get configured user domain patterns for this provider.
+ * @return Collection of domain patterns (empty means handle all)
+ */
+ default Collection userDomains() {
+ return Collections.emptyList();
+ }
+
/**
* Abstract decorator for Authentication.
*
@@ -54,6 +67,16 @@ protected Wrap(final Authentication auth) {
public final Optional user(final String username, final String password) {
return this.auth.user(username, password);
}
+
+ @Override
+ public boolean canHandle(final String username) {
+ return this.auth.canHandle(username);
+ }
+
+ @Override
+ public Collection userDomains() {
+ return this.auth.userDomains();
+ }
}
/**
@@ -136,10 +159,26 @@ public Joined(final List origins) {
@Override
public Optional user(final String user, final String pass) {
- return this.origins.stream()
- .map(auth -> auth.user(user, pass))
- .flatMap(opt -> opt.map(Stream::of).orElseGet(Stream::empty))
- .findFirst();
+ for (final Authentication auth : this.origins) {
+ if (!auth.canHandle(user)) {
+ // Provider doesn't handle this username domain - skip
+ continue;
+ }
+ // Provider can handle this user - try authentication
+ final Optional result = auth.user(user, pass);
+ if (result.isPresent()) {
+ // Success - return immediately
+ return result;
+ }
+ // Provider matched domain but auth failed
+ // If provider has specific domains configured, stop here
+ // (don't try other providers for same domain)
+ if (!auth.userDomains().isEmpty()) {
+ return Optional.empty();
+ }
+ // Provider is a catch-all, continue to next
+ }
+ return Optional.empty();
}
@Override
diff --git a/artipie-core/src/main/java/com/artipie/http/auth/AuthzSlice.java b/artipie-core/src/main/java/com/artipie/http/auth/AuthzSlice.java
index ba4eeb278..775761d31 100644
--- a/artipie-core/src/main/java/com/artipie/http/auth/AuthzSlice.java
+++ b/artipie-core/src/main/java/com/artipie/http/auth/AuthzSlice.java
@@ -4,30 +4,21 @@
*/
package com.artipie.http.auth;
+import com.artipie.asto.Content;
import com.artipie.http.Headers;
import com.artipie.http.Response;
+import com.artipie.http.ResponseBuilder;
import com.artipie.http.Slice;
-import com.artipie.http.async.AsyncResponse;
+import com.artipie.http.headers.Header;
import com.artipie.http.headers.WwwAuthenticate;
-import com.artipie.http.rs.RsStatus;
-import com.artipie.http.rs.RsWithHeaders;
-import com.artipie.http.rs.RsWithStatus;
-import java.nio.ByteBuffer;
-import java.util.Map;
-import org.reactivestreams.Publisher;
+import com.artipie.http.rq.RequestLine;
+import org.slf4j.MDC;
+
+import java.util.concurrent.CompletableFuture;
/**
* Slice with authorization.
- *
- * @since 1.2
- * @checkstyle ReturnCountCheck (500 lines)
- * @checkstyle JavadocMethodCheck (500 lines)
- * @checkstyle ParameterNumberCheck (500 lines)
- * @checkstyle NestedIfDepthCheck (500 lines)
- * @checkstyle MethodBodyCommentsCheck (500 lines)
- * @checkstyle AvoidInlineConditionalsCheck (500 lines)
*/
-@SuppressWarnings("PMD.OnlyOneReturn")
public final class AuthzSlice implements Slice {
/**
@@ -51,58 +42,70 @@ public final class AuthzSlice implements Slice {
private final OperationControl control;
/**
- * Ctor.
- *
* @param origin Origin slice.
* @param auth Authentication scheme.
* @param control Access control by permission.
*/
- public AuthzSlice(final Slice origin, final AuthScheme auth, final OperationControl control) {
+ public AuthzSlice(Slice origin, AuthScheme auth, OperationControl control) {
this.origin = origin;
this.auth = auth;
this.control = control;
}
@Override
- public Response response(
- final String line,
- final Iterable> headers,
- final Publisher body
+ public CompletableFuture response(
+ RequestLine line, Headers headers, Content body
) {
- return new AsyncResponse(
- this.auth.authenticate(headers, line).thenApply(
+ return this.auth.authenticate(headers, line)
+ .toCompletableFuture()
+ .thenCompose(
result -> {
if (result.status() == AuthScheme.AuthStatus.AUTHENTICATED) {
+ // Set MDC for downstream logging (cooldown, metrics, etc.)
+ // This ensures Bearer/JWT authenticated users are tracked correctly
+ final String userName = result.user().name();
+ if (userName != null && !userName.isEmpty() && !result.user().isAnonymous()) {
+ MDC.put("user.name", userName);
+ }
if (this.control.allowed(result.user())) {
return this.origin.response(
line,
- new Headers.From(
- headers, AuthzSlice.LOGIN_HDR,
- result.user().name()
- ),
+ headers.copy().add(AuthzSlice.LOGIN_HDR, userName),
body
);
}
- return new RsWithStatus(RsStatus.FORBIDDEN);
+ // Consume request body to prevent Vert.x request leak
+ return body.asBytesFuture().thenApply(ignored ->
+ ResponseBuilder.forbidden().build()
+ );
}
- // The case of anonymous user
- if (result.status() == AuthScheme.AuthStatus.NO_CREDENTIALS
- && this.control.allowed(result.user())) {
- return this.origin.response(
- line,
- new Headers.From(
- headers, AuthzSlice.LOGIN_HDR,
- result.user().name()
- ),
- body
+ if (result.status() == AuthScheme.AuthStatus.NO_CREDENTIALS) {
+ try {
+ final String challenge = result.challenge();
+ if (challenge != null && !challenge.isBlank()) {
+ return ResponseBuilder.unauthorized()
+ .header(new WwwAuthenticate(challenge))
+ .completedFuture();
+ }
+ } catch (final UnsupportedOperationException ignored) {
+ // fall through when scheme does not provide challenge
+ }
+ if (this.control.allowed(result.user())) {
+ return this.origin.response(
+ line,
+ headers.copy().add(AuthzSlice.LOGIN_HDR, result.user().name()),
+ body
+ );
+ }
+ // Consume request body to prevent Vert.x request leak
+ return body.asBytesFuture().thenApply(ignored2 ->
+ ResponseBuilder.forbidden().build()
);
}
- return new RsWithHeaders(
- new RsWithStatus(RsStatus.UNAUTHORIZED),
- new Headers.From(new WwwAuthenticate(result.challenge()))
- );
+ return ResponseBuilder.unauthorized()
+ .header(new WwwAuthenticate(result.challenge()))
+ .completedFuture();
}
- )
);
}
}
diff --git a/artipie-core/src/main/java/com/artipie/http/auth/BasicAuthScheme.java b/artipie-core/src/main/java/com/artipie/http/auth/BasicAuthScheme.java
index 62d08d6c9..c6a00eea4 100644
--- a/artipie-core/src/main/java/com/artipie/http/auth/BasicAuthScheme.java
+++ b/artipie-core/src/main/java/com/artipie/http/auth/BasicAuthScheme.java
@@ -4,18 +4,24 @@
*/
package com.artipie.http.auth;
+import com.artipie.http.Headers;
import com.artipie.http.headers.Authorization;
+import com.artipie.http.rq.RequestLine;
import com.artipie.http.rq.RqHeaders;
-import java.util.Map;
+import com.artipie.http.trace.TraceContextExecutor;
+
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.atomic.AtomicInteger;
/**
* Basic authentication method.
*
* @since 0.17
- * @checkstyle ReturnCountCheck (500 lines)
*/
@SuppressWarnings("PMD.OnlyOneReturn")
public final class BasicAuthScheme implements AuthScheme {
@@ -31,6 +37,32 @@ public final class BasicAuthScheme implements AuthScheme {
private static final String CHALLENGE =
String.format("%s realm=\"artipie\"", BasicAuthScheme.NAME);
+ /**
+ * Pool name for metrics identification.
+ */
+ public static final String AUTH_POOL_NAME = "artipie.auth.basic";
+
+ /**
+ * Thread pool for blocking authentication operations.
+ * This offloads potentially slow operations (like Okta MFA) from the event loop.
+ * Pool name: {@value #AUTH_POOL_NAME} (visible in thread dumps and metrics).
+ * Wrapped with TraceContextExecutor to propagate MDC (trace.id, user, etc.) to auth threads.
+ */
+ private static final ExecutorService AUTH_EXECUTOR = TraceContextExecutor.wrap(
+ Executors.newCachedThreadPool(
+ new ThreadFactory() {
+ private final AtomicInteger counter = new AtomicInteger(0);
+ @Override
+ public Thread newThread(final Runnable runnable) {
+ final Thread thread = new Thread(runnable);
+ thread.setName(AUTH_POOL_NAME + ".worker-" + counter.incrementAndGet());
+ thread.setDaemon(true);
+ return thread;
+ }
+ }
+ )
+ );
+
/**
* Authentication.
*/
@@ -46,14 +78,23 @@ public BasicAuthScheme(final Authentication auth) {
@Override
public CompletionStage authenticate(
- final Iterable> headers, final String line
+ Headers headers, RequestLine line
) {
- final AuthScheme.Result result = new RqHeaders(headers, Authorization.NAME)
+ final Optional authHeader = new RqHeaders(headers, Authorization.NAME)
.stream()
- .findFirst()
- .map(s -> AuthScheme.result(this.user(s), BasicAuthScheme.CHALLENGE))
- .orElseGet(() -> AuthScheme.result(AuthUser.ANONYMOUS, BasicAuthScheme.CHALLENGE));
- return CompletableFuture.completedFuture(result);
+ .findFirst();
+ if (authHeader.isEmpty()) {
+ // No credentials provided - return immediately without blocking
+ return CompletableFuture.completedFuture(
+ AuthScheme.result(AuthUser.ANONYMOUS, BasicAuthScheme.CHALLENGE)
+ );
+ }
+ // Offload auth to worker thread to prevent blocking event loop
+ // This is critical for auth providers that make external calls (Okta, Keycloak, etc.)
+ return CompletableFuture.supplyAsync(
+ () -> AuthScheme.result(this.user(authHeader.get()), BasicAuthScheme.CHALLENGE),
+ AUTH_EXECUTOR
+ );
}
/**
@@ -64,7 +105,7 @@ public CompletionStage authenticate(
*/
private Optional user(final String header) {
final Authorization atz = new Authorization(header);
- if (atz.scheme().equals(BasicAuthScheme.NAME)) {
+ if (BasicAuthScheme.NAME.equals(atz.scheme())) {
final Authorization.Basic basic = new Authorization.Basic(atz.credentials());
return this.auth.user(basic.username(), basic.password());
}
diff --git a/artipie-core/src/main/java/com/artipie/http/auth/BearerAuthScheme.java b/artipie-core/src/main/java/com/artipie/http/auth/BearerAuthScheme.java
index 0ac4ff506..98f5efb02 100644
--- a/artipie-core/src/main/java/com/artipie/http/auth/BearerAuthScheme.java
+++ b/artipie-core/src/main/java/com/artipie/http/auth/BearerAuthScheme.java
@@ -4,9 +4,11 @@
*/
package com.artipie.http.auth;
+import com.artipie.http.Headers;
import com.artipie.http.headers.Authorization;
+import com.artipie.http.rq.RequestLine;
import com.artipie.http.rq.RqHeaders;
-import java.util.Map;
+
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
@@ -15,7 +17,6 @@
* Bearer authentication method.
*
* @since 0.17
- * @checkstyle ReturnCountCheck (500 lines)
*/
@SuppressWarnings("PMD.OnlyOneReturn")
public final class BearerAuthScheme implements AuthScheme {
@@ -47,8 +48,7 @@ public BearerAuthScheme(final TokenAuthentication auth, final String params) {
}
@Override
- public CompletionStage authenticate(final Iterable> headers,
- final String line) {
+ public CompletionStage authenticate(Headers headers, RequestLine line) {
return new RqHeaders(headers, Authorization.NAME)
.stream()
.findFirst()
@@ -70,7 +70,7 @@ public CompletionStage authenticate(final Iterable> user(final String header) {
final Authorization atz = new Authorization(header);
- if (atz.scheme().equals(BearerAuthScheme.NAME)) {
+ if (BearerAuthScheme.NAME.equals(atz.scheme())) {
return this.auth.user(
new Authorization.Bearer(atz.credentials()).token()
);
diff --git a/artipie-core/src/main/java/com/artipie/http/auth/CombinedAuthScheme.java b/artipie-core/src/main/java/com/artipie/http/auth/CombinedAuthScheme.java
new file mode 100644
index 000000000..3c4b01f7f
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/http/auth/CombinedAuthScheme.java
@@ -0,0 +1,116 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.http.auth;
+
+import com.artipie.http.Headers;
+import com.artipie.http.headers.Authorization;
+import com.artipie.http.rq.RequestLine;
+import com.artipie.http.rq.RqHeaders;
+
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+
+/**
+ * Authentication scheme that supports both Basic and Bearer token authentication.
+ * @since 1.18
+ */
+public final class CombinedAuthScheme implements AuthScheme {
+
+ /**
+ * Basic authentication.
+ */
+ private final Authentication basicAuth;
+
+ /**
+ * Token authentication.
+ */
+ private final TokenAuthentication tokenAuth;
+
+ /**
+ * Ctor.
+ *
+ * @param basicAuth Basic authentication.
+ * @param tokenAuth Token authentication.
+ */
+ public CombinedAuthScheme(
+ final Authentication basicAuth,
+ final TokenAuthentication tokenAuth
+ ) {
+ this.basicAuth = basicAuth;
+ this.tokenAuth = tokenAuth;
+ }
+
+ @Override
+ public CompletionStage authenticate(
+ final Headers headers,
+ final RequestLine line
+ ) {
+ return new RqHeaders(headers, Authorization.NAME)
+ .stream()
+ .findFirst()
+ .map(Authorization::new)
+ .map(
+ auth -> {
+ if (BasicAuthScheme.NAME.equals(auth.scheme())) {
+ return this.authenticateBasic(auth);
+ } else if (BearerAuthScheme.NAME.equals(auth.scheme())) {
+ return this.authenticateBearer(auth);
+ }
+ return CompletableFuture.completedFuture(
+ AuthScheme.result(
+ AuthUser.ANONYMOUS,
+ String.format("%s realm=\"artipie\", %s realm=\"artipie\"",
+ BasicAuthScheme.NAME, BearerAuthScheme.NAME)
+ )
+ );
+ }
+ )
+ .orElseGet(
+ () -> CompletableFuture.completedFuture(
+ AuthScheme.result(
+ AuthUser.ANONYMOUS,
+ String.format("%s realm=\"artipie\", %s realm=\"artipie\"",
+ BasicAuthScheme.NAME, BearerAuthScheme.NAME)
+ )
+ )
+ );
+ }
+
+ /**
+ * Authenticate using Basic authentication.
+ *
+ * @param auth Authorization header
+ * @return Authentication result
+ */
+ private CompletionStage authenticateBasic(final Authorization auth) {
+ final Authorization.Basic basic = new Authorization.Basic(auth.credentials());
+ final Optional user = this.basicAuth.user(basic.username(), basic.password());
+ return CompletableFuture.completedFuture(
+ AuthScheme.result(
+ user,
+ String.format("%s realm=\"artipie\", %s realm=\"artipie\"",
+ BasicAuthScheme.NAME, BearerAuthScheme.NAME)
+ )
+ );
+ }
+
+ /**
+ * Authenticate using Bearer token authentication.
+ *
+ * @param auth Authorization header
+ * @return Authentication result
+ */
+ private CompletionStage authenticateBearer(final Authorization auth) {
+ return this.tokenAuth.user(new Authorization.Bearer(auth.credentials()).token())
+ .thenApply(
+ user -> AuthScheme.result(
+ user,
+ String.format("%s realm=\"artipie\", %s realm=\"artipie\"",
+ BasicAuthScheme.NAME, BearerAuthScheme.NAME)
+ )
+ );
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/http/auth/CombinedAuthzSlice.java b/artipie-core/src/main/java/com/artipie/http/auth/CombinedAuthzSlice.java
new file mode 100644
index 000000000..2a1d55b40
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/http/auth/CombinedAuthzSlice.java
@@ -0,0 +1,248 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.http.auth;
+
+import com.artipie.http.Headers;
+import com.artipie.http.Response;
+import com.artipie.http.ResponseBuilder;
+import com.artipie.http.Slice;
+import com.artipie.http.headers.Authorization;
+import com.artipie.http.headers.Header;
+import com.artipie.http.headers.WwwAuthenticate;
+import com.artipie.http.rq.RequestLine;
+import com.artipie.http.rq.RqHeaders;
+import com.artipie.http.trace.TraceContextExecutor;
+import org.slf4j.MDC;
+
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Slice with combined basic and bearer token authentication.
+ * Supports both Basic and Bearer authentication methods.
+ * @since 1.18
+ */
+public final class CombinedAuthzSlice implements Slice {
+
+ /**
+ * Header for artipie login.
+ */
+ public static final String LOGIN_HDR = "artipie_login";
+
+ /**
+ * Pool name for metrics identification.
+ */
+ public static final String AUTH_POOL_NAME = "artipie.auth.combined";
+
+ /**
+ * Thread pool for blocking authentication operations.
+ * This offloads potentially slow operations (like Okta MFA) from the event loop.
+ * Pool name: {@value #AUTH_POOL_NAME} (visible in thread dumps and metrics).
+ * Wrapped with TraceContextExecutor to propagate MDC (trace.id, user, etc.) to auth threads.
+ */
+ private static final ExecutorService AUTH_EXECUTOR = TraceContextExecutor.wrap(
+ Executors.newCachedThreadPool(
+ new ThreadFactory() {
+ private final AtomicInteger counter = new AtomicInteger(0);
+ @Override
+ public Thread newThread(final Runnable runnable) {
+ final Thread thread = new Thread(runnable);
+ thread.setName(AUTH_POOL_NAME + ".worker-" + counter.incrementAndGet());
+ thread.setDaemon(true);
+ return thread;
+ }
+ }
+ )
+ );
+
+ /**
+ * Origin.
+ */
+ private final Slice origin;
+
+ /**
+ * Basic authentication.
+ */
+ private final Authentication basicAuth;
+
+ /**
+ * Token authentication.
+ */
+ private final TokenAuthentication tokenAuth;
+
+ /**
+ * Access control by permission.
+ */
+ private final OperationControl control;
+
+ /**
+ * Ctor.
+ *
+ * @param origin Origin slice.
+ * @param basicAuth Basic authentication.
+ * @param tokenAuth Token authentication.
+ * @param control Access control by permission.
+ */
+ public CombinedAuthzSlice(
+ final Slice origin,
+ final Authentication basicAuth,
+ final TokenAuthentication tokenAuth,
+ final OperationControl control
+ ) {
+ this.origin = origin;
+ this.basicAuth = basicAuth;
+ this.tokenAuth = tokenAuth;
+ this.control = control;
+ }
+
+ @Override
+ public CompletableFuture response(
+ final RequestLine line, final Headers headers, final com.artipie.asto.Content body
+ ) {
+ return this.authenticate(headers, line)
+ .toCompletableFuture()
+ .thenCompose(
+ result -> {
+ if (result.status() == AuthScheme.AuthStatus.AUTHENTICATED) {
+ // Set MDC for downstream logging (cooldown, metrics, etc.)
+ // This ensures Bearer/JWT authenticated users are tracked correctly
+ final String userName = result.user().name();
+ if (userName != null && !userName.isEmpty() && !result.user().isAnonymous()) {
+ MDC.put("user.name", userName);
+ }
+ if (this.control.allowed(result.user())) {
+ return this.origin.response(
+ line,
+ headers.copy().add(CombinedAuthzSlice.LOGIN_HDR, userName),
+ body
+ );
+ }
+ // Consume request body to prevent Vert.x request leak
+ return body.asBytesFuture().thenApply(ignored ->
+ ResponseBuilder.forbidden().build()
+ );
+ }
+ if (result.status() == AuthScheme.AuthStatus.NO_CREDENTIALS) {
+ try {
+ final String challenge = result.challenge();
+ if (challenge != null && !challenge.isBlank()) {
+ return ResponseBuilder.unauthorized()
+ .header(new WwwAuthenticate(challenge))
+ .completedFuture();
+ }
+ } catch (final UnsupportedOperationException ignored) {
+ // fall through when scheme does not provide challenge
+ }
+ if (this.control.allowed(result.user())) {
+ return this.origin.response(
+ line,
+ headers.copy().add(CombinedAuthzSlice.LOGIN_HDR, result.user().name()),
+ body
+ );
+ }
+ // Consume request body to prevent Vert.x request leak
+ return body.asBytesFuture().thenApply(ignored2 ->
+ ResponseBuilder.forbidden().build()
+ );
+ }
+ return ResponseBuilder.unauthorized()
+ .header(new WwwAuthenticate(result.challenge()))
+ .completedFuture();
+ }
+ );
+ }
+
+ /**
+ * Authenticate using either Basic or Bearer authentication.
+ *
+ * @param headers Request headers.
+ * @param line Request line.
+ * @return Authentication result.
+ */
+ private CompletionStage authenticate(
+ final Headers headers, final RequestLine line
+ ) {
+ return new RqHeaders(headers, Authorization.NAME)
+ .stream()
+ .findFirst()
+ .map(
+ header -> {
+ final Authorization auth = new Authorization(header);
+ final String scheme = auth.scheme();
+
+ if (BasicAuthScheme.NAME.equals(scheme)) {
+ return this.authenticateBasic(auth);
+ } else if (BearerAuthScheme.NAME.equals(scheme)) {
+ return this.authenticateBearer(auth);
+ } else {
+ return CompletableFuture.completedFuture(
+ AuthScheme.result(
+ AuthUser.ANONYMOUS,
+ String.format("%s realm=\"artipie\", %s realm=\"artipie\"",
+ BasicAuthScheme.NAME, BearerAuthScheme.NAME)
+ )
+ );
+ }
+ }
+ ).orElseGet(
+ () -> CompletableFuture.completedFuture(
+ AuthScheme.result(
+ AuthUser.ANONYMOUS,
+ String.format("%s realm=\"artipie\", %s realm=\"artipie\"",
+ BasicAuthScheme.NAME, BearerAuthScheme.NAME)
+ )
+ )
+ );
+ }
+
+ /**
+ * Authenticate using Basic authentication.
+ * Runs on a dedicated thread pool to avoid blocking the event loop,
+ * especially important when authentication involves external IdP calls (e.g., Okta with MFA).
+ *
+ * @param auth Authorization header.
+ * @return Authentication result.
+ */
+ private CompletionStage authenticateBasic(final Authorization auth) {
+ final Authorization.Basic basic = new Authorization.Basic(auth.credentials());
+ // Offload to worker thread to prevent blocking event loop
+ // This is critical for auth providers that make external calls (Okta, Keycloak, etc.)
+ return CompletableFuture.supplyAsync(
+ () -> {
+ final Optional user = this.basicAuth.user(
+ basic.username(), basic.password()
+ );
+ return AuthScheme.result(
+ user,
+ String.format("%s realm=\"artipie\", %s realm=\"artipie\"",
+ BasicAuthScheme.NAME, BearerAuthScheme.NAME)
+ );
+ },
+ AUTH_EXECUTOR
+ );
+ }
+
+ /**
+ * Authenticate using Bearer token authentication.
+ *
+ * @param auth Authorization header.
+ * @return Authentication result.
+ */
+ private CompletionStage authenticateBearer(final Authorization auth) {
+ return this.tokenAuth.user(new Authorization.Bearer(auth.credentials()).token())
+ .thenApply(
+ user -> AuthScheme.result(
+ user,
+ String.format("%s realm=\"artipie\", %s realm=\"artipie\"",
+ BasicAuthScheme.NAME, BearerAuthScheme.NAME)
+ )
+ );
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/http/auth/CombinedAuthzSliceWrap.java b/artipie-core/src/main/java/com/artipie/http/auth/CombinedAuthzSliceWrap.java
new file mode 100644
index 000000000..3e91786ee
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/http/auth/CombinedAuthzSliceWrap.java
@@ -0,0 +1,31 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.http.auth;
+
+import com.artipie.http.Slice;
+
+/**
+ * Slice with combined basic and bearer token authentication.
+ * Supports both Basic and Bearer authentication methods.
+ * @since 1.18
+ */
+public final class CombinedAuthzSliceWrap extends Slice.Wrap {
+
+ /**
+ * Ctor.
+ * @param origin Origin slice
+ * @param basicAuth Basic authentication
+ * @param tokenAuth Token authentication
+ * @param control Access control
+ */
+ public CombinedAuthzSliceWrap(
+ final Slice origin,
+ final Authentication basicAuth,
+ final TokenAuthentication tokenAuth,
+ final OperationControl control
+ ) {
+ super(new CombinedAuthzSlice(origin, basicAuth, tokenAuth, control));
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/http/auth/DomainFilteredAuth.java b/artipie-core/src/main/java/com/artipie/http/auth/DomainFilteredAuth.java
new file mode 100644
index 000000000..a25897820
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/http/auth/DomainFilteredAuth.java
@@ -0,0 +1,72 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.http.auth;
+
+import java.util.Collection;
+import java.util.Optional;
+
+/**
+ * Authentication wrapper that filters by username domain.
+ * Only attempts authentication if username matches configured domain patterns.
+ * @since 1.20.7
+ */
+public final class DomainFilteredAuth implements Authentication {
+
+ /**
+ * Origin authentication.
+ */
+ private final Authentication origin;
+
+ /**
+ * Domain matcher.
+ */
+ private final UserDomainMatcher matcher;
+
+ /**
+ * Provider name for logging.
+ */
+ private final String name;
+
+ /**
+ * Ctor.
+ * @param origin Origin authentication
+ * @param domains Domain patterns (empty = match all)
+ * @param name Provider name for logging
+ */
+ public DomainFilteredAuth(
+ final Authentication origin,
+ final Collection domains,
+ final String name
+ ) {
+ this.origin = origin;
+ this.matcher = new UserDomainMatcher(domains);
+ this.name = name;
+ }
+
+ @Override
+ public Optional user(final String username, final String password) {
+ return this.origin.user(username, password);
+ }
+
+ @Override
+ public boolean canHandle(final String username) {
+ return this.matcher.matches(username);
+ }
+
+ @Override
+ public Collection userDomains() {
+ return this.matcher.patterns();
+ }
+
+ @Override
+ public String toString() {
+ return String.format(
+ "%s(provider=%s, domains=%s)",
+ this.getClass().getSimpleName(),
+ this.name,
+ this.matcher.patterns()
+ );
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/http/auth/OperationControl.java b/artipie-core/src/main/java/com/artipie/http/auth/OperationControl.java
index 4676b8957..73bef6f41 100644
--- a/artipie-core/src/main/java/com/artipie/http/auth/OperationControl.java
+++ b/artipie-core/src/main/java/com/artipie/http/auth/OperationControl.java
@@ -5,8 +5,12 @@
package com.artipie.http.auth;
import com.artipie.security.policy.Policy;
-import com.jcabi.log.Logger;
+import com.artipie.http.log.EcsLogger;
+
import java.security.Permission;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
/**
* Operation controller for slice. The class is meant to check
@@ -14,9 +18,6 @@
*
* Instances of this class are created in the adapter with users' policies and required
* permission for the adapter's operation.
- * @since 1.2
- * @checkstyle StringLiteralsConcatenationCheck (500 lines)
- * @checkstyle AvoidInlineConditionalsCheck (500 lines)
*/
public final class OperationControl {
@@ -26,9 +27,9 @@ public final class OperationControl {
private final Policy> policy;
/**
- * Required permission.
+ * Required permissions (at least one should be allowed).
*/
- private final Permission perm;
+ private final Collection perms;
/**
* Ctor.
@@ -36,8 +37,26 @@ public final class OperationControl {
* @param perm Required permission
*/
public OperationControl(final Policy> policy, final Permission perm) {
+ this(policy, Collections.singleton(perm));
+ }
+
+ /**
+ * Ctor.
+ * @param policy Security policy
+ * @param perms Required permissions (at least one should be allowed)
+ */
+ public OperationControl(final Policy> policy, final Permission... perms) {
+ this(policy, List.of(perms));
+ }
+
+ /**
+ * Ctor.
+ * @param policy Security policy
+ * @param perms Required permissions (at least one should be allowed)
+ */
+ public OperationControl(final Policy> policy, final Collection perms) {
this.policy = policy;
- this.perm = perm;
+ this.perms = perms;
}
/**
@@ -46,12 +65,17 @@ public OperationControl(final Policy> policy, final Permission perm) {
* @return True if authorized
*/
public boolean allowed(final AuthUser user) {
- final boolean res = this.policy.getPermissions(user).implies(this.perm);
- Logger.debug(
- "security",
- "Authorization operation: [permission=%s, user=%s, result=%s]",
- this.perm, user.name(), res ? "allowed" : "NOT allowed"
- );
+ final boolean res = perms.stream()
+ .anyMatch(perm -> policy.getPermissions(user).implies(perm));
+ EcsLogger.debug("com.artipie.security")
+ .message("Authorization operation")
+ .eventCategory("security")
+ .eventAction("authorization_check")
+ .eventOutcome(res ? "success" : "failure")
+ .field("user.name", user.name())
+ .field("user.roles", this.perms.toString())
+ .field("event.outcome", res ? "allowed" : "denied")
+ .log();
return res;
}
}
diff --git a/artipie-core/src/main/java/com/artipie/http/auth/Tokens.java b/artipie-core/src/main/java/com/artipie/http/auth/Tokens.java
index 3217e1ef6..4ea53a5e9 100644
--- a/artipie-core/src/main/java/com/artipie/http/auth/Tokens.java
+++ b/artipie-core/src/main/java/com/artipie/http/auth/Tokens.java
@@ -22,4 +22,14 @@ public interface Tokens {
* @return String token
*/
String generate(AuthUser user);
+
+ /**
+ * Generate token for provided user with explicit permanence control.
+ * @param user User to issue token for
+ * @param permanent If true, generate a non-expiring token regardless of global settings
+ * @return String token
+ */
+ default String generate(AuthUser user, boolean permanent) {
+ return generate(user);
+ }
}
diff --git a/artipie-core/src/main/java/com/artipie/http/auth/UserDomainMatcher.java b/artipie-core/src/main/java/com/artipie/http/auth/UserDomainMatcher.java
new file mode 100644
index 000000000..39297010a
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/http/auth/UserDomainMatcher.java
@@ -0,0 +1,114 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.http.auth;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Utility for matching usernames against domain patterns.
+ * Used by authentication providers for domain-based routing.
+ *
+ * Supported patterns:
+ *
+ * {@code @domain.com} - matches usernames ending with @domain.com
+ * {@code local} - matches usernames without @ (local users)
+ * {@code *} - matches any username (catch-all)
+ *
+ * @since 1.20.7
+ */
+public final class UserDomainMatcher {
+
+ /**
+ * Pattern for local users (no domain).
+ */
+ public static final String LOCAL = "local";
+
+ /**
+ * Pattern for catch-all (any user).
+ */
+ public static final String ANY = "*";
+
+ /**
+ * Domain patterns to match against.
+ */
+ private final List patterns;
+
+ /**
+ * Ctor with no patterns (matches all users).
+ */
+ public UserDomainMatcher() {
+ this(Collections.emptyList());
+ }
+
+ /**
+ * Ctor.
+ * @param patterns Domain patterns
+ */
+ public UserDomainMatcher(final Collection patterns) {
+ this.patterns = List.copyOf(patterns);
+ }
+
+ /**
+ * Check if username matches any configured pattern.
+ * @param username Username to check
+ * @return True if matches (or no patterns configured = matches all)
+ */
+ public boolean matches(final String username) {
+ if (this.patterns.isEmpty()) {
+ // No patterns = catch-all
+ return true;
+ }
+ if (username == null || username.isEmpty()) {
+ return false;
+ }
+ for (final String pattern : this.patterns) {
+ if (matchesPattern(username, pattern)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Get configured patterns.
+ * @return Unmodifiable collection of patterns
+ */
+ public Collection patterns() {
+ return this.patterns;
+ }
+
+ /**
+ * Check if username matches a single pattern.
+ * @param username Username to check
+ * @param pattern Pattern to match against
+ * @return True if matches
+ */
+ private static boolean matchesPattern(final String username, final String pattern) {
+ if (pattern == null || pattern.isEmpty()) {
+ return false;
+ }
+ if (ANY.equals(pattern)) {
+ // Catch-all
+ return true;
+ }
+ if (LOCAL.equals(pattern)) {
+ // Local user = no @ in username
+ return !username.contains("@");
+ }
+ if (pattern.startsWith("@")) {
+ // Domain suffix match
+ return username.endsWith(pattern);
+ }
+ // Exact match
+ return username.equals(pattern);
+ }
+
+ @Override
+ public String toString() {
+ return String.format("UserDomainMatcher(%s)", this.patterns);
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/http/cache/CachedArtifactMetadataStore.java b/artipie-core/src/main/java/com/artipie/http/cache/CachedArtifactMetadataStore.java
new file mode 100644
index 000000000..7f44ce8cc
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/http/cache/CachedArtifactMetadataStore.java
@@ -0,0 +1,266 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.http.cache;
+
+import com.artipie.asto.Content;
+import com.artipie.asto.Key;
+import com.artipie.asto.Storage;
+import com.artipie.http.Headers;
+import com.artipie.http.headers.Header;
+
+import javax.json.Json;
+import javax.json.JsonArray;
+import javax.json.JsonArrayBuilder;
+import javax.json.JsonObject;
+import javax.json.JsonObjectBuilder;
+import javax.json.JsonReader;
+import java.io.StringReader;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Persists response metadata (headers, size, digests) alongside cached artifacts.
+ * Used by proxy slices to serve cached content without revalidating via upstream HEAD calls.
+ */
+public final class CachedArtifactMetadataStore {
+
+ /**
+ * Metadata file suffix.
+ */
+ private static final String META_SUFFIX = ".artipie-meta.json";
+
+ /**
+ * Backing storage.
+ */
+ private final Storage storage;
+
+ /**
+ * New metadata store.
+ *
+ * @param storage Storage for metadata and checksum sidecars.
+ */
+ public CachedArtifactMetadataStore(final Storage storage) {
+ this.storage = storage;
+ }
+
+ /**
+ * Persist headers, size and computed digests for an artifact.
+ * Also materialises checksum sidecar files for quick subsequent lookup.
+ *
+ * @param key Artifact key.
+ * @param headers Upstream response headers.
+ * @param digests Computed digests and size information.
+ * @return Future that completes when metadata and checksum files are written.
+ */
+ public CompletableFuture save(
+ final Key key,
+ final Headers headers,
+ final ComputedDigests digests
+ ) {
+ final Headers normalized = ensureContentLength(headers, digests.size());
+ final CompletableFuture meta = this.saveMetadataFile(key, normalized, digests);
+ final List> checksumWrites = new ArrayList<>(4);
+ digests.sha1().ifPresent(checksum ->
+ checksumWrites.add(this.saveChecksum(key, ".sha1", checksum))
+ );
+ digests.sha256().ifPresent(checksum ->
+ checksumWrites.add(this.saveChecksum(key, ".sha256", checksum))
+ );
+ digests.sha512().ifPresent(checksum ->
+ checksumWrites.add(this.saveChecksum(key, ".sha512", checksum))
+ );
+ digests.md5().ifPresent(checksum ->
+ checksumWrites.add(this.saveChecksum(key, ".md5", checksum))
+ );
+ checksumWrites.add(meta);
+ return CompletableFuture
+ .allOf(checksumWrites.toArray(new CompletableFuture[0]))
+ .thenApply(ignored -> normalized);
+ }
+
+ /**
+ * Load metadata for cached artifact if present.
+ *
+ * @param key Artifact key.
+ * @return Metadata optional.
+ */
+ public CompletableFuture> load(final Key key) {
+ final Key meta = this.metaKey(key);
+ return this.storage.exists(meta).thenCompose(
+ exists -> {
+ if (!exists) {
+ return CompletableFuture.completedFuture(Optional.empty());
+ }
+ return this.storage.value(meta).thenCompose(
+ content -> content.asStringFuture()
+ .thenApply(str -> Optional.of(this.parseMetadata(str)))
+ );
+ }
+ );
+ }
+
+ private CompletableFuture saveChecksum(
+ final Key artifact,
+ final String extension,
+ final String value
+ ) {
+ final Key checksum = new Key.From(String.format("%s%s", artifact.string(), extension));
+ final byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
+ return this.storage.save(checksum, new Content.From(bytes));
+ }
+
+ private CompletableFuture saveMetadataFile(
+ final Key key,
+ final Headers headers,
+ final ComputedDigests digests
+ ) {
+ final JsonObjectBuilder root = Json.createObjectBuilder()
+ .add("size", digests.size());
+ final JsonArrayBuilder hdrs = Json.createArrayBuilder();
+ for (Header header : headers) {
+ hdrs.add(
+ Json.createObjectBuilder()
+ .add("name", header.getKey())
+ .add("value", header.getValue())
+ );
+ }
+ root.add("headers", hdrs);
+ final JsonObjectBuilder checksums = Json.createObjectBuilder();
+ digests.sha1().ifPresent(val -> checksums.add("sha1", val));
+ digests.sha256().ifPresent(val -> checksums.add("sha256", val));
+ digests.sha512().ifPresent(val -> checksums.add("sha512", val));
+ digests.md5().ifPresent(val -> checksums.add("md5", val));
+ root.add("digests", checksums.build());
+ final byte[] bytes = root.build().toString().getBytes(StandardCharsets.UTF_8);
+ return this.storage.save(this.metaKey(key), new Content.From(bytes));
+ }
+
+ private Metadata parseMetadata(final String raw) {
+ try (JsonReader reader = Json.createReader(new StringReader(raw))) {
+ final JsonObject json = reader.readObject();
+ final long size = json.getJsonNumber("size").longValue();
+ final JsonArray hdrs = json.getJsonArray("headers");
+ final List headers = new ArrayList<>(hdrs.size());
+ for (int idx = 0; idx < hdrs.size(); idx++) {
+ final JsonObject item = hdrs.getJsonObject(idx);
+ headers.add(new Header(item.getString("name"), item.getString("value")));
+ }
+ final JsonObject digests = json.getJsonObject("digests");
+ final Map map = new HashMap<>();
+ if (digests.containsKey("sha1")) {
+ map.put("sha1", digests.getString("sha1"));
+ }
+ if (digests.containsKey("sha256")) {
+ map.put("sha256", digests.getString("sha256"));
+ }
+ if (digests.containsKey("sha512")) {
+ map.put("sha512", digests.getString("sha512"));
+ }
+ if (digests.containsKey("md5")) {
+ map.put("md5", digests.getString("md5"));
+ }
+ return new Metadata(
+ new Headers(new ArrayList<>(headers)),
+ new ComputedDigests(size, map)
+ );
+ }
+ }
+
+ private Key metaKey(final Key key) {
+ return new Key.From(String.format("%s%s", key.string(), CachedArtifactMetadataStore.META_SUFFIX));
+ }
+
+ private static Headers ensureContentLength(final Headers headers, final long size) {
+ final Headers normalized = headers.copy();
+ final boolean present = normalized.values("Content-Length").stream()
+ .findFirst()
+ .isPresent();
+ if (!present) {
+ normalized.add("Content-Length", String.valueOf(size));
+ }
+ return normalized;
+ }
+
+ /**
+ * Computed artifact digests and size.
+ */
+ public static final class ComputedDigests {
+
+ /**
+ * Artifact size.
+ */
+ private final long size;
+
+ /**
+ * Digests map.
+ */
+ private final Map digests;
+
+ /**
+ * New computed digests.
+ *
+ * @param size Artifact size.
+ * @param digests Digests map.
+ */
+ public ComputedDigests(final long size, final Map digests) {
+ this.size = size;
+ this.digests = new HashMap<>(digests);
+ }
+
+ public long size() {
+ return this.size;
+ }
+
+ public Optional sha1() {
+ return Optional.ofNullable(this.digests.get("sha1"));
+ }
+
+ public Optional sha256() {
+ return Optional.ofNullable(this.digests.get("sha256"));
+ }
+
+ public Optional sha512() {
+ return Optional.ofNullable(this.digests.get("sha512"));
+ }
+
+ public Optional md5() {
+ return Optional.ofNullable(this.digests.get("md5"));
+ }
+ }
+
+ /**
+ * Cached metadata.
+ */
+ public static final class Metadata {
+
+ /**
+ * Response headers.
+ */
+ private final Headers headers;
+
+ /**
+ * Digests and size.
+ */
+ private final ComputedDigests digests;
+
+ Metadata(final Headers headers, final ComputedDigests digests) {
+ this.headers = headers;
+ this.digests = digests;
+ }
+
+ public Headers headers() {
+ return this.headers;
+ }
+
+ public ComputedDigests digests() {
+ return this.digests;
+ }
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/http/cache/NegativeCache.java b/artipie-core/src/main/java/com/artipie/http/cache/NegativeCache.java
new file mode 100644
index 000000000..a9d5ed0f3
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/http/cache/NegativeCache.java
@@ -0,0 +1,473 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.http.cache;
+
+import com.artipie.asto.Key;
+import com.artipie.cache.GlobalCacheConfig;
+import com.artipie.cache.NegativeCacheConfig;
+import com.artipie.cache.ValkeyConnection;
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import io.lettuce.core.api.async.RedisAsyncCommands;
+import java.time.Duration;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Caches 404 (Not Found) responses to avoid repeated upstream requests for missing artifacts.
+ * This is critical for proxy repositories to avoid hammering upstream repositories with
+ * requests for artifacts that don't exist (e.g., optional dependencies, typos).
+ *
+ * Thread-safe, high-performance cache using Caffeine with automatic TTL expiry.
+ *
+ * Performance impact: Eliminates 100% of repeated 404 requests, reducing load on both
+ * Artipie and upstream repositories.
+ *
+ * @since 0.11
+ */
+public final class NegativeCache {
+
+ /**
+ * Default TTL for negative cache (24 hours).
+ */
+ private static final Duration DEFAULT_TTL = Duration.ofHours(24);
+
+ /**
+ * Default maximum cache size (50,000 entries).
+ * At ~150 bytes per entry = ~7.5MB maximum memory usage.
+ */
+ private static final int DEFAULT_MAX_SIZE = 50_000;
+
+ /**
+ * Sentinel value for negative cache (we only care about presence, not value).
+ */
+ private static final Boolean CACHED = Boolean.TRUE;
+
+ /**
+ * L1 cache for 404 responses (in-memory, hot data).
+ * Thread-safe, high-performance, with automatic TTL expiry.
+ */
+ private final Cache notFoundCache;
+
+ /**
+ * L2 cache (Valkey/Redis, warm data) - optional.
+ */
+ private final RedisAsyncCommands l2;
+
+ /**
+ * Whether two-tier caching is enabled.
+ */
+ private final boolean twoTier;
+
+ /**
+ * Whether negative caching is enabled.
+ */
+ private final boolean enabled;
+
+ /**
+ * Cache TTL for L2.
+ */
+ private final Duration ttl;
+
+ /**
+ * Repository type for cache key namespacing.
+ */
+ private final String repoType;
+
+ /**
+ * Repository name for cache key isolation.
+ * Prevents cache collisions in group repositories.
+ */
+ private final String repoName;
+
+ /**
+ * Create negative cache using unified NegativeCacheConfig.
+ * @param repoType Repository type for cache key namespacing (e.g., "npm", "pypi", "go")
+ * @param repoName Repository name for cache key isolation
+ */
+ public NegativeCache(final String repoType, final String repoName) {
+ this(repoType, repoName, NegativeCacheConfig.getInstance());
+ }
+
+ /**
+ * Create negative cache with explicit config.
+ * @param repoType Repository type for cache key namespacing (e.g., "npm", "pypi", "go")
+ * @param repoName Repository name for cache key isolation
+ * @param config Unified negative cache configuration
+ */
+ public NegativeCache(final String repoType, final String repoName, final NegativeCacheConfig config) {
+ this(
+ config.l2Ttl(),
+ true,
+ config.isValkeyEnabled() ? config.l1MaxSize() : config.maxSize(),
+ config.isValkeyEnabled() ? config.l1Ttl() : config.ttl(),
+ GlobalCacheConfig.valkeyConnection()
+ .filter(v -> config.isValkeyEnabled())
+ .map(ValkeyConnection::async)
+ .orElse(null),
+ repoType,
+ repoName
+ );
+ }
+
+ /**
+ * Create negative cache with default 24h TTL and 50K max size (enabled).
+ * @deprecated Use {@link #NegativeCache(String, String)} instead
+ */
+ @Deprecated
+ public NegativeCache() {
+ this(DEFAULT_TTL, true, DEFAULT_MAX_SIZE, DEFAULT_TTL, null, "unknown", "default");
+ }
+
+ /**
+ * Create negative cache with Valkey connection (two-tier).
+ * @param valkey Valkey connection for L2 cache
+ * @deprecated Use {@link #NegativeCache(String, String, NegativeCacheConfig)} instead
+ */
+ @Deprecated
+ public NegativeCache(final ValkeyConnection valkey) {
+ this(
+ DEFAULT_TTL,
+ true,
+ valkey != null ? Math.max(1000, DEFAULT_MAX_SIZE / 10) : DEFAULT_MAX_SIZE,
+ valkey != null ? Duration.ofMinutes(5) : DEFAULT_TTL,
+ valkey != null ? valkey.async() : null,
+ "unknown",
+ "default"
+ );
+ }
+
+ /**
+ * Create negative cache with custom TTL and default max size.
+ * @param ttl Time-to-live for cached 404s
+ * @deprecated Use {@link #NegativeCache(String, String, NegativeCacheConfig)} instead
+ */
+ @Deprecated
+ public NegativeCache(final Duration ttl) {
+ this(ttl, true, DEFAULT_MAX_SIZE, ttl, null, "unknown", "default");
+ }
+
+ /**
+ * Create negative cache with custom TTL and enable flag.
+ * @param ttl Time-to-live for cached 404s
+ * @param enabled Whether negative caching is enabled
+ * @deprecated Use {@link #NegativeCache(String, String, NegativeCacheConfig)} instead
+ */
+ @Deprecated
+ public NegativeCache(final Duration ttl, final boolean enabled) {
+ this(ttl, enabled, DEFAULT_MAX_SIZE, ttl, null, "unknown", "default");
+ }
+
+ /**
+ * Create negative cache with custom TTL, enable flag, and max size.
+ * @param ttl Time-to-live for cached 404s
+ * @param enabled Whether negative caching is enabled
+ * @param maxSize Maximum number of entries (Window TinyLFU eviction)
+ * @param valkey Valkey connection for L2 cache (null uses GlobalCacheConfig)
+ * @deprecated Use {@link #NegativeCache(String, String, NegativeCacheConfig)} instead
+ */
+ @Deprecated
+ public NegativeCache(final Duration ttl, final boolean enabled, final int maxSize,
+ final ValkeyConnection valkey) {
+ this(
+ ttl,
+ enabled,
+ valkey != null ? Math.max(1000, maxSize / 10) : maxSize,
+ valkey != null ? Duration.ofMinutes(5) : ttl,
+ valkey != null ? valkey.async() : null,
+ "unknown",
+ "default"
+ );
+ }
+
+ /**
+ * Create negative cache with custom TTL, enable flag, max size, and repository name.
+ * @param ttl Time-to-live for cached 404s
+ * @param enabled Whether negative caching is enabled
+ * @param maxSize Maximum number of entries (Window TinyLFU eviction)
+ * @param valkey Valkey connection for L2 cache (null uses GlobalCacheConfig)
+ * @param repoName Repository name for cache key isolation
+ * @deprecated Use {@link #NegativeCache(String, String, NegativeCacheConfig)} instead
+ */
+ @Deprecated
+ public NegativeCache(final Duration ttl, final boolean enabled, final int maxSize,
+ final ValkeyConnection valkey, final String repoName) {
+ this(
+ ttl,
+ enabled,
+ valkey != null ? Math.max(1000, maxSize / 10) : maxSize,
+ valkey != null ? Duration.ofMinutes(5) : ttl,
+ valkey != null ? valkey.async() : null,
+ "unknown",
+ repoName
+ );
+ }
+
+ /**
+ * Create negative cache with custom TTL, enable flag, max size, repo type, and repository name.
+ * @param ttl Time-to-live for cached 404s
+ * @param enabled Whether negative caching is enabled
+ * @param maxSize Maximum number of entries (Window TinyLFU eviction)
+ * @param valkey Valkey connection for L2 cache (null uses GlobalCacheConfig)
+ * @param repoType Repository type for cache key namespacing (e.g., "npm", "pypi", "go")
+ * @param repoName Repository name for cache key isolation
+ * @deprecated Use {@link #NegativeCache(String, String, NegativeCacheConfig)} instead
+ */
+ @Deprecated
+ public NegativeCache(final Duration ttl, final boolean enabled, final int maxSize,
+ final ValkeyConnection valkey, final String repoType, final String repoName) {
+ this(
+ ttl,
+ enabled,
+ valkey != null ? Math.max(1000, maxSize / 10) : maxSize,
+ valkey != null ? Duration.ofMinutes(5) : ttl,
+ valkey != null ? valkey.async() : null,
+ repoType,
+ repoName
+ );
+ }
+
+ /**
+ * Primary constructor - all other constructors delegate to this one.
+ * @param ttl TTL for L2 cache
+ * @param enabled Whether negative caching is enabled
+ * @param l1MaxSize Maximum size for L1 cache
+ * @param l1Ttl TTL for L1 cache
+ * @param l2Commands Redis commands for L2 cache (null for single-tier)
+ * @param repoType Repository type for cache key namespacing
+ * @param repoName Repository name for cache key isolation
+ */
+ @SuppressWarnings("PMD.NullAssignment")
+ private NegativeCache(final Duration ttl, final boolean enabled, final int l1MaxSize,
+ final Duration l1Ttl, final RedisAsyncCommands l2Commands,
+ final String repoType, final String repoName) {
+ this.enabled = enabled;
+ this.twoTier = l2Commands != null;
+ this.l2 = l2Commands;
+ this.ttl = ttl;
+ this.repoType = repoType != null ? repoType : "unknown";
+ this.repoName = repoName != null ? repoName : "default";
+ this.notFoundCache = Caffeine.newBuilder()
+ .maximumSize(l1MaxSize)
+ .expireAfterWrite(l1Ttl.toMillis(), TimeUnit.MILLISECONDS)
+ .recordStats()
+ .build();
+ }
+
+ /**
+ * Check if key is in negative cache (known 404).
+ * Thread-safe - Caffeine handles synchronization.
+ * Caffeine automatically removes expired entries.
+ *
+ * PERFORMANCE: Only checks L1 cache to avoid blocking request thread.
+ * L2 queries happen asynchronously in background.
+ *
+ * @param key Key to check
+ * @return True if cached in L1 as not found
+ */
+ public boolean isNotFound(final Key key) {
+ if (!this.enabled) {
+ return false;
+ }
+
+ final long startNanos = System.nanoTime();
+ final boolean found = this.notFoundCache.getIfPresent(key) != null;
+
+ // Track L1 metrics
+ if (com.artipie.metrics.MicrometerMetrics.isInitialized()) {
+ final long durationMs = (System.nanoTime() - startNanos) / 1_000_000;
+ if (found) {
+ com.artipie.metrics.MicrometerMetrics.getInstance().recordCacheHit("negative", "l1");
+ com.artipie.metrics.MicrometerMetrics.getInstance().recordCacheOperationDuration("negative", "l1", "get", durationMs);
+ } else {
+ com.artipie.metrics.MicrometerMetrics.getInstance().recordCacheMiss("negative", "l1");
+ com.artipie.metrics.MicrometerMetrics.getInstance().recordCacheOperationDuration("negative", "l1", "get", durationMs);
+ }
+ }
+
+ return found;
+ }
+
+ /**
+ * Async check if key is in negative cache (known 404).
+ * Checks both L1 and L2, suitable for async callers.
+ *
+ * @param key Key to check
+ * @return Future with true if cached as not found
+ */
+ public CompletableFuture isNotFoundAsync(final Key key) {
+ if (!this.enabled) {
+ return CompletableFuture.completedFuture(false);
+ }
+
+ // Check L1 first
+ final long l1StartNanos = System.nanoTime();
+ if (this.notFoundCache.getIfPresent(key) != null) {
+ if (com.artipie.metrics.MicrometerMetrics.isInitialized()) {
+ final long durationMs = (System.nanoTime() - l1StartNanos) / 1_000_000;
+ com.artipie.metrics.MicrometerMetrics.getInstance().recordCacheHit("negative", "l1");
+ com.artipie.metrics.MicrometerMetrics.getInstance().recordCacheOperationDuration("negative", "l1", "get", durationMs);
+ }
+ return CompletableFuture.completedFuture(true);
+ }
+
+ // L1 MISS
+ if (com.artipie.metrics.MicrometerMetrics.isInitialized()) {
+ final long durationMs = (System.nanoTime() - l1StartNanos) / 1_000_000;
+ com.artipie.metrics.MicrometerMetrics.getInstance().recordCacheMiss("negative", "l1");
+ com.artipie.metrics.MicrometerMetrics.getInstance().recordCacheOperationDuration("negative", "l1", "get", durationMs);
+ }
+
+ // Check L2 if enabled
+ if (this.twoTier) {
+ final String redisKey = "negative:" + this.repoType + ":" + this.repoName + ":" + key.string();
+ final long l2StartNanos = System.nanoTime();
+
+ return this.l2.get(redisKey)
+ .toCompletableFuture()
+ .orTimeout(100, TimeUnit.MILLISECONDS)
+ .exceptionally(err -> {
+ // Track L2 error - metrics handled elsewhere
+ return null;
+ })
+ .thenApply(l2Bytes -> {
+ final long durationMs = (System.nanoTime() - l2StartNanos) / 1_000_000;
+
+ if (l2Bytes != null) {
+ // L2 HIT
+ if (com.artipie.metrics.MicrometerMetrics.isInitialized()) {
+ com.artipie.metrics.MicrometerMetrics.getInstance().recordCacheHit("negative", "l2");
+ com.artipie.metrics.MicrometerMetrics.getInstance().recordCacheOperationDuration("negative", "l2", "get", durationMs);
+ }
+ this.notFoundCache.put(key, CACHED);
+ return true;
+ }
+
+ // L2 MISS
+ if (com.artipie.metrics.MicrometerMetrics.isInitialized()) {
+ com.artipie.metrics.MicrometerMetrics.getInstance().recordCacheMiss("negative", "l2");
+ com.artipie.metrics.MicrometerMetrics.getInstance().recordCacheOperationDuration("negative", "l2", "get", durationMs);
+ }
+ return false;
+ });
+ }
+
+ return CompletableFuture.completedFuture(false);
+ }
+
+ /**
+ * Cache a key as not found (404).
+ * Thread-safe - Caffeine handles synchronization and eviction.
+ *
+ * @param key Key to cache as not found
+ */
+ public void cacheNotFound(final Key key) {
+ if (!this.enabled) {
+ return;
+ }
+
+ // Cache in L1
+ this.notFoundCache.put(key, CACHED);
+
+ // Cache in L2 (if enabled)
+ if (this.twoTier) {
+ final String redisKey = "negative:" + this.repoType + ":" + this.repoName + ":" + key.string();
+ final byte[] value = new byte[]{1}; // Sentinel value
+ final long seconds = this.ttl.getSeconds();
+ this.l2.setex(redisKey, seconds, value);
+ }
+ }
+
+ /**
+ * Invalidate specific entry (e.g., when artifact is deployed).
+ * Thread-safe - Caffeine handles synchronization.
+ *
+ * @param key Key to invalidate
+ */
+ public void invalidate(final Key key) {
+ // Invalidate L1
+ this.notFoundCache.invalidate(key);
+
+ // Invalidate L2 (if enabled)
+ if (this.twoTier) {
+ final String redisKey = "negative:" + this.repoType + ":" + this.repoName + ":" + key.string();
+ this.l2.del(redisKey);
+ }
+ }
+
+ /**
+ * Invalidate all entries matching a prefix pattern.
+ * Thread-safe - Caffeine handles synchronization.
+ *
+ * @param prefix Key prefix to match
+ */
+ public void invalidatePrefix(final String prefix) {
+ // Invalidate L1
+ this.notFoundCache.asMap().keySet().removeIf(key -> key.string().startsWith(prefix));
+
+ // Invalidate L2 (if enabled)
+ if (this.twoTier) {
+ final String scanPattern = "negative:" + this.repoType + ":" + this.repoName + ":" + prefix + "*";
+ this.l2.keys(scanPattern).thenAccept(keys -> {
+ if (keys != null && !keys.isEmpty()) {
+ this.l2.del(keys.toArray(new String[0]));
+ }
+ });
+ }
+ }
+
+ /**
+ * Clear entire cache.
+ * Thread-safe - Caffeine handles synchronization.
+ */
+ public void clear() {
+ // Clear L1
+ this.notFoundCache.invalidateAll();
+
+ // Clear L2 (if enabled) - scan and delete all negative cache keys
+ if (this.twoTier) {
+ this.l2.keys("negative:" + this.repoType + ":" + this.repoName + ":*").thenAccept(keys -> {
+ if (keys != null && !keys.isEmpty()) {
+ this.l2.del(keys.toArray(new String[0]));
+ }
+ });
+ }
+ }
+
+ /**
+ * Remove expired entries (periodic cleanup).
+ * Caffeine handles expiry automatically, but calling this
+ * triggers immediate cleanup instead of lazy removal.
+ */
+ public void cleanup() {
+ this.notFoundCache.cleanUp();
+ }
+
+ /**
+ * Get current cache size.
+ * Thread-safe - Caffeine handles synchronization.
+ * @return Number of entries in cache
+ */
+ public long size() {
+ return this.notFoundCache.estimatedSize();
+ }
+
+ /**
+ * Get cache statistics from Caffeine.
+ * Includes hit rate, miss rate, eviction count, etc.
+ * @return Caffeine cache statistics
+ */
+ public com.github.benmanes.caffeine.cache.stats.CacheStats stats() {
+ return this.notFoundCache.stats();
+ }
+
+ /**
+ * Check if negative caching is enabled.
+ * @return True if enabled
+ */
+ public boolean isEnabled() {
+ return this.enabled;
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/http/cache/ProxyCacheConfig.java b/artipie-core/src/main/java/com/artipie/http/cache/ProxyCacheConfig.java
new file mode 100644
index 000000000..d7f034503
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/http/cache/ProxyCacheConfig.java
@@ -0,0 +1,177 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.http.cache;
+
+import com.amihaiemil.eoyaml.YamlMapping;
+import java.time.Duration;
+import java.util.Optional;
+
+/**
+ * Proxy cache configuration parser for YAML.
+ * Parses cache settings for negative caching and metadata caching.
+ *
+ * Example YAML:
+ *
+ * cache:
+ * negative:
+ * enabled: true
+ * ttl: PT24H # ISO-8601 duration (24 hours)
+ * maxSize: 50000 # Maximum entries
+ * metadata:
+ * enabled: true
+ * ttl: PT168H # 7 days
+ *
+ *
+ * @since 1.0
+ */
+public final class ProxyCacheConfig {
+
+ /**
+ * Default negative cache TTL (24 hours).
+ */
+ public static final Duration DEFAULT_NEGATIVE_TTL = Duration.ofHours(24);
+
+ /**
+ * Default negative cache max size (50,000 entries ~7.5MB).
+ */
+ public static final int DEFAULT_NEGATIVE_MAX_SIZE = 50_000;
+
+ /**
+ * Default metadata cache TTL (7 days).
+ */
+ public static final Duration DEFAULT_METADATA_TTL = Duration.ofDays(7);
+
+ /**
+ * YAML configuration.
+ */
+ private final YamlMapping yaml;
+
+ /**
+ * Ctor.
+ * @param yaml YAML configuration mapping
+ */
+ public ProxyCacheConfig(final YamlMapping yaml) {
+ this.yaml = yaml;
+ }
+
+ /**
+ * Check if negative caching is enabled.
+ * @return True if enabled (default: true)
+ */
+ public boolean negativeCacheEnabled() {
+ return this.boolValue("cache", "negative", "enabled").orElse(true);
+ }
+
+ /**
+ * Get negative cache TTL.
+ * @return TTL duration (default: 24 hours)
+ */
+ public Duration negativeCacheTtl() {
+ return this.durationValue("cache", "negative", "ttl")
+ .orElse(DEFAULT_NEGATIVE_TTL);
+ }
+
+ /**
+ * Get negative cache max size.
+ * @return Max entries (default: 50,000)
+ */
+ public int negativeCacheMaxSize() {
+ return this.intValue("cache", "negative", "maxSize")
+ .orElse(DEFAULT_NEGATIVE_MAX_SIZE);
+ }
+
+ /**
+ * Check if metadata caching is enabled.
+ * @return True if enabled (default: false - needs implementation)
+ */
+ public boolean metadataCacheEnabled() {
+ return this.boolValue("cache", "metadata", "enabled").orElse(false);
+ }
+
+ /**
+ * Get metadata cache TTL.
+ * @return TTL duration (default: 7 days)
+ */
+ public Duration metadataCacheTtl() {
+ return this.durationValue("cache", "metadata", "ttl")
+ .orElse(DEFAULT_METADATA_TTL);
+ }
+
+ /**
+ * Check if any caching is configured.
+ * @return True if cache section exists
+ */
+ public boolean hasCacheConfig() {
+ return this.yaml.yamlMapping("cache") != null;
+ }
+
+ /**
+ * Get boolean value from nested YAML path.
+ * @param path YAML path segments
+ * @return Optional boolean value
+ */
+ private Optional boolValue(final String... path) {
+ YamlMapping current = this.yaml;
+ for (int i = 0; i < path.length - 1; i++) {
+ current = current.yamlMapping(path[i]);
+ if (current == null) {
+ return Optional.empty();
+ }
+ }
+ final String value = current.string(path[path.length - 1]);
+ return value == null ? Optional.empty() : Optional.of(Boolean.parseBoolean(value));
+ }
+
+ /**
+ * Get integer value from nested YAML path.
+ * @param path YAML path segments
+ * @return Optional integer value
+ */
+ private Optional intValue(final String... path) {
+ YamlMapping current = this.yaml;
+ for (int i = 0; i < path.length - 1; i++) {
+ current = current.yamlMapping(path[i]);
+ if (current == null) {
+ return Optional.empty();
+ }
+ }
+ final String value = current.string(path[path.length - 1]);
+ try {
+ return value == null ? Optional.empty() : Optional.of(Integer.parseInt(value));
+ } catch (final NumberFormatException ex) {
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * Get duration value from nested YAML path.
+ * Supports ISO-8601 duration format (e.g., PT24H, P1D).
+ * @param path YAML path segments
+ * @return Optional duration value
+ */
+ private Optional durationValue(final String... path) {
+ YamlMapping current = this.yaml;
+ for (int i = 0; i < path.length - 1; i++) {
+ current = current.yamlMapping(path[i]);
+ if (current == null) {
+ return Optional.empty();
+ }
+ }
+ final String value = current.string(path[path.length - 1]);
+ try {
+ return value == null ? Optional.empty() : Optional.of(Duration.parse(value));
+ } catch (final Exception ex) {
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * Create default configuration (all caching enabled with defaults).
+ * @return Default configuration
+ */
+ public static ProxyCacheConfig defaults() {
+ return new ProxyCacheConfig(com.amihaiemil.eoyaml.Yaml.createYamlMappingBuilder().build());
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/http/filter/Filter.java b/artipie-core/src/main/java/com/artipie/http/filter/Filter.java
index b6a025d5c..2ae6d2616 100644
--- a/artipie-core/src/main/java/com/artipie/http/filter/Filter.java
+++ b/artipie-core/src/main/java/com/artipie/http/filter/Filter.java
@@ -5,13 +5,14 @@
package com.artipie.http.filter;
import com.amihaiemil.eoyaml.YamlMapping;
-import com.artipie.http.rq.RequestLineFrom;
-import java.util.Map;
+import com.artipie.http.Headers;
+import com.artipie.http.rq.RequestLine;
+
import java.util.Optional;
/**
* Repository content filter.
- *
+ *
* Yaml format:
*
* priority: priority_value
@@ -19,8 +20,6 @@
* where
* 'priority_value' is optional and provides priority value. Default value is zero priority.
*
- *
- * @since 1.2
*/
public abstract class Filter {
/**
@@ -36,7 +35,6 @@ public abstract class Filter {
/**
* Priority.
*/
- @SuppressWarnings("PMD.AvoidFieldNameMatchingMethodName")
private final int priority;
/**
@@ -65,23 +63,20 @@ public int priority() {
* @param headers Request headers.
* @return True if request matched to access conditions.
*/
- public abstract boolean check(RequestLineFrom line,
- Iterable> headers);
+ public abstract boolean check(RequestLine line, Headers headers);
/**
* Wrap is a decorative wrapper for Filter.
*
* @since 0.7
*/
- public abstract class Wrap extends Filter {
+ public abstract static class Wrap extends Filter {
/**
* Origin filter.
*/
private final Filter filter;
/**
- * Ctor.
- *
* @param filter Filter.
* @param yaml Yaml mapping
*/
@@ -90,7 +85,6 @@ public Wrap(final Filter filter, final YamlMapping yaml) {
this.filter = filter;
}
- @Override
/**
* Checks conditions to get access to repository content.
*
@@ -98,8 +92,8 @@ public Wrap(final Filter filter, final YamlMapping yaml) {
* @param headers Request headers.
* @return True if request matched to access conditions.
*/
- public boolean check(final RequestLineFrom line,
- final Iterable> headers) {
+ @Override
+ public boolean check(RequestLine line, Headers headers) {
return this.filter.check(line, headers);
}
}
diff --git a/artipie-core/src/main/java/com/artipie/http/filter/FilterFactoryLoader.java b/artipie-core/src/main/java/com/artipie/http/filter/FilterFactoryLoader.java
index 7f766a0e5..a63c65c33 100644
--- a/artipie-core/src/main/java/com/artipie/http/filter/FilterFactoryLoader.java
+++ b/artipie-core/src/main/java/com/artipie/http/filter/FilterFactoryLoader.java
@@ -75,7 +75,6 @@ public String getFactoryName(final Class> clazz) {
.map(inst -> ((ArtipieFilterFactory) inst).value())
.findFirst()
.orElseThrow(
- // @checkstyle LineLengthCheck (1 lines)
() -> new ArtipieException(
String.format(
"Annotation '%s' should have a not empty value",
diff --git a/artipie-core/src/main/java/com/artipie/http/filter/FilterSlice.java b/artipie-core/src/main/java/com/artipie/http/filter/FilterSlice.java
index 1848898a7..ab830f61e 100644
--- a/artipie-core/src/main/java/com/artipie/http/filter/FilterSlice.java
+++ b/artipie-core/src/main/java/com/artipie/http/filter/FilterSlice.java
@@ -5,24 +5,22 @@
package com.artipie.http.filter;
import com.amihaiemil.eoyaml.YamlMapping;
+import com.artipie.asto.Content;
+import com.artipie.http.Headers;
import com.artipie.http.Response;
import com.artipie.http.Slice;
-import com.artipie.http.rs.RsStatus;
-import com.artipie.http.rs.RsWithStatus;
-import java.nio.ByteBuffer;
-import java.util.Map;
+import com.artipie.http.rq.RequestLine;
+import com.artipie.http.ResponseBuilder;
+
import java.util.Objects;
import java.util.Optional;
-import org.reactivestreams.Publisher;
+import java.util.concurrent.CompletableFuture;
/**
* Slice that filters content of repository.
- * @since 1.2
*/
public class FilterSlice implements Slice {
- /**
- * Origin slice.
- */
+
private final Slice origin;
/**
@@ -31,22 +29,19 @@ public class FilterSlice implements Slice {
private final Filters filters;
/**
- * Ctor.
* @param origin Origin slice
* @param yaml Yaml mapping to read filters from
- * @checkstyle HiddenFieldCheck (10 lines)
*/
public FilterSlice(final Slice origin, final YamlMapping yaml) {
this(
origin,
Optional.of(yaml.yamlMapping("filters"))
- .map(filters -> new Filters(filters))
+ .map(Filters::new)
.get()
);
}
/**
- * Ctor.
* @param origin Origin slice
* @param filters Filters
*/
@@ -56,16 +51,15 @@ public FilterSlice(final Slice origin, final Filters filters) {
}
@Override
- public final Response response(
- final String line,
- final Iterable> headers,
- final Publisher body) {
- final Response response;
+ public final CompletableFuture response(
+ RequestLine line, Headers headers, Content body
+ ) {
if (this.filters.allowed(line, headers)) {
- response = this.origin.response(line, headers, body);
- } else {
- response = new RsWithStatus(RsStatus.FORBIDDEN);
+ return this.origin.response(line, headers, body);
}
- return response;
+ // Consume request body to prevent Vert.x request leak
+ return body.asBytesFuture().thenApply(ignored ->
+ ResponseBuilder.forbidden().build()
+ );
}
}
diff --git a/artipie-core/src/main/java/com/artipie/http/filter/Filters.java b/artipie-core/src/main/java/com/artipie/http/filter/Filters.java
index ec02bc3a3..f035348b1 100644
--- a/artipie-core/src/main/java/com/artipie/http/filter/Filters.java
+++ b/artipie-core/src/main/java/com/artipie/http/filter/Filters.java
@@ -6,25 +6,24 @@
import com.amihaiemil.eoyaml.YamlMapping;
import com.amihaiemil.eoyaml.YamlNode;
-import com.artipie.http.rq.RequestLineFrom;
+import com.artipie.http.Headers;
+import com.artipie.http.rq.RequestLine;
+
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
-import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Filters.
- *
+ *
* Yaml format:
*
* include: yaml-sequence of including filters
* exclude: yaml-sequence of excluding filters
*
-
- * @since 1.2
*/
public final class Filters {
/**
@@ -43,7 +42,6 @@ public final class Filters {
private final List excludes;
/**
- * Ctor.
* @param yaml Yaml mapping to read filters from
*/
public Filters(final YamlMapping yaml) {
@@ -57,13 +55,11 @@ public Filters(final YamlMapping yaml) {
* @param headers Request headers.
* @return True if is allowed to get access to repository content.
*/
- public boolean allowed(final String line,
- final Iterable> headers) {
- final RequestLineFrom rqline = new RequestLineFrom(line);
+ public boolean allowed(RequestLine line, Headers headers) {
final boolean included = this.includes.stream()
- .anyMatch(filter -> filter.check(rqline, headers));
+ .anyMatch(filter -> filter.check(line, headers));
final boolean excluded = this.excludes.stream()
- .anyMatch(filter -> filter.check(rqline, headers));
+ .anyMatch(filter -> filter.check(line, headers));
return included & !excluded;
}
diff --git a/artipie-core/src/main/java/com/artipie/http/filter/GlobFilter.java b/artipie-core/src/main/java/com/artipie/http/filter/GlobFilter.java
index 948bf7a06..d501ca5d6 100644
--- a/artipie-core/src/main/java/com/artipie/http/filter/GlobFilter.java
+++ b/artipie-core/src/main/java/com/artipie/http/filter/GlobFilter.java
@@ -5,18 +5,17 @@
package com.artipie.http.filter;
import com.amihaiemil.eoyaml.YamlMapping;
-import com.artipie.http.rq.RequestLineFrom;
+import com.artipie.http.Headers;
+import com.artipie.http.rq.RequestLine;
+
import java.nio.file.FileSystems;
import java.nio.file.PathMatcher;
import java.nio.file.Paths;
-import java.util.Map;
/**
* Glob repository filter.
- *
- * Uses path part of request for matching.
- *
- * Yaml format:
+ *Uses path part of request for matching.
+ *
Yaml format:
*
* filter: expression
* priority: priority_value
@@ -25,18 +24,12 @@
* 'filter' is mandatory and value contains globbing expression for request path matching.
* 'priority_value' is optional and provides priority value. Default value is zero priority.
*
- *
- * @since 1.2
*/
public final class GlobFilter extends Filter {
- /**
- * Path matcher.
- */
+
private final PathMatcher matcher;
/**
- * Ctor.
- *
* @param yaml Yaml mapping to read filters from
*/
public GlobFilter(final YamlMapping yaml) {
@@ -47,8 +40,7 @@ public GlobFilter(final YamlMapping yaml) {
}
@Override
- public boolean check(final RequestLineFrom line,
- final Iterable> headers) {
+ public boolean check(RequestLine line, Headers headers) {
return this.matcher.matches(Paths.get(line.uri().getPath()));
}
}
diff --git a/artipie-core/src/main/java/com/artipie/http/filter/RegexpFilter.java b/artipie-core/src/main/java/com/artipie/http/filter/RegexpFilter.java
index 4894a8238..54a7398c3 100644
--- a/artipie-core/src/main/java/com/artipie/http/filter/RegexpFilter.java
+++ b/artipie-core/src/main/java/com/artipie/http/filter/RegexpFilter.java
@@ -5,15 +5,16 @@
package com.artipie.http.filter;
import com.amihaiemil.eoyaml.YamlMapping;
-import com.artipie.http.rq.RequestLineFrom;
-import java.util.Map;
+import com.artipie.http.Headers;
+import com.artipie.http.rq.RequestLine;
+
import java.util.regex.Pattern;
/**
* RegExp repository filter.
- *
+ *
* Uses path part of request or full uri for matching.
- *
+ *
* Yaml format:
*
* filter: regular_expression
@@ -29,8 +30,6 @@
* 'case_insensitive' is optional with default value 'false'
* and implies to ignore case in regular expression matching.
*
- *
- * @since 1.2
*/
public final class RegexpFilter extends Filter {
/**
@@ -44,13 +43,8 @@ public final class RegexpFilter extends Filter {
private final boolean fulluri;
/**
- * Ctor.
- *
* @param yaml Yaml mapping to read filters from
*/
- @SuppressWarnings(
- {"PMD.ConstructorOnlyInitializesOrCallOtherConstructors", "PMD.AvoidDuplicateLiterals"}
- )
public RegexpFilter(final YamlMapping yaml) {
super(yaml);
this.fulluri = Boolean.parseBoolean(yaml.string("full_uri"));
@@ -62,8 +56,7 @@ public RegexpFilter(final YamlMapping yaml) {
}
@Override
- public boolean check(final RequestLineFrom line,
- final Iterable> headers) {
+ public boolean check(RequestLine line, Headers headers) {
final boolean res;
if (this.fulluri) {
res = this.pattern.matcher(line.uri().toString()).matches();
diff --git a/artipie-core/src/main/java/com/artipie/http/group/GroupConnection.java b/artipie-core/src/main/java/com/artipie/http/group/GroupConnection.java
deleted file mode 100644
index c20bae759..000000000
--- a/artipie-core/src/main/java/com/artipie/http/group/GroupConnection.java
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
- * https://github.com/artipie/artipie/blob/master/LICENSE.txt
- */
-package com.artipie.http.group;
-
-import com.artipie.http.Connection;
-import com.artipie.http.Headers;
-import com.artipie.http.rs.RsStatus;
-import java.nio.ByteBuffer;
-import java.util.concurrent.CompletionStage;
-import org.reactivestreams.Publisher;
-
-/**
- * One remote target connection.
- *
- * @since 0.11
- */
-final class GroupConnection implements Connection {
-
- /**
- * Origin connection.
- */
- private final Connection origin;
-
- /**
- * Target order.
- */
- private final int pos;
-
- /**
- * Response results.
- */
- private final GroupResults results;
-
- /**
- * New connection for one target.
- * @param origin Origin connection
- * @param pos Order
- * @param results Results
- */
- GroupConnection(final Connection origin, final int pos, final GroupResults results) {
- this.origin = origin;
- this.pos = pos;
- this.results = results;
- }
-
- @Override
- public CompletionStage accept(final RsStatus status, final Headers headers,
- final Publisher body) {
- synchronized (this.results) {
- return this.results.complete(
- this.pos, new GroupResult(status, headers, body), this.origin
- );
- }
- }
-}
diff --git a/artipie-core/src/main/java/com/artipie/http/group/GroupResponse.java b/artipie-core/src/main/java/com/artipie/http/group/GroupResponse.java
deleted file mode 100644
index b78dcd6bf..000000000
--- a/artipie-core/src/main/java/com/artipie/http/group/GroupResponse.java
+++ /dev/null
@@ -1,61 +0,0 @@
-/*
- * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
- * https://github.com/artipie/artipie/blob/master/LICENSE.txt
- */
-package com.artipie.http.group;
-
-import com.artipie.http.Connection;
-import com.artipie.http.Response;
-import com.artipie.http.rs.RsStatus;
-import com.artipie.http.rs.RsWithStatus;
-import java.util.List;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.CompletionStage;
-import java.util.stream.Collectors;
-
-/**
- * Group response.
- *
- * The list of responses which can be send to connection by specified order.
- *
- * @since 0.11
- */
-final class GroupResponse implements Response {
-
- /**
- * Responses.
- */
- private final List responses;
-
- /**
- * New group response.
- * @param responses Responses to group
- */
- GroupResponse(final List responses) {
- this.responses = responses;
- }
-
- @Override
- public CompletionStage send(final Connection con) {
- final CompletableFuture future = new CompletableFuture<>();
- final GroupResults results = new GroupResults(this.responses.size(), future);
- for (int pos = 0; pos < this.responses.size(); ++pos) {
- final GroupConnection connection = new GroupConnection(con, pos, results);
- this.responses.get(pos)
- .send(connection)
- .>thenApply(CompletableFuture::completedFuture)
- .exceptionally(
- throwable -> new RsWithStatus(RsStatus.INTERNAL_ERROR).send(connection)
- );
- }
- return future;
- }
-
- @Override
- public String toString() {
- return String.format(
- "%s: [%s]", this.getClass().getSimpleName(),
- this.responses.stream().map(Object::toString).collect(Collectors.joining(", "))
- );
- }
-}
diff --git a/artipie-core/src/main/java/com/artipie/http/group/GroupResult.java b/artipie-core/src/main/java/com/artipie/http/group/GroupResult.java
deleted file mode 100644
index ea91f7c10..000000000
--- a/artipie-core/src/main/java/com/artipie/http/group/GroupResult.java
+++ /dev/null
@@ -1,121 +0,0 @@
-/*
- * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
- * https://github.com/artipie/artipie/blob/master/LICENSE.txt
- */
-package com.artipie.http.group;
-
-import com.artipie.http.Connection;
-import com.artipie.http.Headers;
-import com.artipie.http.rs.RsStatus;
-import java.nio.ByteBuffer;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.CompletionStage;
-import java.util.concurrent.atomic.AtomicBoolean;
-import org.reactivestreams.Publisher;
-import org.reactivestreams.Subscriber;
-import org.reactivestreams.Subscription;
-
-/**
- * Response result.
- *
- * The result of {@link GroupResponse}, it's waiting in order for all previous responses
- * to be completed, and may be replied to connection or cancelled.
- *
- * @since 0.11
- */
-final class GroupResult {
-
- /**
- * Subscriber which cancel publisher subscription.
- * @checkstyle AnonInnerLengthCheck (25 lines)
- */
- private static final Subscriber super Object> CANCEL_SUB = new Subscriber() {
- @Override
- public void onSubscribe(final Subscription sub) {
- sub.cancel();
- }
-
- @Override
- public void onNext(final Object obj) {
- // nothing to do
- }
-
- @Override
- public void onError(final Throwable err) {
- // nothing to do
- }
-
- @Override
- public void onComplete() {
- // nothing to do
- }
- };
-
- /**
- * Response status.
- */
- private final RsStatus status;
-
- /**
- * Response headers.
- */
- private final Headers headers;
-
- /**
- * Body publisher.
- */
- private final Publisher body;
-
- /**
- * Completed flag.
- */
- private final AtomicBoolean completed;
-
- /**
- * New response result.
- * @param status Response status
- * @param headers Response headers
- * @param body Body publisher
- */
- GroupResult(final RsStatus status, final Headers headers,
- final Publisher body) {
- this.status = status;
- this.headers = headers;
- this.body = body;
- this.completed = new AtomicBoolean();
- }
-
- /**
- * Replay response to connection.
- * @param con Connection
- * @return Future
- */
- public CompletionStage replay(final Connection con) {
- final CompletionStage res;
- if (this.completed.compareAndSet(false, true)) {
- res = con.accept(this.status, this.headers, this.body);
- } else {
- res = CompletableFuture.completedFuture(null);
- }
- return res;
- }
-
- /**
- * Check if response was successes.
- * @return True if success
- */
- public boolean success() {
- final int code = Integer.parseInt(this.status.code());
- // @checkstyle MagicNumberCheck (1 line)
- return code >= 200 && code < 300;
- }
-
- /**
- * Cancel response body stream.
- */
- void cancel() {
- if (this.completed.compareAndSet(false, true)) {
- this.body.subscribe(GroupResult.CANCEL_SUB);
- }
- }
-}
diff --git a/artipie-core/src/main/java/com/artipie/http/group/GroupResults.java b/artipie-core/src/main/java/com/artipie/http/group/GroupResults.java
deleted file mode 100644
index 091e6ea1e..000000000
--- a/artipie-core/src/main/java/com/artipie/http/group/GroupResults.java
+++ /dev/null
@@ -1,90 +0,0 @@
-/*
- * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
- * https://github.com/artipie/artipie/blob/master/LICENSE.txt
- */
-package com.artipie.http.group;
-
-import com.artipie.http.Connection;
-import com.artipie.http.rs.StandardRs;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Objects;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.CompletionStage;
-
-/**
- * Group response results aggregator.
- * @implNote This class is not thread safe and should be synchronized
- * @since 0.11
- */
-final class GroupResults {
-
- /**
- * List of results.
- */
- private final List list;
-
- /**
- * Completion future.
- */
- private final CompletableFuture future;
-
- /**
- * New results aggregator.
- * @param cap Capacity
- * @param future Future to complete when all results are done
- */
- GroupResults(final int cap, final CompletableFuture future) {
- this(new ArrayList<>(Collections.nCopies(cap, null)), future);
- }
-
- /**
- * Primary constructor.
- * @param list List of results
- * @param future Future to complete when all results are done
- */
- private GroupResults(final List list, final CompletableFuture future) {
- this.list = list;
- this.future = future;
- }
-
- /**
- * Complete results.
- *
- * This method checks if the response can be completed. If the result was succeed and
- * all previous ordered results were completed and failed, then the whole response will
- * be replied to the {@link Connection}. If any previous results is not completed, then
- * this result will be placed in the list to wait all previous results.
- *
- * @param order Order of result
- * @param result Repayable result
- * @param con Connection to use for replay
- * @return Future
- * @checkstyle ReturnCountCheck (25 lines)
- */
- @SuppressWarnings("PMD.OnlyOneReturn")
- public CompletionStage complete(final int order, final GroupResult result,
- final Connection con) {
- if (this.future.isDone()) {
- result.cancel();
- return CompletableFuture.completedFuture(null);
- }
- if (order >= this.list.size()) {
- throw new IllegalStateException("Wrong order of result");
- }
- this.list.set(order, result);
- for (int pos = 0; pos < this.list.size(); ++pos) {
- final GroupResult target = this.list.get(pos);
- if (target == null) {
- return CompletableFuture.completedFuture(null);
- }
- if (target.success()) {
- return target.replay(con).thenRun(
- () -> this.list.stream().filter(Objects::nonNull).forEach(GroupResult::cancel)
- ).thenRun(() -> this.future.complete(null));
- }
- }
- return StandardRs.NOT_FOUND.send(con).thenRun(() -> this.future.complete(null));
- }
-}
diff --git a/artipie-core/src/main/java/com/artipie/http/group/GroupSlice.java b/artipie-core/src/main/java/com/artipie/http/group/GroupSlice.java
index 3b1d7f638..535a7df20 100644
--- a/artipie-core/src/main/java/com/artipie/http/group/GroupSlice.java
+++ b/artipie-core/src/main/java/com/artipie/http/group/GroupSlice.java
@@ -4,38 +4,26 @@
*/
package com.artipie.http.group;
+import com.artipie.asto.Content;
+import com.artipie.http.Headers;
+import com.artipie.http.ResponseBuilder;
import com.artipie.http.Response;
import com.artipie.http.Slice;
-import com.artipie.http.rq.RequestLineFrom;
-import com.artipie.http.rq.RqMethod;
-import java.nio.ByteBuffer;
+import com.artipie.http.rq.RequestLine;
+import com.artipie.http.RsStatus;
+import com.artipie.http.log.EcsLogger;
+
import java.util.Arrays;
import java.util.Collections;
-import java.util.HashSet;
import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.stream.Collectors;
-import org.reactivestreams.Publisher;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
/**
* Standard group {@link Slice} implementation.
- *
- * @since 0.11
*/
public final class GroupSlice implements Slice {
- /**
- * Methods to broadcast to all target slices.
- */
- private static final Set BROADCAST_METHODS = Collections.unmodifiableSet(
- new HashSet<>(
- Arrays.asList(
- RqMethod.GET, RqMethod.HEAD, RqMethod.OPTIONS, RqMethod.CONNECT, RqMethod.TRACE
- )
- )
- );
-
/**
* Target slices.
*/
@@ -58,19 +46,116 @@ public GroupSlice(final List targets) {
}
@Override
- public Response response(final String line, final Iterable> headers,
- final Publisher body) {
- final Response rsp;
- final RqMethod method = new RequestLineFrom(line).method();
- if (GroupSlice.BROADCAST_METHODS.contains(method)) {
- rsp = new GroupResponse(
- this.targets.stream()
- .map(slice -> slice.response(line, headers, body))
- .collect(Collectors.toList())
+ public CompletableFuture response(
+ RequestLine line, Headers headers, Content body
+ ) {
+ // Parallel race strategy:
+ // try all repositories simultaneously and return the first successful
+ // response (non-404). This reduces latency when the artifact is only
+ // available in later repositories.
+
+ if (this.targets.isEmpty()) {
+ // Consume request body to prevent Vert.x request leak
+ return body.asBytesFuture().thenApply(ignored ->
+ ResponseBuilder.notFound().build()
);
- } else {
- rsp = this.targets.get(0).response(line, headers, body);
}
- return rsp;
+
+ // Consume the original request body once and create fresh
+ // {@link Content} instances for each member. This is required for
+ // POST requests (such as npm audit), where the body must be forwarded
+ // to all members. For GET/HEAD requests the body is typically empty,
+ // so the additional buffering is negligible.
+ return body.asBytesFuture().thenCompose(requestBytes -> {
+ // Create a result future
+ final CompletableFuture result = new CompletableFuture<>();
+
+ // Track how many repos have responded with 404/error
+ final java.util.concurrent.atomic.AtomicInteger failedCount =
+ new java.util.concurrent.atomic.AtomicInteger(0);
+
+ // Start all repository requests in parallel
+ for (int i = 0; i < this.targets.size(); i++) {
+ final int index = i;
+ final Slice target = this.targets.get(i);
+
+ // Create a fresh Content instance for each member from buffered bytes
+ final Content memberBody = requestBytes.length == 0
+ ? Content.EMPTY
+ : new Content.From(requestBytes);
+
+ EcsLogger.debug("com.artipie.http")
+ .message("Sending request to target (index: " + index + ")")
+ .eventCategory("http")
+ .eventAction("group_race")
+ .eventOutcome("pending")
+ .field("url.path", line.uri().getPath())
+ .log();
+ target.response(line, headers, memberBody)
+ .thenCompose(res -> {
+ // If result already completed (someone else won), consume and discard
+ if (result.isDone()) {
+ EcsLogger.debug("com.artipie.http")
+ .message("Repository response arrived after race completed (index: " + index + ")")
+ .eventCategory("http")
+ .eventAction("group_race")
+ .eventOutcome("late")
+ .log();
+ // Consume body even if this response lost the race to
+ // avoid leaking underlying HTTP resources.
+ return res.body().asBytesFuture().thenApply(ignored -> null);
+ }
+
+ if (res.status() == RsStatus.NOT_FOUND) {
+ EcsLogger.debug("com.artipie.http")
+ .message("Repository returned 404 (index: " + index + ")")
+ .eventCategory("http")
+ .eventAction("group_race")
+ .eventOutcome("not_found")
+ .log();
+ // Consume 404 response bodies as well to avoid leaks.
+ return res.body().asBytesFuture().thenApply(ignored -> {
+ if (failedCount.incrementAndGet() == this.targets.size()) {
+ // All repos returned 404, return 404
+ result.complete(ResponseBuilder.notFound().build());
+ }
+ return null;
+ });
+ }
+
+ // SUCCESS! This repo has the artifact
+ // Complete the result (first success wins) - don't consume body, it will be served
+ EcsLogger.debug("com.artipie.http")
+ .message("Repository found artifact (index: " + index + ")")
+ .eventCategory("http")
+ .eventAction("group_race")
+ .eventOutcome("success")
+ .field("http.response.status_code", res.status().code())
+ .log();
+ result.complete(res);
+ return CompletableFuture.completedFuture(null);
+ })
+ .exceptionally(err -> {
+ if (result.isDone()) {
+ return null;
+ }
+ EcsLogger.warn("com.artipie.http")
+ .message("Failed to get response from repository (index: " + index + ")")
+ .eventCategory("http")
+ .eventAction("group_race")
+ .eventOutcome("failure")
+ .error(err)
+ .log();
+ // Count this as a failure
+ if (failedCount.incrementAndGet() == this.targets.size()) {
+ // All repos failed, return 404
+ result.complete(ResponseBuilder.notFound().build());
+ }
+ return null;
+ });
+ }
+
+ return result;
+ });
}
}
diff --git a/artipie-core/src/main/java/com/artipie/http/headers/Accept.java b/artipie-core/src/main/java/com/artipie/http/headers/Accept.java
index 2de1e8741..e98c1ff13 100644
--- a/artipie-core/src/main/java/com/artipie/http/headers/Accept.java
+++ b/artipie-core/src/main/java/com/artipie/http/headers/Accept.java
@@ -4,12 +4,13 @@
*/
package com.artipie.http.headers;
+import com.artipie.http.Headers;
import com.artipie.http.rq.RqHeaders;
+import wtf.g4s8.mime.MimeType;
+
import java.util.Collections;
import java.util.List;
-import java.util.Map;
import java.util.stream.Collectors;
-import wtf.g4s8.mime.MimeType;
/**
* Accept header, check
@@ -28,13 +29,13 @@ public final class Accept {
/**
* Headers.
*/
- private final Iterable> headers;
+ private final Headers headers;
/**
* Ctor.
* @param headers Headers to extract `accept` header from
*/
- public Accept(final Iterable> headers) {
+ public Accept(Headers headers) {
this.headers = headers;
}
@@ -42,9 +43,7 @@ public Accept(final Iterable> headers) {
* Parses `Accept` header values, sorts them according to weight and returns in
* corresponding order.
* @return Set or the values
- * @checkstyle ReturnCountCheck (11 lines)
*/
- @SuppressWarnings("PMD.OnlyOneReturn")
public List values() {
final RqHeaders rqh = new RqHeaders(this.headers, Accept.NAME);
if (rqh.size() == 0) {
diff --git a/artipie-core/src/main/java/com/artipie/http/headers/Authorization.java b/artipie-core/src/main/java/com/artipie/http/headers/Authorization.java
index ad08ee950..e97c99d23 100644
--- a/artipie-core/src/main/java/com/artipie/http/headers/Authorization.java
+++ b/artipie-core/src/main/java/com/artipie/http/headers/Authorization.java
@@ -8,6 +8,7 @@
import com.artipie.http.auth.BasicAuthScheme;
import com.artipie.http.auth.BearerAuthScheme;
import com.artipie.http.rq.RqHeaders;
+
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.regex.Matcher;
@@ -15,10 +16,8 @@
/**
* Authorization header.
- *
- * @since 0.12
*/
-public final class Authorization extends Header.Wrap {
+public final class Authorization extends Header {
/**
* Header name.
@@ -31,8 +30,6 @@ public final class Authorization extends Header.Wrap {
private static final Pattern VALUE = Pattern.compile("(?[^ ]+) (?.+)");
/**
- * Ctor.
- *
* @param scheme Authentication scheme.
* @param credentials Credentials.
*/
@@ -50,8 +47,6 @@ public Authorization(final String value) {
}
/**
- * Ctor.
- *
* @param headers Headers to extract header from.
*/
public Authorization(final Headers headers) {
@@ -97,11 +92,9 @@ private Matcher matcher() {
*
* @since 0.12
*/
- public static final class Basic extends Header.Wrap {
+ public static final class Basic extends Header {
/**
- * Ctor.
- *
* @param username User name.
* @param password Password.
*/
@@ -114,8 +107,6 @@ public Basic(final String username, final String password) {
}
/**
- * Ctor.
- *
* @param credentials Credentials.
*/
public Basic(final String credentials) {
@@ -137,7 +128,13 @@ public String credentials() {
* @return Username string.
*/
public String username() {
- return this.tokens()[0];
+ final String[] tokens = this.tokens();
+ if (tokens.length < 1) {
+ throw new IllegalArgumentException(
+ "Invalid Basic auth credentials: missing username"
+ );
+ }
+ return tokens[0];
}
/**
@@ -146,7 +143,13 @@ public String username() {
* @return Password string.
*/
public String password() {
- return this.tokens()[1];
+ final String[] tokens = this.tokens();
+ if (tokens.length < 2) {
+ throw new IllegalArgumentException(
+ "Invalid Basic auth credentials: missing password"
+ );
+ }
+ return tokens[1];
}
/**
@@ -155,10 +158,15 @@ public String password() {
* @return Tokens array.
*/
private String[] tokens() {
- return new String(
+ final String decoded = new String(
Base64.getDecoder().decode(this.credentials()),
StandardCharsets.UTF_8
- ).split(":");
+ );
+ // Handle empty decoded string or missing colon
+ if (decoded.isEmpty()) {
+ return new String[0];
+ }
+ return decoded.split(":", 2); // Limit to 2 parts (username:password)
}
}
@@ -167,11 +175,9 @@ private String[] tokens() {
*
* @since 0.12
*/
- public static final class Bearer extends Header.Wrap {
+ public static final class Bearer extends Header {
/**
- * Ctor.
- *
* @param token Token.
*/
public Bearer(final String token) {
@@ -193,7 +199,7 @@ public String token() {
*
* @since 0.23
*/
- public static final class Token extends Header.Wrap {
+ public static final class Token extends Header {
/**
* Ctor.
diff --git a/artipie-core/src/main/java/com/artipie/http/headers/ContentDisposition.java b/artipie-core/src/main/java/com/artipie/http/headers/ContentDisposition.java
index ecde66aae..d448a47e4 100644
--- a/artipie-core/src/main/java/com/artipie/http/headers/ContentDisposition.java
+++ b/artipie-core/src/main/java/com/artipie/http/headers/ContentDisposition.java
@@ -6,6 +6,7 @@
import com.artipie.http.Headers;
import com.artipie.http.rq.RqHeaders;
+
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
@@ -15,9 +16,8 @@
* Content-Disposition header.
*
* @see
- * @since 0.17.8
*/
-public final class ContentDisposition extends Header.Wrap {
+public final class ContentDisposition extends Header {
/**
* Header name.
diff --git a/artipie-core/src/main/java/com/artipie/http/headers/ContentFileName.java b/artipie-core/src/main/java/com/artipie/http/headers/ContentFileName.java
index 1d5f6f859..27cc6e4f5 100644
--- a/artipie-core/src/main/java/com/artipie/http/headers/ContentFileName.java
+++ b/artipie-core/src/main/java/com/artipie/http/headers/ContentFileName.java
@@ -9,10 +9,8 @@
/**
* Content-Disposition header for a file.
- *
- * @since 0.17.8
*/
-public final class ContentFileName extends Header.Wrap {
+public final class ContentFileName extends Header {
/**
* Ctor.
*
diff --git a/artipie-core/src/main/java/com/artipie/http/headers/ContentLength.java b/artipie-core/src/main/java/com/artipie/http/headers/ContentLength.java
index 2685bf88c..32a41a6dd 100644
--- a/artipie-core/src/main/java/com/artipie/http/headers/ContentLength.java
+++ b/artipie-core/src/main/java/com/artipie/http/headers/ContentLength.java
@@ -5,14 +5,15 @@
package com.artipie.http.headers;
import com.artipie.http.Headers;
-import com.artipie.http.rq.RqHeaders;
/**
* Content-Length header.
- *
- * @since 0.10
*/
-public final class ContentLength extends Header.Wrap {
+public final class ContentLength extends Header {
+
+ public static Header with(long size) {
+ return new ContentLength(String.valueOf(size));
+ }
/**
* Header name.
@@ -20,7 +21,6 @@ public final class ContentLength extends Header.Wrap {
public static final String NAME = "Content-Length";
/**
- * Ctor.
* @param length Length number
*/
public ContentLength(final Number length) {
@@ -28,8 +28,6 @@ public ContentLength(final Number length) {
}
/**
- * Ctor.
- *
* @param value Header value.
*/
public ContentLength(final String value) {
@@ -37,12 +35,10 @@ public ContentLength(final String value) {
}
/**
- * Ctor.
- *
* @param headers Headers to extract header from.
*/
public ContentLength(final Headers headers) {
- this(new RqHeaders.Single(headers, ContentLength.NAME).asString());
+ this(headers.single(ContentLength.NAME).getValue());
}
/**
diff --git a/artipie-core/src/main/java/com/artipie/http/headers/ContentType.java b/artipie-core/src/main/java/com/artipie/http/headers/ContentType.java
index a32a05f24..94b791613 100644
--- a/artipie-core/src/main/java/com/artipie/http/headers/ContentType.java
+++ b/artipie-core/src/main/java/com/artipie/http/headers/ContentType.java
@@ -5,35 +5,72 @@
package com.artipie.http.headers;
import com.artipie.http.Headers;
-import com.artipie.http.rq.RqHeaders;
+
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
/**
* Content-Type header.
- *
- * @since 0.11
*/
-public final class ContentType extends Header.Wrap {
-
+public final class ContentType {
/**
* Header name.
*/
public static final String NAME = "Content-Type";
- /**
- * Ctor.
- *
- * @param value Header value.
- */
- public ContentType(final String value) {
- super(new Header(ContentType.NAME, value));
+ public static Header mime(String mime) {
+ return new Header(NAME, mime);
}
- /**
- * Ctor.
- *
- * @param headers Headers to extract header from.
- */
- public ContentType(final Headers headers) {
- this(new RqHeaders.Single(headers, ContentType.NAME).asString());
+ public static Header mime(String mime, Charset charset) {
+ return new Header(NAME, mime + "; charset=" + charset.displayName().toLowerCase());
+ }
+
+ public static Header json() {
+ return json(StandardCharsets.UTF_8);
+ }
+
+ public static Header json(Charset charset) {
+ return mime("application/json", charset);
+ }
+
+ public static Header text() {
+ return text(StandardCharsets.UTF_8);
+ }
+
+ public static Header text(Charset charset) {
+ return mime("text/plain", charset);
+ }
+
+ public static Header html() {
+ return html(StandardCharsets.UTF_8);
+ }
+
+ public static Header html(Charset charset) {
+ return mime("text/html", charset);
+ }
+
+ public static Header yaml() {
+ return yaml(StandardCharsets.UTF_8);
+ }
+
+ public static Header yaml(Charset charset) {
+ return mime("text/x-yaml", charset);
+ }
+
+ public static Header single(Headers headers) {
+ List res = headers.find(NAME);
+ if (res.isEmpty()) {
+ throw new IllegalStateException("No headers were found");
+ }
+ if (res.size() > 1) {
+ throw new IllegalStateException("Too many headers were found");
+ }
+ return res.getFirst();
+ }
+
+ private ContentType() {
+ //no-op
}
}
diff --git a/artipie-core/src/main/java/com/artipie/http/headers/Header.java b/artipie-core/src/main/java/com/artipie/http/headers/Header.java
index 680e64038..d1a630ebc 100644
--- a/artipie-core/src/main/java/com/artipie/http/headers/Header.java
+++ b/artipie-core/src/main/java/com/artipie/http/headers/Header.java
@@ -11,24 +11,13 @@
/**
* HTTP header.
* Name of header is considered to be case-insensitive when compared to one another.
- *
- * @since 0.8
*/
-public final class Header implements Map.Entry {
+public class Header implements Map.Entry {
- /**
- * Name.
- */
private final String name;
-
- /**
- * Value.
- */
private final String value;
/**
- * Ctor.
- *
* @param entry Entry representing a header.
*/
public Header(final Map.Entry entry) {
@@ -36,8 +25,6 @@ public Header(final Map.Entry entry) {
}
/**
- * Ctor.
- *
* @param name Name.
* @param value Value.
*/
@@ -62,95 +49,35 @@ public String setValue(final String ignored) {
}
@Override
- @SuppressWarnings("PMD.OnlyOneReturn")
public boolean equals(final Object that) {
if (this == that) {
return true;
}
- if (that == null || getClass() != that.getClass()) {
+ if(!(that instanceof Header header)){
return false;
}
- final Header header = (Header) that;
return this.lowercaseName().equals(header.lowercaseName())
- && this.getValue().equals(header.getValue());
+ && this.lowercaseValue().equals(header.lowercaseValue());
}
@Override
public int hashCode() {
- return Objects.hash(this.lowercaseName(), this.getValue());
+ return Objects.hash(this.lowercaseName(), this.lowercaseValue());
}
@Override
public String toString() {
- return String.format("%s: %s", this.name, this.getValue());
+ return "Header{" +
+ "name='" + name + '\'' +
+ ", value='" + value + '\'' +
+ '}';
}
- /**
- * Converts name to lowercase for comparison.
- *
- * @return Name in lowercase.
- */
- private String lowercaseName() {
+ protected String lowercaseName() {
return this.name.toLowerCase(Locale.US);
}
- /**
- * Abstract decorator for Header.
- *
- * @since 0.9
- */
- public abstract static class Wrap implements Map.Entry {
-
- /**
- * Origin header.
- */
- private final Map.Entry header;
-
- /**
- * Ctor.
- *
- * @param header Header.
- */
- protected Wrap(final Map.Entry header) {
- this.header = header;
- }
-
- @Override
- public final String getKey() {
- return this.header.getKey();
- }
-
- @Override
- public final String getValue() {
- return this.header.getValue();
- }
-
- @Override
- public final String setValue(final String value) {
- return this.header.setValue(value);
- }
-
- @Override
- @SuppressWarnings("PMD.OnlyOneReturn")
- public final boolean equals(final Object that) {
- if (this == that) {
- return true;
- }
- if (that == null || getClass() != that.getClass()) {
- return false;
- }
- final Wrap wrap = (Wrap) that;
- return Objects.equals(this.header, wrap.header);
- }
-
- @Override
- public final int hashCode() {
- return Objects.hash(this.header);
- }
-
- @Override
- public final String toString() {
- return this.header.toString();
- }
+ protected String lowercaseValue() {
+ return this.getValue().toLowerCase(Locale.US);
}
}
diff --git a/artipie-core/src/main/java/com/artipie/http/headers/Location.java b/artipie-core/src/main/java/com/artipie/http/headers/Location.java
index b7d1f7cf8..47438e736 100644
--- a/artipie-core/src/main/java/com/artipie/http/headers/Location.java
+++ b/artipie-core/src/main/java/com/artipie/http/headers/Location.java
@@ -9,10 +9,8 @@
/**
* Location header.
- *
- * @since 0.11
*/
-public final class Location extends Header.Wrap {
+public final class Location extends Header {
/**
* Header name.
diff --git a/artipie-core/src/main/java/com/artipie/http/headers/Login.java b/artipie-core/src/main/java/com/artipie/http/headers/Login.java
index e92e07008..af882d641 100644
--- a/artipie-core/src/main/java/com/artipie/http/headers/Login.java
+++ b/artipie-core/src/main/java/com/artipie/http/headers/Login.java
@@ -6,45 +6,92 @@
import com.artipie.http.Headers;
import com.artipie.http.auth.AuthzSlice;
-import com.artipie.http.rq.RqHeaders;
import com.artipie.scheduling.ArtifactEvent;
-import java.util.Map;
+import org.slf4j.MDC;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Optional;
/**
* Login header.
- * @since 1.13
*/
-public final class Login extends Header.Wrap {
+public final class Login extends Header {
/**
- * Ctor.
- *
- * @param headers Header.
+ * Prefix of the basic authorization header.
*/
- public Login(final Map.Entry headers) {
- this(
- new RqHeaders(new Headers.From(headers), AuthzSlice.LOGIN_HDR)
- .stream().findFirst().orElse(ArtifactEvent.DEF_OWNER)
- );
- }
+ private static final String BASIC_PREFIX = "Basic ";
/**
- * Ctor.
- *
* @param headers Header.
*/
public Login(final Headers headers) {
- this(
- new RqHeaders(headers, AuthzSlice.LOGIN_HDR)
- .stream().findFirst().orElse(ArtifactEvent.DEF_OWNER)
- );
+ this(resolve(headers));
}
/**
- * Ctor.
* @param value Header value
*/
public Login(final String value) {
super(new Header(AuthzSlice.LOGIN_HDR, value));
}
+
+ private static String resolve(final Headers headers) {
+ // 1. Try artipie_login header (set by AuthzSlice after successful auth)
+ return headers.find(AuthzSlice.LOGIN_HDR)
+ .stream()
+ .findFirst()
+ .map(Header::getValue)
+ .filter(Login::isMeaningful)
+ .orElseGet(() -> {
+ // 2. Try Basic auth header extraction
+ final Optional fromAuth = authorizationUser(headers);
+ if (fromAuth.isPresent()) {
+ return fromAuth.get();
+ }
+ // 3. Try MDC (set by AuthzSlice for Bearer/JWT users)
+ final String mdcUser = MDC.get("user.name");
+ if (mdcUser != null && !mdcUser.isEmpty() && !"anonymous".equals(mdcUser)) {
+ return mdcUser;
+ }
+ // 4. Default fallback
+ return ArtifactEvent.DEF_OWNER;
+ });
+ }
+
+ private static Optional authorizationUser(final Headers headers) {
+ return headers.find("Authorization")
+ .stream()
+ .findFirst()
+ .map(Header::getValue)
+ .flatMap(Login::decodeAuthorization);
+ }
+
+ private static Optional decodeAuthorization(final String header) {
+ if (header.regionMatches(true, 0, BASIC_PREFIX, 0, BASIC_PREFIX.length())) {
+ final String encoded = header.substring(BASIC_PREFIX.length()).trim();
+ if (encoded.isEmpty()) {
+ return Optional.empty();
+ }
+ try {
+ final byte[] decoded = Base64.getDecoder().decode(encoded);
+ final String credentials = new String(decoded, StandardCharsets.UTF_8);
+ final int separator = credentials.indexOf(':');
+ if (separator >= 0) {
+ return Optional.of(credentials.substring(0, separator));
+ }
+ if (!credentials.isBlank()) {
+ return Optional.of(credentials);
+ }
+ } catch (final IllegalArgumentException ignored) {
+ return Optional.empty();
+ }
+ }
+ return Optional.empty();
+ }
+
+ private static boolean isMeaningful(final String value) {
+ return !value.isBlank() && !ArtifactEvent.DEF_OWNER.equals(value);
+ }
}
diff --git a/artipie-core/src/main/java/com/artipie/http/headers/WwwAuthenticate.java b/artipie-core/src/main/java/com/artipie/http/headers/WwwAuthenticate.java
index 4d7d2cbc3..bcc47e28f 100644
--- a/artipie-core/src/main/java/com/artipie/http/headers/WwwAuthenticate.java
+++ b/artipie-core/src/main/java/com/artipie/http/headers/WwwAuthenticate.java
@@ -6,20 +6,19 @@
import com.artipie.http.Headers;
import com.artipie.http.rq.RqHeaders;
+
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
/**
* WWW-Authenticate header.
*
* @since 0.12
*/
-public final class WwwAuthenticate extends Header.Wrap {
+public final class WwwAuthenticate extends Header {
/**
* Header name.
@@ -32,8 +31,6 @@ public final class WwwAuthenticate extends Header.Wrap {
private static final Pattern VALUE = Pattern.compile("(?[^\"]*)( (?.*))?");
/**
- * Ctor.
- *
* @param value Header value.
*/
public WwwAuthenticate(final String value) {
@@ -41,8 +38,6 @@ public WwwAuthenticate(final String value) {
}
/**
- * Ctor.
- *
* @param headers Headers to extract header from.
*/
public WwwAuthenticate(final Headers headers) {
@@ -64,11 +59,41 @@ public String scheme() {
* @return Parameters list.
*/
public List params() {
- return Optional.ofNullable(this.matcher().group("params")).map(
- params -> Stream.of(params.split(","))
- .map(Param::new)
- .collect(Collectors.toList())
- ).orElseGet(Collections::emptyList);
+ return Optional.ofNullable(this.matcher().group("params"))
+ .map(String::trim)
+ .filter(params -> !params.isEmpty())
+ .map(WwwAuthenticate::splitParams)
+ .orElseGet(Collections::emptyList);
+ }
+
+ /**
+ * Split params string into individual parameters, preserving quoted commas.
+ *
+ * @param params Raw params string
+ * @return List of parameter objects
+ */
+ private static List splitParams(final String params) {
+ final StringBuilder current = new StringBuilder();
+ final List result = new java.util.ArrayList<>();
+ boolean quoted = false;
+ for (int idx = 0; idx < params.length(); idx++) {
+ final char symbol = params.charAt(idx);
+ if (symbol == '"') {
+ quoted = !quoted;
+ current.append(symbol);
+ } else if (symbol == ',' && !quoted) {
+ if (current.length() > 0) {
+ result.add(new Param(current.toString().trim()));
+ current.setLength(0);
+ }
+ } else {
+ current.append(symbol);
+ }
+ }
+ if (current.length() > 0) {
+ result.add(new Param(current.toString().trim()));
+ }
+ return result;
}
/**
@@ -106,8 +131,6 @@ private Matcher matcher() {
/**
* WWW-Authenticate header parameter.
- *
- * @since 0.12
*/
public static class Param {
@@ -115,7 +138,7 @@ public static class Param {
* Param RegEx.
*/
private static final Pattern PATTERN = Pattern.compile(
- "(?[^=]*)=\"(?[^\"]*)\""
+ "(?[^=\\s]+)\\s*=\\s*\"(?[^\"]*)\""
);
/**
@@ -124,8 +147,6 @@ public static class Param {
private final String string;
/**
- * Ctor.
- *
* @param string Param raw string.
*/
public Param(final String string) {
@@ -156,7 +177,7 @@ public String value() {
* @return Matcher for param.
*/
private Matcher matcher() {
- final String value = this.string;
+ final String value = this.string.trim();
final Matcher matcher = PATTERN.matcher(value);
if (!matcher.matches()) {
throw new IllegalArgumentException(
diff --git a/artipie-core/src/main/java/com/artipie/http/hm/AssertSlice.java b/artipie-core/src/main/java/com/artipie/http/hm/AssertSlice.java
index 55bb2ed74..618139c14 100644
--- a/artipie-core/src/main/java/com/artipie/http/hm/AssertSlice.java
+++ b/artipie-core/src/main/java/com/artipie/http/hm/AssertSlice.java
@@ -4,13 +4,12 @@
*/
package com.artipie.http.hm;
+import com.artipie.asto.Content;
import com.artipie.http.Headers;
+import com.artipie.http.ResponseBuilder;
import com.artipie.http.Response;
import com.artipie.http.Slice;
-import com.artipie.http.rq.RequestLineFrom;
-import com.artipie.http.rs.StandardRs;
-import java.nio.ByteBuffer;
-import java.util.Map;
+import com.artipie.http.rq.RequestLine;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.MatcherAssert;
@@ -18,9 +17,11 @@
import org.hamcrest.TypeSafeMatcher;
import org.reactivestreams.Publisher;
+import java.nio.ByteBuffer;
+import java.util.concurrent.CompletableFuture;
+
/**
* Slice implementation which assert request data against specified matchers.
- * @since 0.10
*/
public final class AssertSlice implements Slice {
@@ -29,7 +30,7 @@ public final class AssertSlice implements Slice {
* @since 0.10
*/
private static final TypeSafeMatcher> STUB_BODY_MATCHER =
- new TypeSafeMatcher>() {
+ new TypeSafeMatcher<>() {
@Override
protected boolean matchesSafely(final Publisher item) {
return true;
@@ -44,51 +45,50 @@ public void describeTo(final Description description) {
/**
* Request line matcher.
*/
- private final Matcher super RequestLineFrom> line;
+ private final Matcher super RequestLine> lineMatcher;
/**
* Request headers matcher.
*/
- private final Matcher super Headers> head;
+ private final Matcher super Headers> headersMatcher;
/**
* Request body matcher.
*/
- private final Matcher super Publisher> body;
+ private final Matcher super Publisher> bodyMatcher;
/**
* Assert slice request line.
- * @param line Request line matcher
+ * @param lineMatcher Request line matcher
*/
- public AssertSlice(final Matcher super RequestLineFrom> line) {
- this(line, Matchers.any(Headers.class), AssertSlice.STUB_BODY_MATCHER);
+ public AssertSlice(final Matcher super RequestLine> lineMatcher) {
+ this(lineMatcher, Matchers.any(Headers.class), AssertSlice.STUB_BODY_MATCHER);
}
/**
- * Ctor.
- * @param line Request line matcher
- * @param head Request headers matcher
- * @param body Request body matcher
+ * @param lineMatcher Request line matcher
+ * @param headersMatcher Request headers matcher
+ * @param bodyMatcher Request body matcher
*/
- public AssertSlice(final Matcher super RequestLineFrom> line,
- final Matcher super Headers> head, final Matcher super Publisher> body) {
- this.line = line;
- this.head = head;
- this.body = body;
+ public AssertSlice(Matcher super RequestLine> lineMatcher,
+ Matcher super Headers> headersMatcher,
+ Matcher super Publisher> bodyMatcher) {
+ this.lineMatcher = lineMatcher;
+ this.headersMatcher = headersMatcher;
+ this.bodyMatcher = bodyMatcher;
}
@Override
- public Response response(final String lne, final Iterable> headers,
- final Publisher publ) {
+ public CompletableFuture response(RequestLine line, Headers headers, Content body) {
MatcherAssert.assertThat(
- "Wrong request line", new RequestLineFrom(lne), this.line
+ "Wrong request line", line, this.lineMatcher
);
MatcherAssert.assertThat(
- "Wrong headers", new Headers.From(headers), this.head
+ "Wrong headers", headers, this.headersMatcher
);
MatcherAssert.assertThat(
- "Wrong body", publ, this.body
+ "Wrong body", body, this.bodyMatcher
);
- return StandardRs.EMPTY;
+ return ResponseBuilder.ok().completedFuture();
}
}
diff --git a/artipie-core/src/main/java/com/artipie/http/hm/IsHeader.java b/artipie-core/src/main/java/com/artipie/http/hm/IsHeader.java
deleted file mode 100644
index bbd7d0ef3..000000000
--- a/artipie-core/src/main/java/com/artipie/http/hm/IsHeader.java
+++ /dev/null
@@ -1,73 +0,0 @@
-/*
- * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
- * https://github.com/artipie/artipie/blob/master/LICENSE.txt
- */
-package com.artipie.http.hm;
-
-import java.util.Map;
-import org.hamcrest.Description;
-import org.hamcrest.Matcher;
-import org.hamcrest.TypeSafeMatcher;
-import org.hamcrest.core.IsEqual;
-import org.hamcrest.text.IsEqualIgnoringCase;
-
-/**
- * Header matcher.
- *
- * @since 0.8
- */
-public final class IsHeader extends TypeSafeMatcher> {
-
- /**
- * Name matcher.
- */
- private final Matcher name;
-
- /**
- * Value matcher.
- */
- private final Matcher value;
-
- /**
- * Ctor.
- *
- * @param name Expected header name, compared ignoring case.
- * @param value Expected header value.
- */
- public IsHeader(final String name, final String value) {
- this(name, new IsEqual<>(value));
- }
-
- /**
- * Ctor.
- *
- * @param name Expected header name, compared ignoring case.
- * @param value Value matcher.
- */
- public IsHeader(final String name, final Matcher value) {
- this(new IsEqualIgnoringCase(name), value);
- }
-
- /**
- * Ctor.
- *
- * @param name Name matcher.
- * @param value Value matcher.
- */
- public IsHeader(final Matcher name, final Matcher value) {
- this.name = name;
- this.value = value;
- }
-
- @Override
- public void describeTo(final Description description) {
- description.appendDescriptionOf(this.name)
- .appendText(" ")
- .appendDescriptionOf(this.value);
- }
-
- @Override
- public boolean matchesSafely(final Map.Entry item) {
- return this.name.matches(item.getKey()) && this.value.matches(item.getValue());
- }
-}
diff --git a/artipie-core/src/main/java/com/artipie/http/hm/IsString.java b/artipie-core/src/main/java/com/artipie/http/hm/IsString.java
deleted file mode 100644
index f5a7df4f1..000000000
--- a/artipie-core/src/main/java/com/artipie/http/hm/IsString.java
+++ /dev/null
@@ -1,79 +0,0 @@
-/*
- * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
- * https://github.com/artipie/artipie/blob/master/LICENSE.txt
- */
-package com.artipie.http.hm;
-
-import java.nio.charset.Charset;
-import org.hamcrest.Description;
-import org.hamcrest.Matcher;
-import org.hamcrest.TypeSafeMatcher;
-import org.hamcrest.core.IsEqual;
-
-/**
- * Matcher to verify byte array as string.
- *
- * @since 0.7.2
- */
-public final class IsString extends TypeSafeMatcher {
-
- /**
- * Charset used to decode bytes to string.
- */
- private final Charset charset;
-
- /**
- * String matcher.
- */
- private final Matcher matcher;
-
- /**
- * Ctor.
- *
- * @param string String the bytes should be equal to.
- */
- public IsString(final String string) {
- this(Charset.defaultCharset(), new IsEqual<>(string));
- }
-
- /**
- * Ctor.
- *
- * @param charset Charset used to decode bytes to string.
- * @param string String the bytes should be equal to.
- */
- public IsString(final Charset charset, final String string) {
- this(charset, new IsEqual<>(string));
- }
-
- /**
- * Ctor.
- *
- * @param matcher Matcher for string.
- */
- public IsString(final Matcher matcher) {
- this(Charset.defaultCharset(), matcher);
- }
-
- /**
- * Ctor.
- *
- * @param charset Charset used to decode bytes to string.
- * @param matcher Matcher for string.
- */
- public IsString(final Charset charset, final Matcher matcher) {
- this.charset = charset;
- this.matcher = matcher;
- }
-
- @Override
- public void describeTo(final Description description) {
- description.appendText("bytes ").appendDescriptionOf(this.matcher);
- }
-
- @Override
- public boolean matchesSafely(final byte[] item) {
- final String string = new String(item, this.charset);
- return this.matcher.matches(string);
- }
-}
diff --git a/artipie-core/src/main/java/com/artipie/http/hm/ResponseAssert.java b/artipie-core/src/main/java/com/artipie/http/hm/ResponseAssert.java
new file mode 100644
index 000000000..081cd625c
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/http/hm/ResponseAssert.java
@@ -0,0 +1,68 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.http.hm;
+
+import com.artipie.http.Headers;
+import com.artipie.http.Response;
+import com.artipie.http.headers.Header;
+import com.artipie.http.RsStatus;
+import org.hamcrest.MatcherAssert;
+import org.hamcrest.Matchers;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+public class ResponseAssert {
+
+ public static void checkOk(Response actual) {
+ check(actual, RsStatus.OK);
+ }
+
+ public static void check(Response actual, RsStatus status) {
+ MatcherAssert.assertThat(actual.status(), Matchers.is(status));
+ }
+
+ public static void check(Response actual, RsStatus status, Header... headers) {
+ MatcherAssert.assertThat(actual.status(), Matchers.is(status));
+ checkHeaders(actual.headers(), headers);
+ }
+
+ public static void check(Response actual, RsStatus status, byte[] body, Header... headers) {
+ MatcherAssert.assertThat(actual.status(), Matchers.is(status));
+ checkHeaders(actual.headers(), headers);
+ MatcherAssert.assertThat(actual.body().asBytes(), Matchers.is(body));
+ }
+
+ public static void check(Response actual, RsStatus status, byte[] body) {
+ MatcherAssert.assertThat(actual.status(), Matchers.is(status));
+ MatcherAssert.assertThat(actual.body().asBytes(), Matchers.is(body));
+ }
+
+ public static void check(Response actual, byte[] body) {
+ MatcherAssert.assertThat(actual.body().asBytes(), Matchers.is(body));
+ }
+
+ private static void checkHeaders(Headers actual, Header... expected) {
+ Arrays.stream(expected).forEach(h -> checkHeader(actual, h));
+ }
+
+ private static void checkHeader(Headers actual, Header header) {
+ List list = actual.find(header.getKey());
+ MatcherAssert.assertThat("Actual headers doesn't contain '" + header.getKey() + "'",
+ list.isEmpty(), Matchers.is(false));
+ Optional res = list.stream()
+ .filter(h -> Objects.equals(h, header))
+ .findAny();
+ if (res.isEmpty()) {
+ throw new AssertionError(
+ "'" + header.getKey() + "' header values don't match: expected=" + header.getValue()
+ + ", actual=" + list.stream().map(Header::getValue).collect(Collectors.joining(", "))
+ );
+ }
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/http/hm/ResponseMatcher.java b/artipie-core/src/main/java/com/artipie/http/hm/ResponseMatcher.java
index deebdbf77..ea2e6902a 100644
--- a/artipie-core/src/main/java/com/artipie/http/hm/ResponseMatcher.java
+++ b/artipie-core/src/main/java/com/artipie/http/hm/ResponseMatcher.java
@@ -5,30 +5,24 @@
package com.artipie.http.hm;
import com.artipie.http.Response;
-import com.artipie.http.rs.RsStatus;
-import java.nio.charset.Charset;
-import java.nio.charset.StandardCharsets;
-import java.util.Map;
+import com.artipie.http.RsStatus;
+import com.artipie.http.headers.Header;
import org.hamcrest.Matcher;
-import org.hamcrest.Matchers;
import org.hamcrest.core.AllOf;
/**
* Response matcher.
- * @since 0.10
*/
public final class ResponseMatcher extends AllOf {
/**
- * Ctor.
- *
* @param status Expected status
* @param headers Expected headers
* @param body Expected body
*/
public ResponseMatcher(
final RsStatus status,
- final Iterable extends Map.Entry> headers,
+ final Iterable extends Header> headers,
final byte[] body
) {
super(
@@ -39,16 +33,14 @@ public ResponseMatcher(
}
/**
- * Ctor.
* @param status Expected status
* @param body Expected body
* @param headers Expected headers
*/
- @SafeVarargs
public ResponseMatcher(
final RsStatus status,
final byte[] body,
- final Map.Entry... headers
+ final Header... headers
) {
super(
new RsHasStatus(status),
@@ -58,7 +50,6 @@ public ResponseMatcher(
}
/**
- * Ctor.
* @param status Expected status
* @param body Expected body
*/
@@ -70,85 +61,6 @@ public ResponseMatcher(final RsStatus status, final byte[] body) {
}
/**
- * Ctor.
- * @param status Expected status
- * @param body Expected body
- */
- public ResponseMatcher(final RsStatus status, final String body) {
- this(
- status,
- body,
- StandardCharsets.UTF_8
- );
- }
-
- /**
- * Ctor.
- * @param body Expected body
- */
- public ResponseMatcher(final Matcher body) {
- this(body, StandardCharsets.UTF_8);
- }
-
- /**
- * Ctor.
- * @param body Expected body
- */
- public ResponseMatcher(final String body) {
- this(Matchers.is(body));
- }
-
- /**
- * Ctor.
- * @param body Expected body
- * @param charset Character set
- */
- public ResponseMatcher(final Matcher body, final Charset charset) {
- this(RsStatus.OK, body, charset);
- }
-
- /**
- * Ctor.
- * @param body Expected body
- * @param charset Character set
- */
- public ResponseMatcher(final String body, final Charset charset) {
- this(RsStatus.OK, body, charset);
- }
-
- /**
- * Ctor.
- * @param status Expected status
- * @param body Expected body
- * @param charset Character set
- */
- public ResponseMatcher(final RsStatus status, final String body, final Charset charset) {
- this(
- status,
- Matchers.is(body),
- charset
- );
- }
-
- /**
- * Ctor.
- * @param status Expected status
- * @param body Expected body
- * @param charset Character set
- */
- public ResponseMatcher(
- final RsStatus status,
- final Matcher body,
- final Charset charset
- ) {
- super(
- new RsHasStatus(status),
- new RsHasBody(body, charset)
- );
- }
-
- /**
- * Ctor.
* @param body Expected body
*/
public ResponseMatcher(final byte[] body) {
@@ -156,86 +68,49 @@ public ResponseMatcher(final byte[] body) {
}
/**
- * Ctor.
- *
* @param headers Expected headers
*/
- public ResponseMatcher(final Iterable extends Map.Entry> headers) {
- this(
- RsStatus.OK,
- new RsHasHeaders(headers)
- );
+ public ResponseMatcher(Iterable extends Header> headers) {
+ this(RsStatus.OK, new RsHasHeaders(headers));
}
/**
- * Ctor.
* @param headers Expected headers
*/
- @SafeVarargs
- public ResponseMatcher(final Map.Entry... headers) {
- this(
- RsStatus.OK,
- new RsHasHeaders(headers)
- );
+ public ResponseMatcher(Header... headers) {
+ this(RsStatus.OK, new RsHasHeaders(headers));
}
/**
- * Ctor.
- *
* @param status Expected status
* @param headers Expected headers
*/
- public ResponseMatcher(
- final RsStatus status,
- final Iterable extends Map.Entry> headers
- ) {
- this(
- status,
- new RsHasHeaders(headers)
- );
+ public ResponseMatcher(RsStatus status, Iterable extends Header> headers) {
+ this(status, new RsHasHeaders(headers));
}
/**
- * Ctor.
* @param status Expected status
* @param headers Expected headers
*/
- @SafeVarargs
- public ResponseMatcher(final RsStatus status, final Map.Entry... headers) {
- this(
- status,
- new RsHasHeaders(headers)
- );
+ public ResponseMatcher(RsStatus status, Header... headers) {
+ this(status, new RsHasHeaders(headers));
}
/**
- * Ctor.
* @param status Expected status
* @param headers Matchers for expected headers
*/
@SafeVarargs
- public ResponseMatcher(
- final RsStatus status,
- final Matcher super Map.Entry>... headers
- ) {
- this(
- status,
- new RsHasHeaders(headers)
- );
+ public ResponseMatcher(RsStatus status, Matcher super Header>... headers) {
+ this(status, new RsHasHeaders(headers));
}
/**
- * Ctor.
* @param status Expected status
* @param headers Matchers for expected headers
*/
- public ResponseMatcher(
- final RsStatus status,
- final Matcher headers
- ) {
- super(
- new RsHasStatus(status), headers
- );
+ public ResponseMatcher(RsStatus status, Matcher headers) {
+ super(new RsHasStatus(status), headers);
}
-
}
diff --git a/artipie-core/src/main/java/com/artipie/http/hm/RqLineHasUri.java b/artipie-core/src/main/java/com/artipie/http/hm/RqLineHasUri.java
index 315b06fe8..d8a1da938 100644
--- a/artipie-core/src/main/java/com/artipie/http/hm/RqLineHasUri.java
+++ b/artipie-core/src/main/java/com/artipie/http/hm/RqLineHasUri.java
@@ -4,18 +4,18 @@
*/
package com.artipie.http.hm;
-import com.artipie.http.rq.RequestLineFrom;
-import java.net.URI;
+import com.artipie.http.rq.RequestLine;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;
import org.hamcrest.core.IsEqual;
+import java.net.URI;
+
/**
* Request line URI matcher.
- * @since 0.10
*/
-public final class RqLineHasUri extends TypeSafeMatcher {
+public final class RqLineHasUri extends TypeSafeMatcher {
/**
* Request line URI matcher.
@@ -31,7 +31,7 @@ public RqLineHasUri(final Matcher target) {
}
@Override
- public boolean matchesSafely(final RequestLineFrom item) {
+ public boolean matchesSafely(final RequestLine item) {
return this.target.matches(item.uri());
}
@@ -41,7 +41,7 @@ public void describeTo(final Description description) {
}
@Override
- public void describeMismatchSafely(final RequestLineFrom item, final Description description) {
+ public void describeMismatchSafely(final RequestLine item, final Description description) {
this.target.describeMismatch(item.uri(), description.appendText("URI was: "));
}
diff --git a/artipie-core/src/main/java/com/artipie/http/hm/RsHasBody.java b/artipie-core/src/main/java/com/artipie/http/hm/RsHasBody.java
index 10c93d554..4c389cd4d 100644
--- a/artipie-core/src/main/java/com/artipie/http/hm/RsHasBody.java
+++ b/artipie-core/src/main/java/com/artipie/http/hm/RsHasBody.java
@@ -5,28 +5,14 @@
package com.artipie.http.hm;
-import com.artipie.http.Connection;
-import com.artipie.http.Headers;
import com.artipie.http.Response;
-import com.artipie.http.rs.RsStatus;
-import io.reactivex.Flowable;
-import java.nio.ByteBuffer;
-import java.nio.charset.Charset;
-import java.nio.charset.StandardCharsets;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.CompletionStage;
-import java.util.concurrent.atomic.AtomicReference;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
-import org.hamcrest.Matchers;
import org.hamcrest.TypeSafeMatcher;
import org.hamcrest.core.IsEqual;
-import org.reactivestreams.Publisher;
/**
* Matcher to verify response body.
- *
- * @since 0.1
*/
public final class RsHasBody extends TypeSafeMatcher {
@@ -36,34 +22,6 @@ public final class RsHasBody extends TypeSafeMatcher {
private final Matcher body;
/**
- * Check response has string body in charset.
- * @param body Body string
- */
- public RsHasBody(final String body) {
- this(Matchers.is(body), StandardCharsets.UTF_8);
- }
-
- /**
- * Check response has string body in charset.
- * @param body Body string
- * @param charset Charset encoding
- */
- public RsHasBody(final String body, final Charset charset) {
- this(Matchers.is(body), charset);
- }
-
- /**
- * Check response has string body in charset.
- * @param body Body string
- * @param charset Charset encoding
- */
- public RsHasBody(final Matcher body, final Charset charset) {
- this(new IsString(charset, body));
- }
-
- /**
- * Ctor.
- *
* @param body Body to match
*/
public RsHasBody(final byte[] body) {
@@ -71,8 +29,6 @@ public RsHasBody(final byte[] body) {
}
/**
- * Ctor.
- *
* @param body Body matcher
*/
public RsHasBody(final Matcher body) {
@@ -86,67 +42,6 @@ public void describeTo(final Description description) {
@Override
public boolean matchesSafely(final Response item) {
- final AtomicReference out = new AtomicReference<>();
- item.send(new FakeConnection(out)).toCompletableFuture().join();
- return this.body.matches(out.get());
- }
-
- /**
- * Fake connection.
- *
- * @since 0.1
- */
- private static final class FakeConnection implements Connection {
-
- /**
- * Body container.
- */
- private final AtomicReference container;
-
- /**
- * Ctor.
- *
- * @param container Body container
- */
- FakeConnection(final AtomicReference container) {
- this.container = container;
- }
-
- @Override
- public CompletionStage accept(
- final RsStatus status,
- final Headers headers,
- final Publisher body
- ) {
- return CompletableFuture.supplyAsync(
- () -> {
- final ByteBuffer buffer = Flowable.fromPublisher(body)
- .toList()
- .blockingGet()
- .stream()
- .reduce(
- (left, right) -> {
- left.mark();
- right.mark();
- final ByteBuffer concat = ByteBuffer.allocate(
- left.remaining() + right.remaining()
- ).put(left).put(right);
- left.reset();
- right.reset();
- concat.flip();
- return concat;
- }
- )
- .orElse(ByteBuffer.allocate(0));
- final byte[] bytes = new byte[buffer.remaining()];
- buffer.mark();
- buffer.get(bytes);
- buffer.reset();
- this.container.set(bytes);
- return null;
- }
- );
- }
+ return this.body.matches(item.body().asBytes());
}
-
}
diff --git a/artipie-core/src/main/java/com/artipie/http/hm/RsHasHeaders.java b/artipie-core/src/main/java/com/artipie/http/hm/RsHasHeaders.java
index 2634af511..35d11013f 100644
--- a/artipie-core/src/main/java/com/artipie/http/hm/RsHasHeaders.java
+++ b/artipie-core/src/main/java/com/artipie/http/hm/RsHasHeaders.java
@@ -2,77 +2,55 @@
* The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
* https://github.com/artipie/artipie/blob/master/LICENSE.txt
*/
-
package com.artipie.http.hm;
-import com.artipie.http.Connection;
-import com.artipie.http.Headers;
import com.artipie.http.Response;
import com.artipie.http.headers.Header;
-import com.artipie.http.rs.RsStatus;
-import com.google.common.collect.ImmutableList;
-import java.nio.ByteBuffer;
-import java.util.Arrays;
-import java.util.Map.Entry;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.atomic.AtomicReference;
-import java.util.stream.Collectors;
-import java.util.stream.StreamSupport;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.Matchers;
import org.hamcrest.TypeSafeMatcher;
-import org.reactivestreams.Publisher;
+
+import java.util.Arrays;
+import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
/**
* Matcher to verify response headers.
- *
- * @since 0.8
*/
public final class RsHasHeaders extends TypeSafeMatcher {
/**
* Headers matcher.
*/
- private final Matcher extends Iterable extends Entry>> headers;
+ private final Matcher extends Iterable extends Header>> headers;
/**
- * Ctor.
- *
* @param headers Expected headers in any order.
*/
- @SafeVarargs
- public RsHasHeaders(final Entry... headers) {
+ public RsHasHeaders(Header... headers) {
this(Arrays.asList(headers));
}
/**
- * Ctor.
- *
* @param headers Expected header matchers in any order.
*/
- public RsHasHeaders(final Iterable extends Entry> headers) {
+ public RsHasHeaders(final Iterable extends Header> headers) {
this(transform(headers));
}
/**
- * Ctor.
- *
* @param headers Expected header matchers in any order.
*/
@SafeVarargs
- public RsHasHeaders(final Matcher super Entry>... headers) {
+ public RsHasHeaders(Matcher super Header>... headers) {
this(Matchers.hasItems(headers));
}
/**
- * Ctor.
- *
* @param headers Headers matcher
*/
- public RsHasHeaders(
- final Matcher extends Iterable extends Entry>> headers
- ) {
+ public RsHasHeaders(Matcher extends Iterable extends Header>> headers) {
this.headers = headers;
}
@@ -83,20 +61,12 @@ public void describeTo(final Description description) {
@Override
public boolean matchesSafely(final Response item) {
- final AtomicReference>> out = new AtomicReference<>();
- item.send(new FakeConnection(out)).toCompletableFuture().join();
- return this.headers.matches(out.get());
+ return this.headers.matches(item.headers());
}
@Override
public void describeMismatchSafely(final Response item, final Description desc) {
- final AtomicReference>> out = new AtomicReference<>();
- item.send(new FakeConnection(out)).toCompletableFuture().join();
- desc.appendText("was ").appendValue(
- StreamSupport.stream(out.get().spliterator(), false)
- .map(entry -> String.format("%s: %s", entry.getKey(), entry.getValue()))
- .collect(Collectors.joining(";"))
- );
+ desc.appendText("was ").appendValue(item.headers().asString());
}
/**
@@ -106,58 +76,11 @@ public void describeMismatchSafely(final Response item, final Description desc)
* @param headers Expected headers in any order.
* @return Expected header matchers in any order.
*/
- private static Matcher extends Iterable>> transform(
- final Iterable extends Entry> headers
- ) {
+ private static Matcher extends Iterable> transform(Iterable extends Header> headers) {
return Matchers.allOf(
StreamSupport.stream(headers.spliterator(), false)
- .>map(
- original -> new Header(
- original.getKey(),
- original.getValue()
- )
- )
.map(Matchers::hasItem)
.collect(Collectors.toList())
);
}
-
- /**
- * Fake connection.
- *
- * @since 0.8
- */
- private static final class FakeConnection implements Connection {
-
- /**
- * Headers container.
- */
- private final AtomicReference>> container;
-
- /**
- * Ctor.
- *
- * @param container Headers container
- */
- FakeConnection(final AtomicReference>> container) {
- this.container = container;
- }
-
- @Override
- public CompletableFuture accept(
- final RsStatus status,
- final Headers headers,
- final Publisher body) {
- return CompletableFuture.supplyAsync(
- () -> {
- this.container.set(
- ImmutableList.copyOf(headers).stream().>map(
- original -> new Header(original.getKey(), original.getValue())
- ).collect(Collectors.toList())
- );
- return null;
- }
- );
- }
- }
}
diff --git a/artipie-core/src/main/java/com/artipie/http/hm/RsHasStatus.java b/artipie-core/src/main/java/com/artipie/http/hm/RsHasStatus.java
index 8cf23582c..42041a2b4 100644
--- a/artipie-core/src/main/java/com/artipie/http/hm/RsHasStatus.java
+++ b/artipie-core/src/main/java/com/artipie/http/hm/RsHasStatus.java
@@ -5,22 +5,15 @@
package com.artipie.http.hm;
-import com.artipie.http.Connection;
-import com.artipie.http.Headers;
import com.artipie.http.Response;
-import com.artipie.http.rs.RsStatus;
-import java.nio.ByteBuffer;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.atomic.AtomicReference;
+import com.artipie.http.RsStatus;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;
import org.hamcrest.core.IsEqual;
-import org.reactivestreams.Publisher;
/**
* Matcher to verify response status.
- * @since 0.1
*/
public final class RsHasStatus extends TypeSafeMatcher {
@@ -30,7 +23,6 @@ public final class RsHasStatus extends TypeSafeMatcher {
private final Matcher status;
/**
- * Ctor.
* @param status Code to match
*/
public RsHasStatus(final RsStatus status) {
@@ -38,7 +30,6 @@ public RsHasStatus(final RsStatus status) {
}
/**
- * Ctor.
* @param status Code matcher
*/
public RsHasStatus(final Matcher status) {
@@ -52,41 +43,6 @@ public void describeTo(final Description description) {
@Override
public boolean matchesSafely(final Response item) {
- final AtomicReference out = new AtomicReference<>();
- item.send(new FakeConnection(out)).toCompletableFuture().join();
- return this.status.matches(out.get());
- }
-
- /**
- * Fake connection.
- * @since 0.1
- */
- private static final class FakeConnection implements Connection {
-
- /**
- * Status code container.
- */
- private final AtomicReference container;
-
- /**
- * Ctor.
- * @param container Status code container
- */
- FakeConnection(final AtomicReference container) {
- this.container = container;
- }
-
- @Override
- public CompletableFuture accept(
- final RsStatus status,
- final Headers headers,
- final Publisher body) {
- return CompletableFuture.supplyAsync(
- () -> {
- this.container.set(status);
- return null;
- }
- );
- }
+ return this.status.matches(item.status());
}
}
diff --git a/artipie-core/src/main/java/com/artipie/http/hm/SliceHasResponse.java b/artipie-core/src/main/java/com/artipie/http/hm/SliceHasResponse.java
index d637b4da7..11f7eee79 100644
--- a/artipie-core/src/main/java/com/artipie/http/hm/SliceHasResponse.java
+++ b/artipie-core/src/main/java/com/artipie/http/hm/SliceHasResponse.java
@@ -9,13 +9,13 @@
import com.artipie.http.Response;
import com.artipie.http.Slice;
import com.artipie.http.rq.RequestLine;
-import com.artipie.http.rs.CachedResponse;
import io.reactivex.Flowable;
-import java.util.function.Function;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;
+import java.util.function.Function;
+
/**
* Matcher for {@link Slice} response.
* @since 0.16
@@ -35,7 +35,7 @@ public final class SliceHasResponse extends TypeSafeMatcher {
/**
* Response cache.
*/
- private Response rcache;
+ private Response response;
/**
* New response matcher for slice with request line.
@@ -53,8 +53,7 @@ public SliceHasResponse(final Matcher extends Response> rsp, final RequestLine
* @param headers Headers
* @param line Request line
*/
- public SliceHasResponse(final Matcher extends Response> rsp, final Headers headers,
- final RequestLine line) {
+ public SliceHasResponse(Matcher extends Response> rsp, Headers headers, RequestLine line) {
this(rsp, line, headers, new Content.From(Flowable.empty()));
}
@@ -64,12 +63,15 @@ public SliceHasResponse(final Matcher extends Response> rsp, final Headers hea
* @param line Request line
* @param headers Headers
* @param body Body
- * @checkstyle ParameterNumberCheck (5 lines)
*/
- public SliceHasResponse(final Matcher extends Response> rsp, final RequestLine line,
- final Headers headers, final Content body) {
+ public SliceHasResponse(
+ Matcher extends Response> rsp,
+ RequestLine line,
+ Headers headers,
+ Content body
+ ) {
this.rsp = rsp;
- this.responser = slice -> slice.response(line.toString(), headers, body);
+ this.responser = slice -> slice.response(line, headers, body).join();
}
@Override
@@ -93,9 +95,9 @@ public void describeMismatchSafely(final Slice item, final Description descripti
* @return Cached response
*/
private Response response(final Slice slice) {
- if (this.rcache == null) {
- this.rcache = new CachedResponse(this.responser.apply(slice));
+ if (this.response == null) {
+ this.response = this.responser.apply(slice);
}
- return this.rcache;
+ return this.response;
}
}
diff --git a/artipie-core/src/main/java/com/artipie/http/log/EcsLogEvent.java b/artipie-core/src/main/java/com/artipie/http/log/EcsLogEvent.java
new file mode 100644
index 000000000..d7db6386b
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/http/log/EcsLogEvent.java
@@ -0,0 +1,489 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.http.log;
+
+import com.artipie.http.Headers;
+import com.artipie.http.RsStatus;
+import com.artipie.http.headers.Header;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.message.MapMessage;
+import org.slf4j.MDC;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * ECS (Elastic Common Schema) compliant log event builder for HTTP requests.
+ *
+ * Significantly reduces log volume by:
+ *
+ * Only logging errors and slow requests at WARN/ERROR level
+ * Success requests logged at DEBUG level (disabled in production)
+ * Using structured fields instead of verbose messages
+ *
+ *
+ * @see ECS Reference
+ * @since 1.18.23
+ */
+public final class EcsLogEvent {
+
+ private static final org.apache.logging.log4j.Logger LOGGER = LogManager.getLogger("http.access");
+
+ /**
+ * Latency threshold for slow request warnings (ms).
+ */
+ private static final long SLOW_REQUEST_THRESHOLD_MS = 5000;
+
+ // All ECS fields stored flat with dot notation for proper JSON serialization
+ private String message;
+ private final Map fields = new HashMap<>();
+
+ /**
+ * Create new log event builder.
+ */
+ public EcsLogEvent() {
+ fields.put("event.kind", "event");
+ fields.put("event.category", "web");
+ fields.put("event.type", "access");
+ // Add data stream fields (ECS data_stream.*)
+ fields.put("data_stream.type", "logs");
+ fields.put("data_stream.dataset", "artipie.log");
+ }
+
+ /**
+ * Set HTTP request method (GET, POST, etc.).
+ * @param method HTTP method
+ * @return this
+ */
+ public EcsLogEvent httpMethod(final String method) {
+ fields.put("http.request.method", method);
+ return this;
+ }
+
+ /**
+ * Set HTTP version (1.1, 2.0, etc.).
+ * @param version HTTP version
+ * @return this
+ */
+ public EcsLogEvent httpVersion(final String version) {
+ fields.put("http.version", version);
+ return this;
+ }
+
+ /**
+ * Set HTTP response status code.
+ * @param status Status code
+ * @return this
+ */
+ public EcsLogEvent httpStatus(final RsStatus status) {
+ fields.put("http.response.status_code", status.code());
+ return this;
+ }
+
+ /**
+ * Set HTTP response body size in bytes.
+ * @param bytes Body size
+ * @return this
+ */
+ public EcsLogEvent httpResponseBytes(final long bytes) {
+ fields.put("http.response.body.bytes", bytes);
+ return this;
+ }
+
+ /**
+ * Set request duration in milliseconds.
+ * @param durationMs Duration
+ * @return this
+ */
+ public EcsLogEvent duration(final long durationMs) {
+ fields.put("event.duration", durationMs * 1_000_000); // Convert to nanoseconds (ECS standard)
+ return this;
+ }
+
+ /**
+ * Set client IP address.
+ * @param ip Client IP
+ * @return this
+ */
+ public EcsLogEvent clientIp(final String ip) {
+ fields.put("client.ip", ip);
+ return this;
+ }
+
+ /**
+ * Set client port.
+ * @param port Client port
+ * @return this
+ */
+ public EcsLogEvent clientPort(final int port) {
+ fields.put("client.port", port);
+ return this;
+ }
+
+ /**
+ * Set user agent from headers (parsed according to ECS schema).
+ * @param headers Request headers
+ * @return this
+ */
+ public EcsLogEvent userAgent(final Headers headers) {
+ for (Header h : headers.find("user-agent")) {
+ final String original = h.getValue();
+ if (original != null && !original.isEmpty()) {
+ fields.put("user_agent.original", original);
+
+ // Parse user agent (basic parsing - can be enhanced with ua-parser library)
+ final UserAgentInfo info = parseUserAgent(original);
+ if (info.name != null) {
+ fields.put("user_agent.name", info.name);
+ }
+ if (info.version != null) {
+ fields.put("user_agent.version", info.version);
+ }
+ if (info.osName != null) {
+ fields.put("user_agent.os.name", info.osName);
+ if (info.osVersion != null) {
+ fields.put("user_agent.os.version", info.osVersion);
+ }
+ }
+ if (info.deviceName != null) {
+ fields.put("user_agent.device.name", info.deviceName);
+ }
+ }
+ break;
+ }
+ return this;
+ }
+
+ /**
+ * Set authenticated username.
+ * @param username Username
+ * @return this
+ */
+ public EcsLogEvent userName(final String username) {
+ if (username != null && !username.isEmpty()) {
+ fields.put("user.name", username);
+ }
+ return this;
+ }
+
+ /**
+ * Set request URL path (sanitized).
+ * @param path URL path
+ * @return this
+ */
+ public EcsLogEvent urlPath(final String path) {
+ fields.put("url.path", LogSanitizer.sanitizeUrl(path));
+ return this;
+ }
+
+ /**
+ * Set query string (sanitized).
+ * @param query Query string
+ * @return this
+ */
+ public EcsLogEvent urlQuery(final String query) {
+ if (query != null && !query.isEmpty()) {
+ fields.put("url.query", LogSanitizer.sanitizeUrl(query));
+ }
+ return this;
+ }
+
+ /**
+ * Set destination address for proxy requests.
+ * @param address Remote address
+ * @return this
+ */
+ public EcsLogEvent destinationAddress(final String address) {
+ if (address != null && !address.isEmpty()) {
+ fields.put("destination.address", address);
+ }
+ return this;
+ }
+
+ /**
+ * Set destination port for proxy requests.
+ * @param port Remote port
+ * @return this
+ */
+ public EcsLogEvent destinationPort(final int port) {
+ fields.put("destination.port", port);
+ return this;
+ }
+
+ /**
+ * Set event outcome (success, failure, unknown).
+ * @param outcome Outcome
+ * @return this
+ */
+ public EcsLogEvent outcome(final String outcome) {
+ fields.put("event.outcome", outcome);
+ return this;
+ }
+
+ /**
+ * Set custom message.
+ * @param msg Message
+ * @return this
+ */
+ public EcsLogEvent message(final String msg) {
+ this.message = msg;
+ return this;
+ }
+
+ /**
+ * Add error details (ECS-compliant).
+ * Captures exception message, fully qualified type, and full stack trace.
+ *
+ * @param error Error/Exception
+ * @return this
+ * @see ECS Error Fields
+ */
+ public EcsLogEvent error(final Throwable error) {
+ // ECS error.message - The error message
+ fields.put("error.message", error.getMessage() != null ? error.getMessage() : error.toString());
+
+ // ECS error.type - Fully qualified class name for better categorization
+ fields.put("error.type", error.getClass().getName());
+
+ // ECS error.stack_trace - Full stack trace as string
+ fields.put("error.stack_trace", getStackTrace(error));
+
+ fields.put("event.outcome", "failure");
+ return this;
+ }
+
+ /**
+ * Log at appropriate level based on outcome.
+ *
+ * Strategy to reduce log volume:
+ *
+ * ERROR (>= 500): Always log at ERROR level
+ * WARN (>= 400 or slow >5s): Log at WARN level
+ * SUCCESS (< 400): Log at DEBUG level (production: disabled)
+ *
+ */
+ public void log() {
+ // Add trace.id from MDC if available (ECS tracing field)
+ final String traceId = MDC.get("trace.id");
+ if (traceId != null && !traceId.isEmpty()) {
+ fields.put("trace.id", traceId);
+ }
+
+ // Determine log level based on status and duration
+ final Integer statusCode = (Integer) fields.get("http.response.status_code");
+ final Long durationNs = (Long) fields.get("event.duration");
+ final long durationMs = durationNs != null ? durationNs / 1_000_000 : 0;
+
+ final String logMessage = this.message != null
+ ? this.message
+ : buildDefaultMessage(statusCode);
+
+ // Create MapMessage with all fields for structured JSON output
+ final MapMessage mapMessage = new MapMessage(fields);
+ mapMessage.with("message", logMessage);
+
+ final boolean failureOutcome = "failure".equals(fields.get("event.outcome"));
+ if (statusCode != null && statusCode >= 500) {
+ mapMessage.with("event.severity", "critical");
+ LOGGER.info(mapMessage);
+ } else if (statusCode != null && statusCode >= 400) {
+ mapMessage.with("event.severity", "warning");
+ LOGGER.info(mapMessage);
+ } else if (durationMs > SLOW_REQUEST_THRESHOLD_MS) {
+ mapMessage.with("event.severity", "warning");
+ mapMessage.with("message", String.format("Slow request: %dms - %s", durationMs, logMessage));
+ LOGGER.info(mapMessage);
+ } else if (failureOutcome) {
+ LOGGER.info(mapMessage);
+ } else {
+ LOGGER.debug(mapMessage);
+ }
+ }
+
+ /**
+ * Build default message from ECS fields.
+ */
+ private String buildDefaultMessage(final Integer statusCode) {
+ final String method = (String) fields.get("http.request.method");
+ final String path = (String) fields.get("url.path");
+ return String.format("%s %s %d",
+ method != null ? method : "?",
+ path != null ? path : "?",
+ statusCode != null ? statusCode : 0
+ );
+ }
+
+ /**
+ * Get stack trace as string.
+ */
+ private static String getStackTrace(final Throwable error) {
+ final java.io.StringWriter sw = new java.io.StringWriter();
+ error.printStackTrace(new java.io.PrintWriter(sw));
+ return sw.toString();
+ }
+
+ /**
+ * Extract IP from X-Forwarded-For or remote address.
+ * @param headers Request headers
+ * @param remoteAddress Fallback remote address
+ * @return Client IP
+ */
+ public static String extractClientIp(final Headers headers, final String remoteAddress) {
+ // Check X-Forwarded-For first
+ for (Header h : headers.find("x-forwarded-for")) {
+ final String value = h.getValue();
+ if (value != null && !value.isEmpty()) {
+ // Get first IP in list (original client)
+ final int comma = value.indexOf(',');
+ return comma > 0 ? value.substring(0, comma).trim() : value.trim();
+ }
+ }
+ // Check X-Real-IP
+ for (Header h : headers.find("x-real-ip")) {
+ return h.getValue();
+ }
+ // Fallback to remote address
+ return remoteAddress;
+ }
+
+ /**
+ * Extract username from Authorization header.
+ * @param headers Request headers
+ * @return Username or null
+ */
+ public static Optional extractUsername(final Headers headers) {
+ for (Header h : headers.find("authorization")) {
+ final String value = h.getValue();
+ if (value != null && value.toLowerCase().startsWith("basic ")) {
+ try {
+ final String decoded = new String(
+ java.util.Base64.getDecoder().decode(value.substring(6))
+ );
+ final int colon = decoded.indexOf(':');
+ if (colon > 0) {
+ return Optional.of(decoded.substring(0, colon));
+ }
+ } catch (IllegalArgumentException e) {
+ // Invalid base64, ignore
+ }
+ }
+ // For Bearer tokens, we don't extract username (would need token validation)
+ }
+ return Optional.empty();
+ }
+
+ /**
+ * Parse user agent string into ECS components.
+ * Basic implementation focusing on common package managers and CI/CD tools.
+ *
+ * @param ua User agent string
+ * @return Parsed user agent info
+ */
+ private static UserAgentInfo parseUserAgent(final String ua) {
+ final UserAgentInfo info = new UserAgentInfo();
+
+ if (ua == null || ua.isEmpty()) {
+ return info;
+ }
+
+ // Common package manager patterns
+ if (ua.startsWith("Maven/")) {
+ info.name = "Maven";
+ extractVersion(ua, "Maven/", info);
+ } else if (ua.startsWith("npm/")) {
+ info.name = "npm";
+ extractVersion(ua, "npm/", info);
+ } else if (ua.startsWith("pip/")) {
+ info.name = "pip";
+ extractVersion(ua, "pip/", info);
+ } else if (ua.contains("Docker-Client/")) {
+ info.name = "Docker";
+ extractVersion(ua, "Docker-Client/", info);
+ } else if (ua.startsWith("Go-http-client/")) {
+ info.name = "Go";
+ extractVersion(ua, "Go-http-client/", info);
+ } else if (ua.startsWith("Gradle/")) {
+ info.name = "Gradle";
+ extractVersion(ua, "Gradle/", info);
+ } else if (ua.contains("Composer/")) {
+ info.name = "Composer";
+ extractVersion(ua, "Composer/", info);
+ } else if (ua.startsWith("NuGet")) {
+ info.name = "NuGet";
+ if (ua.contains("/")) {
+ extractVersion(ua, "NuGet Command Line/", info);
+ }
+ } else if (ua.contains("curl/")) {
+ info.name = "curl";
+ extractVersion(ua, "curl/", info);
+ } else if (ua.contains("wget/")) {
+ info.name = "wget";
+ extractVersion(ua, "wget/", info);
+ }
+
+ // Extract OS information
+ if (ua.contains("Linux")) {
+ info.osName = "Linux";
+ } else if (ua.contains("Windows")) {
+ info.osName = "Windows";
+ } else if (ua.contains("Mac OS X") || ua.contains("Darwin")) {
+ info.osName = "macOS";
+ } else if (ua.contains("FreeBSD")) {
+ info.osName = "FreeBSD";
+ }
+
+ // Extract Java version if present
+ if (ua.contains("Java/")) {
+ final int start = ua.indexOf("Java/") + 5;
+ final int end = findVersionEnd(ua, start);
+ if (end > start) {
+ info.osVersion = ua.substring(start, end);
+ }
+ }
+
+ return info;
+ }
+
+ /**
+ * Extract version from user agent string.
+ */
+ private static void extractVersion(final String ua, final String prefix, final UserAgentInfo info) {
+ final int start = ua.indexOf(prefix);
+ if (start >= 0) {
+ final int versionStart = start + prefix.length();
+ final int versionEnd = findVersionEnd(ua, versionStart);
+ if (versionEnd > versionStart) {
+ info.version = ua.substring(versionStart, versionEnd);
+ }
+ }
+ }
+
+ /**
+ * Find end of version string (space, semicolon, or parenthesis).
+ */
+ private static int findVersionEnd(final String ua, final int start) {
+ int end = start;
+ while (end < ua.length()) {
+ final char c = ua.charAt(end);
+ if (c == ' ' || c == ';' || c == '(' || c == ')') {
+ break;
+ }
+ end++;
+ }
+ return end;
+ }
+
+ /**
+ * Parsed user agent information.
+ */
+ private static final class UserAgentInfo {
+ String name;
+ String version;
+ String osName;
+ String osVersion;
+ String deviceName;
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/http/log/EcsLogger.java b/artipie-core/src/main/java/com/artipie/http/log/EcsLogger.java
new file mode 100644
index 000000000..c2a7284bd
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/http/log/EcsLogger.java
@@ -0,0 +1,305 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.http.log;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.message.MapMessage;
+import org.slf4j.MDC;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * ECS (Elastic Common Schema) compliant logger for non-HTTP application logs.
+ *
+ * Provides structured logging with proper ECS field mapping and automatic
+ * trace.id propagation from MDC. Use this instead of plain Logger calls to ensure
+ * all logs are ECS-compliant and contain trace context.
+ *
+ *
Usage examples:
+ *
{@code
+ * // Simple message
+ * EcsLogger.info("com.artipie.maven")
+ * .message("Metadata rebuild queued")
+ * .field("package.group", "com.example")
+ * .field("package.name", "my-artifact")
+ * .log();
+ *
+ * // With error
+ * EcsLogger.error("com.artipie.npm")
+ * .message("Package processing failed")
+ * .error(exception)
+ * .field("package.name", packageName)
+ * .log();
+ *
+ * // With event metadata
+ * EcsLogger.warn("com.artipie.docker")
+ * .message("Slow cache operation")
+ * .eventCategory("storage")
+ * .eventAction("cache_read")
+ * .duration(durationMs)
+ * .log();
+ * }
+ *
+ * @see ECS Reference
+ * @since 1.18.24
+ */
+public final class EcsLogger {
+
+ private final org.apache.logging.log4j.Logger logger;
+ private final LogLevel level;
+ private String message;
+ private final Map fields = new HashMap<>();
+
+ /**
+ * Log levels matching ECS and user requirements.
+ */
+ public enum LogLevel {
+ /** Code tracing - very detailed execution flow */
+ TRACE,
+ /** Diagnostic information for debugging */
+ DEBUG,
+ /** Production default - important business events */
+ INFO,
+ /** Recoverable issues that don't prevent operation */
+ WARN,
+ /** Operation failures that need attention */
+ ERROR,
+ /** Catastrophic errors requiring immediate action */
+ FATAL
+ }
+
+ /**
+ * Private constructor - use static factory methods.
+ * @param loggerName Logger name (usually class or package name)
+ * @param level Log level
+ */
+ private EcsLogger(final String loggerName, final LogLevel level) {
+ this.logger = LogManager.getLogger(loggerName);
+ this.level = level;
+ }
+
+ /**
+ * Create TRACE level logger.
+ * @param loggerName Logger name
+ * @return Logger builder
+ */
+ public static EcsLogger trace(final String loggerName) {
+ return new EcsLogger(loggerName, LogLevel.TRACE);
+ }
+
+ /**
+ * Create DEBUG level logger.
+ * @param loggerName Logger name
+ * @return Logger builder
+ */
+ public static EcsLogger debug(final String loggerName) {
+ return new EcsLogger(loggerName, LogLevel.DEBUG);
+ }
+
+ /**
+ * Create INFO level logger.
+ * @param loggerName Logger name
+ * @return Logger builder
+ */
+ public static EcsLogger info(final String loggerName) {
+ return new EcsLogger(loggerName, LogLevel.INFO);
+ }
+
+ /**
+ * Create WARN level logger.
+ * @param loggerName Logger name
+ * @return Logger builder
+ */
+ public static EcsLogger warn(final String loggerName) {
+ return new EcsLogger(loggerName, LogLevel.WARN);
+ }
+
+ /**
+ * Create ERROR level logger.
+ * @param loggerName Logger name
+ * @return Logger builder
+ */
+ public static EcsLogger error(final String loggerName) {
+ return new EcsLogger(loggerName, LogLevel.ERROR);
+ }
+
+ /**
+ * Create FATAL level logger (logged as ERROR with fatal marker).
+ * @param loggerName Logger name
+ * @return Logger builder
+ */
+ public static EcsLogger fatal(final String loggerName) {
+ return new EcsLogger(loggerName, LogLevel.FATAL);
+ }
+
+ /**
+ * Set log message.
+ * @param msg Message
+ * @return this
+ */
+ public EcsLogger message(final String msg) {
+ this.message = msg;
+ return this;
+ }
+
+ /**
+ * Add error details (ECS-compliant).
+ * @param error Error/Exception
+ * @return this
+ */
+ public EcsLogger error(final Throwable error) {
+ this.fields.put("error.message", error.getMessage() != null ? error.getMessage() : error.toString());
+ this.fields.put("error.type", error.getClass().getName());
+ this.fields.put("error.stack_trace", getStackTrace(error));
+ this.fields.put("event.outcome", "failure");
+ return this;
+ }
+
+ /**
+ * Set event category (e.g., "storage", "authentication", "database").
+ * @param category Event category
+ * @return this
+ */
+ public EcsLogger eventCategory(final String category) {
+ this.fields.put("event.category", category);
+ return this;
+ }
+
+ /**
+ * Set event action (e.g., "cache_read", "metadata_rebuild", "user_login").
+ * @param action Event action
+ * @return this
+ */
+ public EcsLogger eventAction(final String action) {
+ this.fields.put("event.action", action);
+ return this;
+ }
+
+ /**
+ * Set event outcome (success, failure, unknown).
+ * @param outcome Outcome
+ * @return this
+ */
+ public EcsLogger eventOutcome(final String outcome) {
+ this.fields.put("event.outcome", outcome);
+ return this;
+ }
+
+ /**
+ * Set operation duration in milliseconds.
+ * @param durationMs Duration
+ * @return this
+ */
+ public EcsLogger duration(final long durationMs) {
+ this.fields.put("event.duration", durationMs * 1_000_000); // Convert to nanoseconds (ECS standard)
+ return this;
+ }
+
+ /**
+ * Add custom field using ECS dot notation for nested fields.
+ * Use dot notation for nested fields (e.g., "maven.group_id", "npm.package_name").
+ *
+ * @param key Field key
+ * @param value Field value
+ * @return this
+ */
+ public EcsLogger field(final String key, final Object value) {
+ if (value != null) {
+ this.fields.put(key, value);
+ }
+ return this;
+ }
+
+ /**
+ * Add user name (authenticated user).
+ * @param username Username
+ * @return this
+ */
+ public EcsLogger userName(final String username) {
+ if (username != null && !username.isEmpty()) {
+ this.fields.put("user.name", username);
+ }
+ return this;
+ }
+
+ /**
+ * Log the event at the configured level.
+ * Uses Log4j2 MapMessage for proper structured JSON output with ECS fields.
+ */
+ public void log() {
+ // Add trace context from MDC if available (ECS tracing fields)
+ // These are set by EcsLoggingSlice at request start for request correlation
+ final String traceId = MDC.get("trace.id");
+ if (traceId != null && !traceId.isEmpty()) {
+ this.fields.put("trace.id", traceId);
+ }
+
+ // Add client.ip from MDC if not already set
+ if (!this.fields.containsKey("client.ip")) {
+ final String clientIp = MDC.get("client.ip");
+ if (clientIp != null && !clientIp.isEmpty()) {
+ this.fields.put("client.ip", clientIp);
+ }
+ }
+
+ // Add user.name from MDC if not already set
+ if (!this.fields.containsKey("user.name")) {
+ final String userName = MDC.get("user.name");
+ if (userName != null && !userName.isEmpty()) {
+ this.fields.put("user.name", userName);
+ }
+ }
+
+ // Add data stream fields (ECS data_stream.*)
+ this.fields.put("data_stream.type", "logs");
+ this.fields.put("data_stream.dataset", "artipie.log");
+
+ // Create MapMessage with all fields for structured JSON output
+ final MapMessage mapMessage = new MapMessage(this.fields);
+
+ // Set the message text
+ final String logMessage = this.message != null ? this.message : "Application event";
+ mapMessage.with("message", logMessage);
+
+ // Log at appropriate level using MapMessage for structured output
+ switch (this.level) {
+ case TRACE:
+ if (this.logger.isTraceEnabled()) {
+ this.logger.trace(mapMessage);
+ }
+ break;
+ case DEBUG:
+ if (this.logger.isDebugEnabled()) {
+ this.logger.debug(mapMessage);
+ }
+ break;
+ case INFO:
+ this.logger.info(mapMessage);
+ break;
+ case WARN:
+ this.logger.warn(mapMessage);
+ break;
+ case ERROR:
+ this.logger.error(mapMessage);
+ break;
+ case FATAL:
+ // FATAL logged as ERROR with special marker
+ mapMessage.with("event.severity", "fatal");
+ this.logger.error(mapMessage);
+ break;
+ }
+ }
+
+ /**
+ * Get stack trace as string.
+ */
+ private static String getStackTrace(final Throwable error) {
+ final java.io.StringWriter sw = new java.io.StringWriter();
+ error.printStackTrace(new java.io.PrintWriter(sw));
+ return sw.toString();
+ }
+}
+
diff --git a/artipie-core/src/main/java/com/artipie/http/log/LogSanitizer.java b/artipie-core/src/main/java/com/artipie/http/log/LogSanitizer.java
new file mode 100644
index 000000000..778a8e7b8
--- /dev/null
+++ b/artipie-core/src/main/java/com/artipie/http/log/LogSanitizer.java
@@ -0,0 +1,201 @@
+/*
+ * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
+ * https://github.com/artipie/artipie/blob/master/LICENSE.txt
+ */
+package com.artipie.http.log;
+
+import com.artipie.http.Headers;
+import com.artipie.http.headers.Header;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.regex.Pattern;
+
+/**
+ * Sanitizes sensitive information from logs (headers, URLs, etc.).
+ * Masks authorization tokens, API keys, passwords, and other credentials.
+ *
+ * @since 1.18.15
+ */
+public final class LogSanitizer {
+
+ /**
+ * Sensitive header names that should be masked.
+ */
+ private static final List SENSITIVE_HEADERS = List.of(
+ "authorization",
+ "x-api-key",
+ "x-auth-token",
+ "x-access-token",
+ "cookie",
+ "set-cookie",
+ "proxy-authorization",
+ "www-authenticate",
+ "proxy-authenticate",
+ "x-csrf-token",
+ "x-xsrf-token"
+ );
+
+ /**
+ * Pattern for Bearer tokens in Authorization header.
+ */
+ private static final Pattern BEARER_PATTERN = Pattern.compile(
+ "(Bearer\\s+)[A-Za-z0-9\\-._~+/]+=*",
+ Pattern.CASE_INSENSITIVE
+ );
+
+ /**
+ * Pattern for Basic auth in Authorization header.
+ */
+ private static final Pattern BASIC_PATTERN = Pattern.compile(
+ "(Basic\\s+)[A-Za-z0-9+/]+=*",
+ Pattern.CASE_INSENSITIVE
+ );
+
+ /**
+ * Pattern for API keys in URLs.
+ */
+ private static final Pattern URL_API_KEY_PATTERN = Pattern.compile(
+ "([?&](?:api[_-]?key|token|access[_-]?token|auth[_-]?token)=)[^&\\s]+",
+ Pattern.CASE_INSENSITIVE
+ );
+
+ /**
+ * Mask to use for sensitive data.
+ */
+ private static final String MASK = "***REDACTED***";
+
+ /**
+ * Private constructor - utility class.
+ */
+ private LogSanitizer() {
+ }
+
+ /**
+ * Sanitize HTTP headers for logging.
+ * Masks sensitive header values while preserving structure.
+ *
+ * @param headers Original headers
+ * @return Sanitized headers safe for logging
+ */
+ public static Headers sanitizeHeaders(final Headers headers) {
+ final List sanitized = new ArrayList<>();
+ for (final Header header : headers) {
+ final String name = header.getKey();
+ final String value = header.getValue();
+
+ if (isSensitiveHeader(name)) {
+ sanitized.add(new Header(name, maskValue(value)));
+ } else {
+ sanitized.add(header);
+ }
+ }
+ return new Headers(sanitized);
+ }
+
+ /**
+ * Sanitize a URL for logging by masking query parameters with sensitive names.
+ *
+ * @param url Original URL
+ * @return Sanitized URL safe for logging
+ */
+ public static String sanitizeUrl(final String url) {
+ if (url == null || url.isEmpty()) {
+ return url;
+ }
+ return URL_API_KEY_PATTERN.matcher(url).replaceAll("$1" + MASK);
+ }
+
+ /**
+ * Sanitize an authorization header value specifically.
+ * Handles Bearer, Basic, and other auth schemes.
+ *
+ * @param authValue Authorization header value
+ * @return Sanitized value showing only auth type
+ */
+ public static String sanitizeAuthHeader(final String authValue) {
+ if (authValue == null || authValue.isEmpty()) {
+ return authValue;
+ }
+
+ String result = authValue;
+
+ // Mask Bearer tokens
+ result = BEARER_PATTERN.matcher(result).replaceAll("$1" + MASK);
+
+ // Mask Basic auth
+ result = BASIC_PATTERN.matcher(result).replaceAll("$1" + MASK);
+
+ // If no pattern matched but it looks like auth, mask everything after first space
+ if (result.equals(authValue) && authValue.contains(" ")) {
+ final int spaceIdx = authValue.indexOf(' ');
+ result = authValue.substring(0, spaceIdx + 1) + MASK;
+ }
+
+ return result;
+ }
+
+ /**
+ * Sanitize a generic string that might contain sensitive data.
+ * Useful for error messages, log messages, etc.
+ *
+ * @param message Original message
+ * @return Sanitized message
+ */
+ public static String sanitizeMessage(final String message) {
+ if (message == null || message.isEmpty()) {
+ return message;
+ }
+
+ String result = message;
+
+ // Mask Bearer tokens
+ result = BEARER_PATTERN.matcher(result).replaceAll("$1" + MASK);
+
+ // Mask Basic auth
+ result = BASIC_PATTERN.matcher(result).replaceAll("$1" + MASK);
+
+ // Mask API keys in text
+ result = result.replaceAll(
+ "(?i)(api[_-]?key|token|password|secret)[\"']?\\s*[:=]\\s*[\"']?[A-Za-z0-9\\-._~+/]+",
+ "$1=" + MASK
+ );
+
+ return result;
+ }
+
+ /**
+ * Check if a header name is sensitive and should be masked.
+ *
+ * @param headerName Header name to check
+ * @return True if header is sensitive
+ */
+ private static boolean isSensitiveHeader(final String headerName) {
+ final String lower = headerName.toLowerCase(Locale.US);
+ return SENSITIVE_HEADERS.stream().anyMatch(lower::equals);
+ }
+
+ /**
+ * Mask a header value, showing only type/prefix if applicable.
+ *
+ * @param value Original value
+ * @return Masked value
+ */
+ private static String maskValue(final String value) {
+ if (value == null || value.isEmpty()) {
+ return value;
+ }
+
+ // For auth headers, preserve the auth type
+ if (value.toLowerCase(Locale.US).startsWith("bearer ")) {
+ return "Bearer " + MASK;
+ }
+ if (value.toLowerCase(Locale.US).startsWith("basic ")) {
+ return "Basic " + MASK;
+ }
+
+ // For other sensitive values, mask completely
+ return MASK;
+ }
+}
diff --git a/artipie-core/src/main/java/com/artipie/http/misc/BufAccumulator.java b/artipie-core/src/main/java/com/artipie/http/misc/BufAccumulator.java
index d354268b2..442930f08 100644
--- a/artipie-core/src/main/java/com/artipie/http/misc/BufAccumulator.java
+++ b/artipie-core/src/main/java/com/artipie/http/misc/BufAccumulator.java
@@ -152,7 +152,6 @@ public byte[] array() {
@SuppressWarnings("PMD.NullAssignment")
public void close() {
this.check();
- // @checkstyle MethodBodyCommentsCheck (1 lines)
// assign to null means broken state, it's verified by `check` method.
this.buffer = null;
}
diff --git a/artipie-core/src/main/java/com/artipie/http/misc/ByteBufferTokenizer.java b/artipie-core/src/main/java/com/artipie/http/misc/ByteBufferTokenizer.java
index 4baac00c8..e2197d476 100644
--- a/artipie-core/src/main/java/com/artipie/http/misc/ByteBufferTokenizer.java
+++ b/artipie-core/src/main/java/com/artipie/http/misc/ByteBufferTokenizer.java
@@ -30,7 +30,6 @@
* @implNote The state could be broken in case of runtime exception occurs during
* the tokenization process, it couldn't be recovered and should not be used after failure
* @since 1.0
- * @checkstyle MethodBodyCommentsCheck (500 lines)
*/
@NotThreadSafe
public final class ByteBufferTokenizer implements Closeable {
@@ -161,7 +160,6 @@ private void flush() {
*/
private static int indexOf(final int offset, final byte[] array, final byte[] token) {
int res = -1;
- // @checkstyle LocalVariableNameCheck (10 lines)
TOP: for (int i = offset; i < array.length - token.length + 1; ++i) {
boolean found = true;
for (int j = 0; j < token.length; ++j) {
diff --git a/artipie-core/src/main/java/com/artipie/http/misc/Pipeline.java b/artipie-core/src/main/java/com/artipie/http/misc/Pipeline.java
index 3345699c8..aa6aa2f6f 100644
--- a/artipie-core/src/main/java/com/artipie/http/misc/Pipeline.java
+++ b/artipie-core/src/main/java/com/artipie/http/misc/Pipeline.java
@@ -131,7 +131,6 @@ public void request(final long amt) {
/**
* Check if all required parts are connected, and request from upstream if so.
- * @checkstyle MethodBodyCommentsCheck (10 lines)
*/
private void checkRequest() {
synchronized (this.lock) {
diff --git a/artipie-core/src/main/java/com/artipie/http/misc/RandomFreePort.java b/artipie-core/src/main/java/com/artipie/http/misc/RandomFreePort.java
index 51cc7276f..1020fd31c 100644
--- a/artipie-core/src/main/java/com/artipie/http/misc/RandomFreePort.java
+++ b/artipie-core/src/main/java/com/artipie/http/misc/RandomFreePort.java
@@ -10,15 +10,14 @@
/**
* Provides random free port.
- * @since 0.18
- * @checkstyle NonStaticMethodCheck (500 lines)
*/
public final class RandomFreePort {
/**
* Returns free port.
+ *
* @return Free port.
*/
- public int get() {
+ public static int get() {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
} catch (final IOException exc) {
diff --git a/artipie-core/src/main/java/com/artipie/http/misc/TokenizerFlatProc.java b/artipie-core/src/main/java/com/artipie/http/misc/TokenizerFlatProc.java
index 827d4bee8..e3c16189d 100644
--- a/artipie-core/src/main/java/com/artipie/http/misc/TokenizerFlatProc.java
+++ b/artipie-core/src/main/java/com/artipie/http/misc/TokenizerFlatProc.java
@@ -182,8 +182,7 @@ public void cancel() {
/**
* Notify item received.
- * @checkstyle NonStaticMethodCheck (10 lines)
- */
+ */
public void receive() {
// not implemented
}
diff --git a/artipie-core/src/main/java/com/artipie/http/rq/RequestLine.java b/artipie-core/src/main/java/com/artipie/http/rq/RequestLine.java
index d9d02a178..ee20a31b0 100644
--- a/artipie-core/src/main/java/com/artipie/http/rq/RequestLine.java
+++ b/artipie-core/src/main/java/com/artipie/http/rq/RequestLine.java
@@ -4,23 +4,39 @@
*/
package com.artipie.http.rq;
+import java.net.URI;
+import java.util.Objects;
+
/**
- * Http Request Line.
+ * Request line helper object.
+ *
+ * See 5.1 section of RFC2616:
+ *
+ * The Request-Line begins with a method token,
+ * followed by the Request-URI and the protocol version,
+ * and ending with {@code CRLF}.
+ * The elements are separated by SP characters.
+ * No {@code CR} or {@code LF} is allowed except in the final {@code CRLF} sequence.
*
- * See: 5.1 https://www.w3.org/Protocols/rfc2616/rfc2616-sec5.html
- * @since 0.1
+ * {@code Request-Line = Method SP Request-URI SP HTTP-Version CRLF}.
+ * @see RFC2616
*/
public final class RequestLine {
+ public static RequestLine from(String line) {
+ RequestLineFrom from = new RequestLineFrom(line);
+ return new RequestLine(from.method(), from.uri(), from.version());
+ }
+
/**
* The request method.
*/
- private final String method;
+ private final RqMethod method;
/**
* The request uri.
*/
- private final String uri;
+ private final URI uri;
/**
* The Http version.
@@ -28,39 +44,95 @@ public final class RequestLine {
private final String version;
/**
- * Ctor.
- *
* @param method Request method.
* @param uri Request URI.
*/
- public RequestLine(final RqMethod method, final String uri) {
+ public RequestLine(RqMethod method, String uri) {
this(method.value(), uri);
}
/**
- * Ctor.
- *
* @param method Request method.
* @param uri Request URI.
*/
- public RequestLine(final String method, final String uri) {
+ public RequestLine(String method, String uri) {
this(method, uri, "HTTP/1.1");
}
/**
- * Ctor.
* @param method The http method.
* @param uri The http uri.
* @param version The http version.
*/
- public RequestLine(final String method, final String uri, final String version) {
+ public RequestLine(String method, String uri, String version) {
+ this(RqMethod.valueOf(method.toUpperCase()), URI.create(sanitizeUri(uri)), version);
+ }
+
+ /**
+ * Sanitize URI by encoding illegal characters.
+ * Handles cases like Maven artifacts with unresolved properties: ${version}
+ * @param uri Raw URI string
+ * @return Sanitized URI string safe for URI.create()
+ */
+ private static String sanitizeUri(String uri) {
+ // Handle empty or malformed URIs
+ if (uri == null || uri.isEmpty()) {
+ return "/";
+ }
+
+ // Handle "//" which URI parser interprets as start of authority (hostname)
+ // but with no actual authority, causing "Expected authority" error
+ if ("//".equals(uri)) {
+ return "/";
+ }
+
+ // Replace illegal characters that commonly appear in badly-formed requests
+ // $ is illegal in URI paths without encoding
+ // Maven properties like ${commons-support.version} should be %24%7B...%7D
+ // Maven version ranges like [release] should be %5B...%5D
+ return uri
+ .replace("$", "%24") // $ → %24
+ .replace("{", "%7B") // { → %7B
+ .replace("}", "%7D") // } → %7D
+ .replace("[", "%5B") // [ → %5B (Maven version ranges)
+ .replace("]", "%5D") // ] → %5D (Maven version ranges)
+ .replace("|", "%7C") // | → %7C (also commonly problematic)
+ .replace("\\", "%5C"); // \ → %5C (Windows paths)
+ }
+
+ public RequestLine(RqMethod method, URI uri, String version) {
this.method = method;
this.uri = uri;
this.version = version;
}
+ public RqMethod method() {
+ return method;
+ }
+
+ public URI uri() {
+ return uri;
+ }
+
+ public String version() {
+ return version;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ RequestLine that = (RequestLine) o;
+ return method == that.method && Objects.equals(uri, that.uri) && Objects.equals(version, that.version);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(method, uri, version);
+ }
+
@Override
public String toString() {
- return String.format("%s %s %s\r\n", this.method, this.uri, this.version);
+ return this.method.value() + ' ' + this.uri + ' ' + this.version;
}
}
diff --git a/artipie-core/src/main/java/com/artipie/http/rq/RequestLineFrom.java b/artipie-core/src/main/java/com/artipie/http/rq/RequestLineFrom.java
index 382440ee6..afd9153d8 100644
--- a/artipie-core/src/main/java/com/artipie/http/rq/RequestLineFrom.java
+++ b/artipie-core/src/main/java/com/artipie/http/rq/RequestLineFrom.java
@@ -23,9 +23,8 @@
* {@code Request-Line = Method SP Request-URI SP HTTP-Version CRLF}.
*
* @see RFC2616
- * @since 0.1
*/
-public final class RequestLineFrom {
+final class RequestLineFrom {
/**
* HTTP request line.
@@ -36,7 +35,7 @@ public final class RequestLineFrom {
* Primary ctor.
* @param line HTTP request line
*/
- public RequestLineFrom(final String line) {
+ RequestLineFrom(final String line) {
this.line = line;
}
@@ -45,14 +44,7 @@ public RequestLineFrom(final String line) {
* @return Method name
*/
public RqMethod method() {
- final String string = this.part(0);
- return RqMethod.ALL
- .stream()
- .filter(method -> method.value().equals(string))
- .findAny()
- .orElseThrow(
- () -> new IllegalStateException(String.format("Unknown method: '%s'", string))
- );
+ return RqMethod.valueOf(this.part(0).toUpperCase());
}
/**
@@ -79,7 +71,6 @@ public String version() {
*/
private String part(final int idx) {
final String[] parts = this.line.trim().split("\\s");
- // @checkstyle MagicNumberCheck (1 line)
if (parts.length == 3) {
return parts[idx];
} else {
diff --git a/artipie-core/src/main/java/com/artipie/http/rq/RequestLinePrefix.java b/artipie-core/src/main/java/com/artipie/http/rq/RequestLinePrefix.java
index 83297c53d..6a82963fe 100644
--- a/artipie-core/src/main/java/com/artipie/http/rq/RequestLinePrefix.java
+++ b/artipie-core/src/main/java/com/artipie/http/rq/RequestLinePrefix.java
@@ -5,11 +5,9 @@
package com.artipie.http.rq;
import com.artipie.http.Headers;
-import java.util.Map;
/**
* Path prefix obtained from X-FullPath header and request line.
- * @since 0.16
*/
public final class RequestLinePrefix {
@@ -38,15 +36,6 @@ public RequestLinePrefix(final String line, final Headers headers) {
this.headers = headers;
}
- /**
- * Ctor.
- * @param line Request line
- * @param headers Request headers
- */
- public RequestLinePrefix(final String line, final Iterable> headers) {
- this(line, new Headers.From(headers));
- }
-
/**
* Obtains path prefix by `X-FullPath` header and request line. If header is absent, empty line
* is returned.
diff --git a/artipie-core/src/main/java/com/artipie/http/rq/RqHeaders.java b/artipie-core/src/main/java/com/artipie/http/rq/RqHeaders.java
index c60d3504e..a8ebcad92 100644
--- a/artipie-core/src/main/java/com/artipie/http/rq/RqHeaders.java
+++ b/artipie-core/src/main/java/com/artipie/http/rq/RqHeaders.java
@@ -4,11 +4,10 @@
*/
package com.artipie.http.rq;
+import com.artipie.http.Headers;
+
import java.util.AbstractList;
import java.util.List;
-import java.util.Map;
-import java.util.stream.Collectors;
-import java.util.stream.StreamSupport;
/**
* Request headers.
@@ -33,7 +32,6 @@
*
* > Field names are case-insensitive
*
- * @since 0.4
*/
public final class RqHeaders extends AbstractList {
@@ -47,11 +45,8 @@ public final class RqHeaders extends AbstractList {
* @param headers All headers
* @param name Header name
*/
- public RqHeaders(final Iterable> headers, final String name) {
- this.origin = StreamSupport.stream(headers.spliterator(), false)
- .filter(entry -> entry.getKey().equalsIgnoreCase(name))
- .map(Map.Entry::getValue)
- .collect(Collectors.toList());
+ public RqHeaders(Headers headers, String name) {
+ this.origin = headers.values(name);
}
@Override
@@ -90,7 +85,7 @@ public static final class Single {
* @param headers All header values
* @param name Header name
*/
- public Single(final Iterable> headers, final String name) {
+ public Single(final Headers headers, final String name) {
this.headers = new RqHeaders(headers, name);
}
diff --git a/artipie-core/src/main/java/com/artipie/http/rq/RqMethod.java b/artipie-core/src/main/java/com/artipie/http/rq/RqMethod.java
index 983f5fa9e..9026bebeb 100644
--- a/artipie-core/src/main/java/com/artipie/http/rq/RqMethod.java
+++ b/artipie-core/src/main/java/com/artipie/http/rq/RqMethod.java
@@ -60,11 +60,6 @@ public enum RqMethod {
*/
CONNECT("CONNECT");
- /**
- * Set of all existing methods.
- */
- public static final Set ALL = EnumSet.allOf(RqMethod.class);
-
/**
* String value.
*/
diff --git a/artipie-core/src/main/java/com/artipie/http/rq/RqParams.java b/artipie-core/src/main/java/com/artipie/http/rq/RqParams.java
index aa53a2100..9c7756856 100644
--- a/artipie-core/src/main/java/com/artipie/http/rq/RqParams.java
+++ b/artipie-core/src/main/java/com/artipie/http/rq/RqParams.java
@@ -4,45 +4,26 @@
*/
package com.artipie.http.rq;
-import com.google.common.base.Splitter;
-import java.io.UnsupportedEncodingException;
+import org.apache.hc.core5.http.NameValuePair;
+import org.apache.hc.core5.net.URIBuilder;
+
import java.net.URI;
-import java.net.URLDecoder;
-import java.util.Collections;
import java.util.List;
+import java.util.Objects;
import java.util.Optional;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-import java.util.stream.StreamSupport;
/**
* URI query parameters. See RFC .
- *
- * @since 0.18
*/
public final class RqParams {
- /**
- * Request query.
- */
- private final String query;
+ private final List params;
/**
- * Ctor.
- *
* @param uri Request URI.
*/
- public RqParams(final URI uri) {
- this(uri.getQuery());
- }
-
- /**
- * Ctor.
- *
- * @param query Request query.
- */
- public RqParams(final String query) {
- this.query = query;
+ public RqParams(URI uri) {
+ params = new URIBuilder(uri).getQueryParams();
}
/**
@@ -53,14 +34,11 @@ public RqParams(final String query) {
* @param name Parameter name.
* @return Parameter value.
*/
- public Optional value(final String name) {
- final Optional result;
- if (this.query == null) {
- result = Optional.empty();
- } else {
- result = this.findValues(name).findFirst();
- }
- return result;
+ public Optional value(String name) {
+ return params.stream()
+ .filter(p -> Objects.equals(name, p.getName()))
+ .map(NameValuePair::getValue)
+ .findFirst();
}
/**
@@ -71,49 +49,10 @@ public Optional value(final String name) {
* @param name Parameter name.
* @return List of Parameter values
*/
- public List extends String> values(final String name) {
- final List results;
- if (this.query == null) {
- results = Collections.emptyList();
- } else {
- results = this.findValues(name).collect(Collectors.toList());
- }
- return results;
- }
-
- /**
- * Find in query all values for given parameter name.
- * @param name Parameter name
- * @return Stream {@link Stream} of found values
- */
- private Stream findValues(final String name) {
- return StreamSupport.stream(
- Splitter.on("&").omitEmptyStrings().split(this.query).spliterator(), false
- ).flatMap(
- param -> {
- final String prefix = String.format("%s=", name);
- final Stream value;
- if (param.startsWith(prefix)) {
- value = Stream.of(param.substring(prefix.length()));
- } else {
- value = Stream.empty();
- }
- return value;
- }
- ).map(RqParams::decode);
- }
-
- /**
- * Decode string using URL-encoding.
- *
- * @param enc Encoded string
- * @return Decoded string
- */
- private static String decode(final String enc) {
- try {
- return URLDecoder.decode(enc, "UTF-8");
- } catch (final UnsupportedEncodingException err) {
- throw new IllegalStateException(err);
- }
+ public List values(String name) {
+ return params.stream()
+ .filter(p -> Objects.equals(name, p.getName()))
+ .map(NameValuePair::getValue)
+ .toList();
}
}
diff --git a/artipie-core/src/main/java/com/artipie/http/rq/multipart/Completion.java b/artipie-core/src/main/java/com/artipie/http/rq/multipart/Completion.java
index b6cb0b768..fa103c184 100644
--- a/artipie-core/src/main/java/com/artipie/http/rq/multipart/Completion.java
+++ b/artipie-core/src/main/java/com/artipie/http/rq/multipart/Completion.java
@@ -17,7 +17,6 @@ final class Completion {
/**
* Fake implementation for tests.
* @since 1.0
- * @checkstyle AnonInnerLengthCheck (25 lines)
*/
static final Completion> FAKE = new Completion<>(
new Subscriber() {
diff --git a/artipie-core/src/main/java/com/artipie/http/rq/multipart/MultiPart.java b/artipie-core/src/main/java/com/artipie/http/rq/multipart/MultiPart.java
index 05ec83ef0..d3e717c91 100644
--- a/artipie-core/src/main/java/com/artipie/http/rq/multipart/MultiPart.java
+++ b/artipie-core/src/main/java/com/artipie/http/rq/multipart/MultiPart.java
@@ -17,8 +17,6 @@
/**
* Multipart request part.
- * @since 1.0
- * @checkstyle MethodBodyCommentsCheck (500 lines)
*/
@SuppressWarnings("PMD.NullAssignment")
final class MultiPart implements RqMultipart.Part, ByteBufferTokenizer.Receiver, Subscription {
@@ -52,7 +50,7 @@ final class MultiPart implements RqMultipart.Part, ByteBufferTokenizer.Receiver,
/**
* Multipart header.
*/
- private final MultipartHeaders hdr;
+ private final MultipartHeaders mpartHeaders;
/**
* Downstream.
@@ -117,7 +115,7 @@ final class MultiPart implements RqMultipart.Part, ByteBufferTokenizer.Receiver,
this.tokenizer = new ByteBufferTokenizer(
this, MultiPart.DELIM.getBytes(), MultiPart.CAP_PART
);
- this.hdr = new MultipartHeaders(MultiPart.CAP_HEADER);
+ this.mpartHeaders = new MultipartHeaders(MultiPart.CAP_HEADER);
this.tmpacc = new BufAccumulator(MultiPart.CAP_HEADER);
this.lock = new Object();
this.exec = exec;
@@ -125,7 +123,7 @@ final class MultiPart implements RqMultipart.Part, ByteBufferTokenizer.Receiver,
@Override
public Headers headers() {
- return this.hdr;
+ return this.mpartHeaders.headers();
}
@Override
@@ -147,7 +145,7 @@ public void receive(final ByteBuffer next, final boolean end) {
if (this.head) {
this.nextChunk(next);
} else {
- this.hdr.push(next);
+ this.mpartHeaders.push(next);
if (end) {
this.head = true;
this.ready.accept(this);
diff --git a/artipie-core/src/main/java/com/artipie/http/rq/multipart/MultiParts.java b/artipie-core/src/main/java/com/artipie/http/rq/multipart/MultiParts.java
index 71bb437da..2567c2cfd 100644
--- a/artipie-core/src/main/java/com/artipie/http/rq/multipart/MultiParts.java
+++ b/artipie-core/src/main/java/com/artipie/http/rq/multipart/MultiParts.java
@@ -7,10 +7,13 @@
import com.artipie.ArtipieException;
import com.artipie.http.misc.ByteBufferTokenizer;
import com.artipie.http.misc.Pipeline;
+import com.artipie.http.trace.TraceContextExecutor;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.atomic.AtomicInteger;
import org.reactivestreams.Processor;
import org.reactivestreams.Publisher;
import org.reactivestreams.Subscriber;
@@ -20,15 +23,39 @@
* Multipart parts publisher.
*
* @since 1.0
- * @checkstyle MethodBodyCommentsCheck (500 lines)
*/
final class MultiParts implements Processor,
ByteBufferTokenizer.Receiver {
+ /**
+ * Pool name prefix for metrics identification.
+ */
+ public static final String POOL_NAME = "artipie.http.multipart";
+
/**
* Cached thread pool for parts processing.
+ * Pool name: {@value #POOL_NAME}.parts (visible in thread dumps and metrics).
+ * Wrapped with TraceContextExecutor to propagate MDC (trace.id, user, etc.) to parts threads.
+ */
+ private static final ExecutorService CACHED_PEXEC = TraceContextExecutor.wrap(
+ Executors.newCachedThreadPool(
+ new ThreadFactory() {
+ private final AtomicInteger counter = new AtomicInteger(0);
+ @Override
+ public Thread newThread(final Runnable runnable) {
+ final Thread thread = new Thread(runnable);
+ thread.setName(POOL_NAME + ".parts-" + counter.incrementAndGet());
+ thread.setDaemon(true);
+ return thread;
+ }
+ }
+ )
+ );
+
+ /**
+ * Counter for subscription executor threads.
*/
- private static final ExecutorService CACHED_PEXEC = Executors.newCachedThreadPool();
+ private static final AtomicInteger SUB_COUNTER = new AtomicInteger(0);
/**
* Upstream downstream pipeline.
@@ -87,7 +114,15 @@ final class MultiParts implements Processor,
this.tokenizer = new ByteBufferTokenizer(
this, boundary.getBytes(StandardCharsets.US_ASCII)
);
- this.exec = Executors.newSingleThreadExecutor();
+ this.exec = TraceContextExecutor.wrap(
+ Executors.newSingleThreadExecutor(
+ r -> {
+ final Thread thread = new Thread(r, POOL_NAME + ".sub-" + SUB_COUNTER.incrementAndGet());
+ thread.setDaemon(true);
+ return thread;
+ }
+ )
+ );
this.pipeline = new Pipeline<>();
this.completion = new Completion<>(this.pipeline);
this.state = new State();
diff --git a/artipie-core/src/main/java/com/artipie/http/rq/multipart/MultipartHeaders.java b/artipie-core/src/main/java/com/artipie/http/rq/multipart/MultipartHeaders.java
index 735b5577f..9c3c717be 100644
--- a/artipie-core/src/main/java/com/artipie/http/rq/multipart/MultipartHeaders.java
+++ b/artipie-core/src/main/java/com/artipie/http/rq/multipart/MultipartHeaders.java
@@ -7,12 +7,11 @@
import com.artipie.http.Headers;
import com.artipie.http.headers.Header;
import com.artipie.http.misc.BufAccumulator;
+
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
-import java.util.Iterator;
import java.util.Locale;
-import java.util.Map;
import java.util.stream.Collectors;
/**
@@ -24,9 +23,8 @@
* it lazy parses and construt headers collection.
* After reading headers iterable, the temporary buffer
* becomes invalid.
- * @since 1.0
*/
-final class MultipartHeaders implements Headers {
+final class MultipartHeaders {
/**
* Sync lock.
@@ -52,15 +50,13 @@ final class MultipartHeaders implements Headers {
this.accumulator = new BufAccumulator(cap);
}
- @Override
- @SuppressWarnings("PMD.NullAssignment")
- public Iterator> iterator() {
+ public Headers headers() {
if (this.cache == null) {
synchronized (this.lock) {
if (this.cache == null) {
final byte[] arr = this.accumulator.array();
final String hstr = new String(arr, StandardCharsets.US_ASCII);
- this.cache = new From(
+ this.cache = new Headers(
Arrays.stream(hstr.split("\r\n")).filter(str -> !str.isEmpty()).map(
line -> {
final String[] parts = line.split(":", 2);
@@ -75,7 +71,7 @@ public Iterator> iterator() {
this.accumulator.close();
}
}
- return this.cache.iterator();
+ return this.cache;
}
/**
diff --git a/artipie-core/src/main/java/com/artipie/http/rq/multipart/RqMultipart.java b/artipie-core/src/main/java/com/artipie/http/rq/multipart/RqMultipart.java
index a8cb535e0..522701628 100644
--- a/artipie-core/src/main/java/com/artipie/http/rq/multipart/RqMultipart.java
+++ b/artipie-core/src/main/java/com/artipie/http/rq/multipart/RqMultipart.java
@@ -8,16 +8,18 @@
import com.artipie.http.ArtipieHttpException;
import com.artipie.http.Headers;
import com.artipie.http.headers.ContentType;
-import com.artipie.http.rs.RsStatus;
+import com.artipie.http.headers.Header;
+import com.artipie.http.RsStatus;
import io.reactivex.Completable;
import io.reactivex.Flowable;
import io.reactivex.Single;
+import org.reactivestreams.Publisher;
+import wtf.g4s8.mime.MimeType;
+
import java.nio.ByteBuffer;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.function.Predicate;
-import org.reactivestreams.Publisher;
-import wtf.g4s8.mime.MimeType;
/**
* Multipart request.
@@ -33,14 +35,13 @@
* @implNote The implementation does not keep request part data in memory or storage,
* it should process each chunk and send to proper downstream.
* @implNote The body part will not be parsed until {@code parts()} method call.
- * @since 1.0
*/
public final class RqMultipart {
/**
* Content type.
*/
- private ContentType ctype;
+ private Header contentType;
/**
* Body upstream.
@@ -53,17 +54,17 @@ public final class RqMultipart {
* @param body Upstream
*/
public RqMultipart(final Headers headers, final Publisher body) {
- this(new ContentType(headers), body);
+ this(headers.single(ContentType.NAME), body);
}
/**
* Multipart request from content type and body upstream.
*
- * @param ctype Content type
+ * @param contentType Content type
* @param body Upstream
*/
- public RqMultipart(final ContentType ctype, final Publisher body) {
- this.ctype = ctype;
+ public RqMultipart(final Header contentType, final Publisher body) {
+ this.contentType = contentType;
this.upstream = body;
}
@@ -132,7 +133,7 @@ public Publisher extends Part> filter(final Predicate pred) {
* @return Boundary string
*/
private String boundary() {
- final String header = MimeType.of(this.ctype.getValue()).param("boundary").orElseThrow(
+ final String header = MimeType.of(this.contentType.getValue()).param("boundary").orElseThrow(
() -> new ArtipieHttpException(
RsStatus.BAD_REQUEST,
"Content-type boundary param missed"
@@ -239,8 +240,7 @@ public void ignore(final Part part) {
* Create filter single source which either returns accepted item, or
* drain ignored item and return empty after that.
* @return Single source
- * @checkstyle ReturnCountCheck (20 lines)
- */
+ */
@SuppressWarnings({"PMD.ConfusingTernary", "PMD.OnlyOneReturn"})
Single extends Part> filter() {
if (this.accepted != null) {
@@ -259,7 +259,6 @@ Single extends Part> filter() {
/**
* Check if part was accepted or rejected.
- * @param err
*/
private void check() {
if (this.accepted != null) {
diff --git a/artipie-core/src/main/java/com/artipie/http/rq/multipart/State.java b/artipie-core/src/main/java/com/artipie/http/rq/multipart/State.java
index b28a8ad80..f66ac7030 100644
--- a/artipie-core/src/main/java/com/artipie/http/rq/multipart/State.java
+++ b/artipie-core/src/main/java/com/artipie/http/rq/multipart/State.java
@@ -30,8 +30,8 @@
* This class defines all state and its transition and provide method to patch and check the state.
*
* @since 1.0
- * @checkstyle MagicNumberCheck (50 lines)
*/
+@SuppressWarnings("PMD.AvoidAccessToStaticMembersViaThis")
final class State {
/**
diff --git a/artipie-core/src/main/java/com/artipie/http/rs/CachedResponse.java b/artipie-core/src/main/java/com/artipie/http/rs/CachedResponse.java
deleted file mode 100644
index c7e30d553..000000000
--- a/artipie-core/src/main/java/com/artipie/http/rs/CachedResponse.java
+++ /dev/null
@@ -1,134 +0,0 @@
-/*
- * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
- * https://github.com/artipie/artipie/blob/master/LICENSE.txt
- */
-package com.artipie.http.rs;
-
-import com.artipie.asto.Content;
-import com.artipie.asto.ext.PublisherAs;
-import com.artipie.http.Connection;
-import com.artipie.http.Headers;
-import com.artipie.http.Response;
-import java.nio.ByteBuffer;
-import java.util.Arrays;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.CompletionStage;
-import java.util.stream.Collectors;
-import java.util.stream.StreamSupport;
-import org.reactivestreams.Publisher;
-
-/**
- * Response that caches origin response once it first sent and can replay it many times.
- * It can be useful when testing one response against multiple matchers, and response
- * from slice should be called only once.
- * @since 0.17
- */
-public final class CachedResponse implements Response {
-
- /**
- * Origin response.
- */
- private final Response origin;
-
- /**
- * Stateful connection.
- */
- private final StatefulConnection con;
-
- /**
- * Wraps response with stateful connection.
- * @param origin Origin response
- */
- public CachedResponse(final Response origin) {
- this.origin = origin;
- this.con = new StatefulConnection();
- }
-
- @Override
- public CompletionStage send(final Connection connection) {
- return this.con.load(this.origin).thenCompose(self -> self.replay(connection));
- }
-
- @Override
- public String toString() {
- return String.format(
- "(%s: state=%s)",
- this.getClass().getSimpleName(),
- this.con.toString()
- );
- }
-
- /**
- * Connection that keeps response state and can reply it to other connection.
- * @since 0.16
- */
- private static final class StatefulConnection implements Connection {
-
- /**
- * Response status.
- */
- private volatile RsStatus status;
-
- /**
- * Response headers.
- */
- private volatile Headers headers;
-
- /**
- * Response body.
- */
- private volatile byte[] body;
-
- @Override
- public CompletionStage accept(final RsStatus stts, final Headers hdrs,
- final Publisher bdy) {
- this.status = stts;
- this.headers = hdrs;
- return new PublisherAs(bdy).bytes().thenAccept(
- bytes -> this.body = bytes
- );
- }
-
- @Override
- public String toString() {
- return String.format(
- "(%s: status=%s, headers=[%s], body=%s)",
- this.getClass().getSimpleName(),
- this.status,
- StreamSupport.stream(this.headers.spliterator(), false)
- .map(
- header -> String.format(
- "\"%s\": \"%s\"",
- header.getKey(),
- header.getValue()
- )
- ).collect(Collectors.joining(", ")),
- Arrays.toString(this.body)
- );
- }
-
- /**
- * Load state from response if needed.
- * @param response Response to load the state
- * @return Self future
- */
- CompletionStage load(final Response response) {
- final CompletionStage self;
- if (this.status == null && this.headers == null && this.body == null) {
- self = response.send(this).thenApply(none -> this);
- } else {
- self = CompletableFuture.completedFuture(this);
- }
- return self;
- }
-
- /**
- * Reply self state to connection.
- * @param connection Connection
- * @return Future
- */
- CompletionStage replay(final Connection connection) {
- return connection.accept(this.status, this.headers, new Content.From(this.body));
- }
- }
-}
diff --git a/artipie-core/src/main/java/com/artipie/http/rs/RsFull.java b/artipie-core/src/main/java/com/artipie/http/rs/RsFull.java
deleted file mode 100644
index 7c58e4a93..000000000
--- a/artipie-core/src/main/java/com/artipie/http/rs/RsFull.java
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
- * https://github.com/artipie/artipie/blob/master/LICENSE.txt
- */
-
-package com.artipie.http.rs;
-
-import com.artipie.asto.Content;
-import com.artipie.http.Response;
-import java.nio.ByteBuffer;
-import java.util.Map;
-import org.reactivestreams.Publisher;
-
-/**
- * RsFull, response with status code, headers and body.
- *
- * @since 0.8
- */
-public final class RsFull extends Response.Wrap {
-
- /**
- * Ctor.
- * @param status Status code
- * @param headers Headers
- * @param body Response body
- */
- public RsFull(
- final RsStatus status,
- final Iterable> headers,
- final Publisher body) {
- this(status, headers, new Content.From(body));
- }
-
- /**
- * Ctor.
- * @param status Status code
- * @param headers Headers
- * @param body Response body
- */
- public RsFull(
- final RsStatus status,
- final Iterable> headers,
- final Content body) {
- super(
- new RsWithStatus(
- new RsWithHeaders(
- new RsWithBody(
- body
- ), headers
- ), status
- )
- );
- }
-}
diff --git a/artipie-core/src/main/java/com/artipie/http/rs/RsWithBody.java b/artipie-core/src/main/java/com/artipie/http/rs/RsWithBody.java
deleted file mode 100644
index e6d5bf4ce..000000000
--- a/artipie-core/src/main/java/com/artipie/http/rs/RsWithBody.java
+++ /dev/null
@@ -1,183 +0,0 @@
-/*
- * The MIT License (MIT) Copyright (c) 2020-2023 artipie.com
- * https://github.com/artipie/artipie/blob/master/LICENSE.txt
- */
-
-package com.artipie.http.rs;
-
-import com.artipie.asto.Content;
-import com.artipie.http.Connection;
-import com.artipie.http.Headers;
-import com.artipie.http.Response;
-import com.artipie.http.headers.ContentLength;
-import io.reactivex.Flowable;
-import java.nio.ByteBuffer;
-import java.nio.charset.Charset;
-import java.util.Optional;
-import java.util.concurrent.CompletionStage;
-import org.reactivestreams.Publisher;
-
-/**
- * Response with body.
- * @since 0.3
- */
-public final class RsWithBody implements Response {
-
- /**
- * Origin response.
- */
- private final Response origin;
-
- /**
- * Body content.
- */
- private final Content body;
-
- /**
- * Decorates response with new text body.
- * @param origin Response to decorate
- * @param body Text body
- * @param charset Encoding
- */
- public RsWithBody(final Response origin, final CharSequence body, final Charset charset) {
- this(origin, ByteBuffer.wrap(body.toString().getBytes(charset)));
- }
-
- /**
- * Creates new response with text body.
- * @param body Text body
- * @param charset Encoding
- */
- public RsWithBody(final CharSequence body, final Charset charset) {
- this(ByteBuffer.wrap(body.toString().getBytes(charset)));
- }
-
- /**
- * Creates new response from byte buffer.
- * @param buf Buffer body
- */
- public RsWithBody(final ByteBuffer buf) {
- this(StandardRs.EMPTY, buf);
- }
-
- /**
- * Decorates origin response body with byte buffer.
- * @param origin Response
- * @param bytes Byte array
- */
- public RsWithBody(final Response origin, final byte[] bytes) {
- this(origin, ByteBuffer.wrap(bytes));
- }
-
- /**
- * Decorates origin response body with byte buffer.
- * @param origin Response
- * @param buf Body buffer
- */
- public RsWithBody(final Response origin, final ByteBuffer buf) {
- this(origin, new Content.From(Optional.of((long) buf.remaining()), Flowable.just(buf)));
- }
-
- /**
- * Creates new response with body publisher.
- * @param body Publisher
- */
- public RsWithBody(final Publisher body) {
- this(StandardRs.EMPTY, body);
- }
-
- /**
- * Response with body from publisher.
- * @param origin Origin response
- * @param body Publisher
- */
- public RsWithBody(final Response origin, final Publisher body) {
- this(origin, new Content.From(body));
- }
-
- /**
- * Creates new response with body content.
- *
- * @param body Content.
- */
- public RsWithBody(final Content body) {
- this(StandardRs.EMPTY, body);
- }
-
- /**
- * Decorates origin response body with content.
- * @param origin Response
- * @param body Content
- */
- public RsWithBody(final Response origin, final Content body) {
- this.origin = origin;
- this.body = body;
- }
-
- @Override
- public CompletionStage send(final Connection con) {
- return withHeaders(this.origin, this.body.size()).send(new ConWithBody(con, this.body));
- }
-
- @Override
- public String toString() {
- return String.format(
- "(%s: origin='%s', body='%s')",
- this.getClass().getSimpleName(),
- this.origin.toString(),
- this.body.toString()
- );
- }
-
- /**
- * Wrap response with headers if size provided.
- * @param origin Origin response
- * @param size Maybe size
- * @return Wrapped response
- */
- private static Response withHeaders(final Response origin, final Optional size) {
- return size.